From 211c40bf58045a58adae3cacbeff1a0c1e4e7567 Mon Sep 17 00:00:00 2001 From: Hannes Achleitner Date: Sat, 26 Sep 2026 19:25:24 +0200 Subject: [PATCH 01/22] Add KotlinMultiplatform plan --- KotlinMultiplatform.md | 234 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 KotlinMultiplatform.md diff --git a/KotlinMultiplatform.md b/KotlinMultiplatform.md new file mode 100644 index 00000000..50efcd7e --- /dev/null +++ b/KotlinMultiplatform.md @@ -0,0 +1,234 @@ +# Kotlin Multiplatform (KMP) Plan for AndroidChart + +This document tracks the plan to add a Kotlin Multiplatform library and demo app to +AndroidChart, alongside the existing `chartLib` (View-based) and `chartLibCompose` +(Android-only Compose wrapper) modules. + +## Why this is not a small config change + +`chartLib` and `chartLibCompose` are deeply coupled to Android APIs: + +- `chartLib` uses `android.graphics.Canvas/Paint/Bitmap/Matrix/RectF/Typeface/DashPathEffect/ + Drawable`, `android.view.View/MotionEvent/ViewGroup`, `android.content.Context`, + `android.os.Parcelable/Handler` throughout renderers, data classes, and components. +- `chartLibCompose` does **not** draw with Compose `Canvas`/`DrawScope`. Every + `*Composable.kt` wraps the existing View-based chart via `AndroidView` interop + (`androidx.compose.ui.viewinterop.AndroidView`). It is Android-only end to end. + +A scan of `data/`, `components/`, `utils/`, `interfaces/` shows Android types baked +directly into field/property signatures (not just implementation details), e.g.: + +| Android type | Where used | Occurrences | +|----------------------------|-----------------------------------------------|-------------| +| `android.graphics.drawable.Drawable` | `BaseEntry.icon`, `MarkerImage` | 17 | +| `android.annotation.SuppressLint` | misc | 14 | +| `android.graphics.DashPathEffect` | `IDataSet`, axis/line dash styling | 6 | +| `android.graphics.Color` | `ColorTemplate`, components | 6 | +| `android.os.Build` | version checks | 4 | +| `android.graphics.Typeface` | `ComponentBase`, `IDataSet` | 3 | +| `android.os.Parcelable`/`Parcel` | `EntryFloat`, `PointF`, etc. | 4 | +| `android.graphics.Paint` | `AxisBase`, renderers | 2 | +| `android.content.Context` | `AssetManagerUtils`, `ContextUtils` | 2 | + +Because of this, there is no small "first slice" that avoids introducing common +(platform-agnostic) replacement abstractions — nearly every core class in `data/`, +`components/`, and `utils/` needs at least one Android type removed from its public +API before it can move into a `commonMain` source set. + +## Two possible strategies (recap) + +**A. Compose Multiplatform rewrite (recommended)** — rewrite `chartLibCompose` (or a +new sibling module) to draw with `androidx.compose.foundation.Canvas` / `DrawScope` +instead of wrapping `AndroidView`, and target Kotlin Multiplatform + JetBrains Compose +Multiplatform (Android, iOS, Desktop, Wasm/JS). This reuses Compose's own drawing, +gesture, and text APIs instead of reinventing Canvas/Paint/Matrix abstractions. + +**B. Full KMP for `chartLib` itself** — keep the View-based renderer but introduce +`expect`/`actual` for `Canvas`, `Paint`, `Bitmap`, `Context`, gesture input, etc. Far +larger in scope (touches ~180 files, all 22+ renderers, all touch listeners, save/export +utils) and duplicates what Compose Multiplatform already provides. Not recommended +unless non-Compose consumers on other platforms are a hard requirement. + +This plan follows **Strategy A**, built up incrementally by first extracting a +platform-agnostic core data/logic module (`chartLibCore`) that both the existing +Android `chartLib` and a future Compose Multiplatform renderer can share. + +## Module layout (target end state) + +``` +chartLib Android View-based chart library (unchanged, Android-only) +chartLibCore NEW: Kotlin Multiplatform pure-Kotlin data model & logic + (commonMain + androidMain + iosMain + desktop jvm) +chartLibCompose Android-only Compose wrapper around chartLib (kept as-is for + AndroidView-based consumers, or deprecated once + chartLibComposeMultiplatform matures) +chartLibComposeMultiplatform NEW: Compose Multiplatform renderer built on + chartLibCore + Compose Canvas/DrawScope + (commonMain + androidMain + iosMain + desktop + wasmJs) +app Existing Android demo app (unchanged) +demoKmp NEW: Compose Multiplatform demo app consuming + chartLibComposeMultiplatform on Android/iOS/Desktop/Wasm +lint Existing custom lint rules (unchanged) +``` + +## Status + +- [x] **Step 0 — Feasibility scan.** Confirmed Xcode toolchain is available locally; + AGP 9.2.1 requires the new `com.android.kotlin.multiplatform.library` plugin + (not `com.android.library`) for KMP modules with an Android target. +- [x] **Step A.1 — Module scaffold + first portable-class extraction.** + Created `chartLibCore` (KMP: `androidTarget`, `jvm("desktop")`, `iosX64`, + `iosArm64`, `iosSimulatorArm64`). Moved the 5 classes with zero true Android + coupling into `commonMain`, preserving package names so no call sites changed: + - `utils/ObjectPool.kt` (removed JVM-only `@Synchronized`, see note below) + - `utils/FSize.kt` + - `utils/PointD.kt` + - `utils/ColorTemplate.kt` (replaced `android.graphics.Color.rgb(...)` with a + common `argb()` helper) + - `highlight/Range.kt` + - `highlight/RangeDouble.kt` + - Moved `ObjectPoolTest` into `commonTest`, ported JUnit → `kotlin.test`. + - `chartLib` now depends on `chartLibCore` via `api(project(":chartLibCore"))`. + - Verified: `chartLibCore` builds/tests green on Android, desktop-JVM, and all + 3 iOS targets; `chartLib:test`, `chartLibCompose:assembleDebug`, and the full + `./gradlew test` all still pass unchanged. + - **Known trade-off:** `ObjectPool` lost `@Synchronized` (JVM-only annotation, + unavailable in `commonMain`). Revisit with `kotlinx-atomicfu` or a + platform-specific `expect`/`actual` lock if thread-safety across pool + instances becomes a real requirement on non-JVM targets. +- [ ] **Step A.2 — Design common replacement abstractions.** (see below) +- [ ] **Step A.3 — Migrate the data model (`data/`, `interfaces/`).** +- [ ] **Step A.4 — Migrate formatters (`formatter/`) and remaining highlighters + (`highlight/`).** +- [ ] **Step A.5 — Migrate `components/` (axes, legend, limit lines).** +- [ ] **Step A.6 — Migrate `utils/` geometry & viewport math + (`ViewPortHandler`, `Transformer`, `PointF`).** +- [ ] **Step A.7 — New `chartLibComposeMultiplatform` module**: Compose + Multiplatform renderers built on `chartLibCore` using `DrawScope`. +- [ ] **Step A.8 — Gesture handling** with `pointerInput`/`detectTransformGestures` + replacing `ChartTouchListener`/`MotionEvent`. +- [ ] **Step A.9 — `demoKmp` Compose Multiplatform demo app** + (Android, iOS, Desktop, optionally Wasm/JS). +- [ ] **Step A.10 — CI.** Extend GitHub Actions to build iOS/desktop/wasm targets + (today only an Android emulator runs instrumentation tests). +- [ ] **Step A.11 — Publishing.** Extend `com.vanniktech.maven.publish` KMP + publication support with per-target artifact coordinates for `chartLibCore` + and `chartLibComposeMultiplatform`. + +## Step A.2 — Common replacement abstractions needed before further migration + +These must exist in `chartLibCore` (or be designed) before the corresponding +Android-coupled classes can move: + +| Android type | Common replacement | Consumers to update | +|-----------------------------------|-----------------------------------------------------------------------------|----------------------| +| `android.graphics.Color` (Int) | Already just an `Int` ARGB value at the API boundary — no wrapper needed, just remove the `android.graphics.Color` import/usages (done for `ColorTemplate`; remaining in `components/`). | `ComponentBase`, `AxisBase` | +| `android.graphics.DashPathEffect` | `data class DashEffect(val intervals: FloatArray, val phase: Float)` in `chartLibCore`; Android renderer maps it to a real `DashPathEffect` at draw time. | `IDataSet`, `LineDataSet`, `BarLineScatterCandleBubbleDataSet`, renderers | +| `android.graphics.Typeface` | `expect class ChartTypeface` (or drop entirely from the data model and let each renderer own font/typeface as a rendering concern, not a dataset concern). | `ComponentBase`, `IDataSet` | +| `android.graphics.drawable.Drawable` (`icon`) | `expect class ChartIcon` wrapping a platform image handle (`Drawable` on Android, `UIImage`/`Painter`-friendly type on Compose Multiplatform), or make icon rendering a Compose-only concern (`painterResource` at the composable layer, not baked into `EntryFloat`). | `BaseEntry`, `EntryFloat`, `MarkerImage` | +| `android.os.Parcelable` | Drop from common data classes. Parcelable is an Android IPC/state-restoration detail, not core chart data. If Android call sites rely on `Parcelable` (e.g. `EntryFloat` in a `Bundle`), add an Android-only wrapper/extension in `chartLib`'s `androidMain`. | `EntryFloat`, `PointF` | +| `android.graphics.Matrix` / `RectF` | Pure-Kotlin `Matrix3x2`/`Rect` value classes for the shared viewport math in `ViewPortHandler`/`Transformer`; Android renderer converts to/from `android.graphics.Matrix`/`RectF` only at the drawing boundary. | `ViewPortHandler`, `Transformer`, `TransformerHorizontalBarChart` | +| `android.view.View` (in `ViewPortHandler`) | Remove the `View` reference from shared viewport math; pass width/height as plain values instead. | `ViewPortHandler` | +| `android.animation.TimeInterpolator` (`Easing`) | Plain Kotlin `fun interface Easing { fun getInterpolation(input: Float): Float }`; Android animator wraps it in a `TimeInterpolator` adapter at the call site. | `ChartAnimator`, `jobs/*` | +| `timber.log.Timber` (Android-only logging) | Introduce a tiny common logging `expect`/`actual` facade (`ChartLog.d(...)`), or simply drop debug logging from common code and keep it Android-only via `androidMain`. | `NumberUtils`, `ViewPortHandler`, `AxisBase` | +| `android.annotation.SuppressLint` / `@TargetApi` | Just remove — these are Android lint/build-tool annotations with no multiplatform meaning. | `ChartHighlighter`, `HorizontalBarHighlighter`, others | +| `android.content.Context` (`AssetManagerUtils`, `ContextUtils`) | Keep these Android-only; they stay in `chartLib`'s `androidMain`, not `commonMain`. | `AssetManagerUtils`, `ContextUtils` | + +## Migration order rationale (Steps A.3–A.6) + +The dependency graph forces a specific order — nothing in `formatter/` or +`highlight/` can move until `data/EntryFloat`, `interfaces/datasets/IDataSet`, and +`utils/ViewPortHandler` are portable, because every formatter/highlighter references +at least one of them: + +1. **`data/` + `interfaces/`** — apply the `DashEffect`/`ChartTypeface`/`ChartIcon`/ + dropped-`Parcelable` abstractions from Step A.2 to `BaseEntry`, `EntryFloat`, + `EntryDouble`, all `*Entry*` subclasses, `BaseDataSet`, `DataSet`, all + `*DataSet` subclasses, `ChartData` and subclasses, and all `interfaces/datasets/*` + / `interfaces/dataprovider/*`. +2. **`formatter/`** — zero Android imports already; only blocked by `EntryFloat`, + `IDataSet`, `ViewPortHandler`, `AxisBase`. Once `data/`+`interfaces/` move, only + `ViewPortHandler`/`AxisBase` block the formatters that reference them + (`DefaultValueFormatter`, `IValueFormatter`, `LargeValueFormatter`, + `PercentFormatter`, `StackedValueFormatter`) — see Step A.6. +3. **`highlight/`** — `Range`/`RangeDouble` already moved. The rest + (`Highlight`, `IHighlighter`, `BarHighlighter`, `CombinedHighlighter`, + `PieHighlighter`, `PieRadarHighlighter`, `RadarHighlighter`, `ChartHighlighter`, + `HorizontalBarHighlighter`) only need `@SuppressLint` removed plus the `data/` + and `interfaces/` migration from step 1. Note: `PieHighlighter`/`RadarHighlighter`/ + `PieRadarHighlighter` reference `charts/PieChart`, `charts/RadarChart`, + `charts/PieRadarChartBase` directly — these need to depend on new common + *provider interfaces* instead of concrete `View`-based chart classes (introduce + `interfaces/dataprovider` equivalents if not already sufficient), so the + highlighter logic doesn't require the Android `View` subclasses at all. +4. **`components/`** — `ComponentBase`/`AxisBase` need `Typeface`/`Paint`/`Color` + removed per Step A.2. `Legend`, `LegendEntry`, `XAxis`, `YAxis`, `LimitLine`, + `LimitRange`, `Description` mostly extend/compose these, so they follow + naturally once the base classes are portable. `MarkerView`/`MarkerImage` + (Android `View` subclasses) stay in `chartLib`'s `androidMain` — they are + inherently platform UI, not shared logic. +5. **`utils/` geometry** — `ViewPortHandler`, `Transformer`, + `TransformerHorizontalBarChart` need the `Matrix`/`RectF`/`View` replacement + from Step A.2. `PointF` needs `Parcelable` dropped from the common variant + (keep an Android-only `Parcelable` extension if needed for `Bundle` interop). + `CanvasUtils`, `SaveUtils`, `PaintUtils`, `AssetManagerUtils`, `ContextUtils` + stay Android-only (`androidMain`) — they are fundamentally platform IO/graphics + concerns, not shared chart logic. + +## Step A.7+ — Compose Multiplatform renderer + +Once `chartLibCore` covers data/formatters/highlighters/components/viewport math, +create `chartLibComposeMultiplatform`: + +- Apply `org.jetbrains.kotlin.multiplatform` + `org.jetbrains.compose` (JetBrains + Compose Multiplatform gradle plugin, not just `org.jetbrains.kotlin.plugin.compose` + which only enables the compiler plugin for Android/JVM-only Compose). +- Reimplement each renderer (`LineChartRenderer`, `BarChartRenderer`, + `PieChartRenderer`, etc.) as `DrawScope` extension functions using + `drawLine`/`drawPath`/`drawCircle`/`drawText` instead of `Canvas.drawX(Paint)`. + This is the single largest remaining chunk of work (~32 renderer files). +- Replace touch/gesture handling (`ChartTouchListener`, `BarLineChartTouchListener`, + `PieRadarChartTouchListener`) with Compose `pointerInput` + + `detectTransformGestures`/`detectDragGestures`. +- Target `androidTarget()`, `iosX64/iosArm64/iosSimulatorArm64()`, + `jvm("desktop")`, and optionally `wasmJs { browser() }`. +- Structure source sets as `commonMain` (chart composables + draw logic), + `androidMain`, `iosMain`, `desktopMain`, `wasmJsMain` only for platform-specific + bits (e.g., font loading, bitmap export). + +## Step A.9 — `demoKmp` app + +- New Compose Multiplatform application module with `commonMain` (shared UI + + chart usage examples mirroring `chartLibCompose`'s `examples/ChartExamples.kt`), + `androidMain` (activity entry point), `iosMain` (SwiftUI/Compose entry point via + `MainViewController`), `desktopMain` (`main()` launching a `ComposeWindow`), and + optionally `wasmJsMain`. +- Register in `settings.gradle.kts` as `include(":demoKmp")`. + +## Step A.10 — CI + +- Extend `.github/workflows` to run `./gradlew :chartLibCore:allTests` and + `:chartLibComposeMultiplatform:allTests` on macOS runners (required for iOS + targets) in addition to the existing Android emulator instrumentation job. +- Add a desktop smoke-test / screenshot job for `demoKmp` if feasible. + +## Step A.11 — Publishing + +- Extend `mavenPublishing { }` blocks in `chartLibCore` and + `chartLibComposeMultiplatform` for KMP publications (each target publishes its + own artifact suffix, e.g. `chartLibCore-android`, `chartLibCore-iosarm64`, + `chartLibCore-iossimulatorarm64`, `chartLibCore-iosx64`, + `chartLibCore-desktop`, plus a `chartLibCore` metadata/common artifact). +- Update `jitpack.yml` / README to document new KMP coordinates once published. + +## Notes / open questions for maintainers + +- This roughly doubles the surface area to keep in sync with the existing + View-based `chartLib`, which is explicitly in **maintenance mode**. Confirm + there's real demand/maintainer bandwidth before committing to Steps A.7–A.11. +- Decide whether `chartLibCompose` (AndroidView-wrapper) stays long-term as a + lighter-weight Android-only option, or is deprecated once + `chartLibComposeMultiplatform` reaches parity. +- Decide on `wasmJs`/JS support scope now vs. later — it adds meaningful + complexity (font rendering, canvas API differences) for comparatively niche + demand relative to Android/iOS/Desktop. From 178ae69e12e34cc0df50ef5c44be2b3c81a148cc Mon Sep 17 00:00:00 2001 From: Hannes Achleitner Date: Sat, 26 Sep 2026 19:26:45 +0200 Subject: [PATCH 02/22] Created a new Kotlin Multiplatform module and extract 5 genuinely platform-independent classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Created a new chartLibCore Kotlin Multiplatform module (com.android.kotlin.multiplatform.library + org.jetbrains.kotlin.multiplatform) targeting androidTarget, jvm("desktop"), and iosX64/iosArm64/iosSimulatorArm64. * Extracted the 5 genuinely platform-independent classes into its commonMain (same package names, so no call-site changes needed elsewhere): ObjectPool, FSize, PointD, ColorTemplate, highlight.Range, highlight.RangeDouble. * Fixed the only two real platform couplings found: replaced android.graphics.Color.rgb(...) in ColorTemplate with a common argb() helper, and dropped the JVM-only @Synchronized annotations in ObjectPool (common code can't assume JVM's synchronized — the class is not thread-safety-critical against instrumentation tests as run today, but flagging this trade-off). * Moved ObjectPoolTest into commonTest, converted its JUnit assertions to kotlin.test. * Registered chartLibCore in settings.gradle.kts and made chartLib depend on it (api(project(":chartLibCore"))). * Verified: chartLibCore builds/tests pass on Android, desktop-JVM, and all 3 iOS targets; chartLib:test, chartLibCompose:assembleDebug, and the full ./gradlew test all still pass unchanged. --- chartLib/build.gradle.kts | 1 + chartLibCore/build.gradle.kts | 36 +++++ .../info/appdev/charting/highlight/Range.kt | 0 .../appdev/charting/highlight/RangeDouble.kt | 0 .../appdev/charting/utils/ColorTemplate.kt | 64 +++++---- .../info/appdev/charting/utils/FSize.kt | 0 .../info/appdev/charting/utils/ObjectPool.kt | 4 - .../info/appdev/charting/utils/PointD.kt | 0 .../appdev/charting/test/ObjectPoolTest.kt | 130 +++++++++--------- settings.gradle.kts | 1 + 10 files changed, 138 insertions(+), 98 deletions(-) create mode 100644 chartLibCore/build.gradle.kts rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/highlight/Range.kt (100%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/highlight/RangeDouble.kt (100%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/utils/ColorTemplate.kt (63%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/utils/FSize.kt (100%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/utils/ObjectPool.kt (98%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/utils/PointD.kt (100%) rename {chartLib/src/test => chartLibCore/src/commonTest}/kotlin/info/appdev/charting/test/ObjectPoolTest.kt (60%) diff --git a/chartLib/build.gradle.kts b/chartLib/build.gradle.kts index cdc1e7ce..f9a0d2df 100644 --- a/chartLib/build.gradle.kts +++ b/chartLib/build.gradle.kts @@ -55,6 +55,7 @@ dependencies { lintChecks(project(":lint")) // applies locally lintPublish(project(path = ":lint", configuration = "lintJar")) // embeds in published AAR + api(project(":chartLibCore")) implementation("androidx.annotation:annotation:1.10.0") implementation("androidx.core:core:1.16.0") implementation("androidx.activity:activity-ktx:1.10.1") diff --git a/chartLibCore/build.gradle.kts b/chartLibCore/build.gradle.kts new file mode 100644 index 00000000..bb88abc5 --- /dev/null +++ b/chartLibCore/build.gradle.kts @@ -0,0 +1,36 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("com.android.kotlin.multiplatform.library") + id("org.jetbrains.kotlin.multiplatform") +} + +kotlin { + android { + namespace = "info.appdev.charting.core" + compileSdk = 36 + minSdk = 23 + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + } + + jvm("desktop") + + val xcfName = "chartLibCoreKit" + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64() + ).forEach { iosTarget -> + iosTarget.binaries.framework { + baseName = xcfName + } + } + + sourceSets { + commonTest.dependencies { + implementation(kotlin("test")) + } + } +} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/highlight/Range.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/highlight/Range.kt similarity index 100% rename from chartLib/src/main/kotlin/info/appdev/charting/highlight/Range.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/highlight/Range.kt diff --git a/chartLib/src/main/kotlin/info/appdev/charting/highlight/RangeDouble.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/highlight/RangeDouble.kt similarity index 100% rename from chartLib/src/main/kotlin/info/appdev/charting/highlight/RangeDouble.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/highlight/RangeDouble.kt diff --git a/chartLib/src/main/kotlin/info/appdev/charting/utils/ColorTemplate.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ColorTemplate.kt similarity index 63% rename from chartLib/src/main/kotlin/info/appdev/charting/utils/ColorTemplate.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ColorTemplate.kt index 43c2b6dd..1809971b 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/utils/ColorTemplate.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ColorTemplate.kt @@ -1,7 +1,5 @@ package info.appdev.charting.utils -import android.graphics.Color - /** * Class that holds predefined color integer arrays (e.g. ColorTemplate.VORDIPLOM_COLORS) * and convenience methods for loading colors from resources. @@ -23,39 +21,39 @@ object ColorTemplate { * FREE TO CREATE YOUR OWN WITH AS MANY DIFFERENT COLORS AS YOU WANT */ val LIBERTY_COLORS: IntArray = intArrayOf( - Color.rgb(207, 248, 246), - Color.rgb(148, 212, 212), - Color.rgb(136, 180, 187), - Color.rgb(118, 174, 175), - Color.rgb(42, 109, 130) + argb(207, 248, 246), + argb(148, 212, 212), + argb(136, 180, 187), + argb(118, 174, 175), + argb(42, 109, 130) ) val JOYFUL_COLORS: IntArray = intArrayOf( - Color.rgb(217, 80, 138), - Color.rgb(254, 149, 7), - Color.rgb(254, 247, 120), - Color.rgb(106, 167, 134), - Color.rgb(53, 194, 209) + argb(217, 80, 138), + argb(254, 149, 7), + argb(254, 247, 120), + argb(106, 167, 134), + argb(53, 194, 209) ) val PASTEL_COLORS: IntArray = intArrayOf( - Color.rgb(64, 89, 128), - Color.rgb(149, 165, 124), - Color.rgb(217, 184, 162), - Color.rgb(191, 134, 134), - Color.rgb(179, 48, 80) + argb(64, 89, 128), + argb(149, 165, 124), + argb(217, 184, 162), + argb(191, 134, 134), + argb(179, 48, 80) ) val COLORFUL_COLORS: IntArray = intArrayOf( - Color.rgb(193, 37, 82), - Color.rgb(255, 102, 0), - Color.rgb(245, 199, 0), - Color.rgb(106, 150, 31), - Color.rgb(179, 100, 53) + argb(193, 37, 82), + argb(255, 102, 0), + argb(245, 199, 0), + argb(106, 150, 31), + argb(179, 100, 53) ) val VORDIPLOM_COLORS: IntArray = intArrayOf( - Color.rgb(192, 255, 140), - Color.rgb(255, 247, 140), - Color.rgb(255, 208, 140), - Color.rgb(140, 234, 255), - Color.rgb(255, 140, 157) + argb(192, 255, 140), + argb(255, 247, 140), + argb(255, 208, 140), + argb(140, 234, 255), + argb(255, 140, 157) ) val MATERIAL_COLORS: IntArray = intArrayOf( rgb("#2ecc71"), @@ -72,14 +70,14 @@ object ColorTemplate { val r = (color shr 16) and 0xFF val g = (color shr 8) and 0xFF val b = (color) and 0xFF - return Color.rgb(r, g, b) + return argb(r, g, b) } /** * Returns the Android ICS holo blue light color. */ val holoBlue: Int - get() = Color.rgb(51, 181, 229) + get() = argb(51, 181, 229) /** * Sets the alpha component of the given color. @@ -99,4 +97,12 @@ object ColorTemplate { } return result } + + /** + * Builds a fully opaque ARGB color int from the given red/green/blue components (0-255), + * platform-independent replacement for android.graphics.Color.rgb(r, g, b). + */ + private fun argb(red: Int, green: Int, blue: Int): Int { + return (0xFF shl 24) or ((red and 0xFF) shl 16) or ((green and 0xFF) shl 8) or (blue and 0xFF) + } } diff --git a/chartLib/src/main/kotlin/info/appdev/charting/utils/FSize.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/FSize.kt similarity index 100% rename from chartLib/src/main/kotlin/info/appdev/charting/utils/FSize.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/FSize.kt diff --git a/chartLib/src/main/kotlin/info/appdev/charting/utils/ObjectPool.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ObjectPool.kt similarity index 98% rename from chartLib/src/main/kotlin/info/appdev/charting/utils/ObjectPool.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ObjectPool.kt index 4386d78d..11bf71fe 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/utils/ObjectPool.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ObjectPool.kt @@ -66,7 +66,6 @@ class ObjectPool> private constructor(withCapacity: Int, poolObj * * @return An instance of Poolable object T */ - @Synchronized fun get(): T { if (objects.isEmpty() && this.replenishPercentage > 0.0f) { this.refillPool() @@ -84,7 +83,6 @@ class ObjectPool> private constructor(withCapacity: Int, poolObj * * @param object An object of type T to recycle */ - @Synchronized fun recycle(`object`: T?) { if (`object` == null) return @@ -108,7 +106,6 @@ class ObjectPool> private constructor(withCapacity: Int, poolObj * * @param objects A list of objects of type T to recycle */ - @Synchronized fun recycle(objects: List) { val objectsListSize = objects.size @@ -171,7 +168,6 @@ class ObjectPool> private constructor(withCapacity: Int, poolObj * @param object An instance of the object that the pool should recycle. * @return */ - @Synchronized fun > create(withCapacity: Int, `object`: T): ObjectPool { val result: ObjectPool = ObjectPool(withCapacity, `object`) result.poolId = ids diff --git a/chartLib/src/main/kotlin/info/appdev/charting/utils/PointD.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/PointD.kt similarity index 100% rename from chartLib/src/main/kotlin/info/appdev/charting/utils/PointD.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/PointD.kt diff --git a/chartLib/src/test/kotlin/info/appdev/charting/test/ObjectPoolTest.kt b/chartLibCore/src/commonTest/kotlin/info/appdev/charting/test/ObjectPoolTest.kt similarity index 60% rename from chartLib/src/test/kotlin/info/appdev/charting/test/ObjectPoolTest.kt rename to chartLibCore/src/commonTest/kotlin/info/appdev/charting/test/ObjectPoolTest.kt index 6e16a06d..4e11d5fb 100644 --- a/chartLib/src/test/kotlin/info/appdev/charting/test/ObjectPoolTest.kt +++ b/chartLibCore/src/commonTest/kotlin/info/appdev/charting/test/ObjectPoolTest.kt @@ -2,8 +2,8 @@ package info.appdev.charting.test import info.appdev.charting.utils.ObjectPool import info.appdev.charting.utils.ObjectPool.Poolable -import org.junit.Assert -import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.Test class ObjectPoolTest { internal class TestPoolable private constructor(var foo: Int, var bar: Int) : Poolable() { @@ -41,37 +41,37 @@ class ObjectPoolTest { var poolCount = TestPoolable.getPool().poolCount val testPoolables = ArrayList() - Assert.assertEquals(4, poolCapacity) - Assert.assertEquals(4, poolCount) + assertEquals(4, poolCapacity) + assertEquals(4, poolCount) var testPoolable = TestPoolable.getInstance(6, 7) - Assert.assertEquals(6, testPoolable.foo) - Assert.assertEquals(7, testPoolable.bar) + assertEquals(6, testPoolable.foo) + assertEquals(7, testPoolable.bar) poolCapacity = TestPoolable.getPool().poolCapacity poolCount = TestPoolable.getPool().poolCount - Assert.assertEquals(4, poolCapacity) - Assert.assertEquals(3, poolCount) + assertEquals(4, poolCapacity) + assertEquals(3, poolCount) TestPoolable.recycleInstance(testPoolable) poolCapacity = TestPoolable.getPool().poolCapacity poolCount = TestPoolable.getPool().poolCount - Assert.assertEquals(4, poolCapacity) - Assert.assertEquals(4, poolCount) + assertEquals(4, poolCapacity) + assertEquals(4, poolCount) testPoolable = TestPoolable.getInstance(20, 30) - Assert.assertEquals(20, testPoolable.foo) - Assert.assertEquals(30, testPoolable.bar) + assertEquals(20, testPoolable.foo) + assertEquals(30, testPoolable.bar) TestPoolable.recycleInstance(testPoolable) poolCapacity = TestPoolable.getPool().poolCapacity poolCount = TestPoolable.getPool().poolCount - Assert.assertEquals(4, poolCapacity) - Assert.assertEquals(4, poolCount) + assertEquals(4, poolCapacity) + assertEquals(4, poolCount) testPoolables.add(TestPoolable.getInstance(12, 24)) testPoolables.add(TestPoolable.getInstance(1, 2)) @@ -80,15 +80,15 @@ class ObjectPoolTest { poolCapacity = TestPoolable.getPool().poolCapacity poolCount = TestPoolable.getPool().poolCount - Assert.assertEquals(4, poolCapacity) - Assert.assertEquals(0, poolCount) + assertEquals(4, poolCapacity) + assertEquals(0, poolCount) TestPoolable.recycleInstances(testPoolables) poolCapacity = TestPoolable.getPool().poolCapacity poolCount = TestPoolable.getPool().poolCount - Assert.assertEquals(4, poolCapacity) - Assert.assertEquals(4, poolCount) + assertEquals(4, poolCapacity) + assertEquals(4, poolCount) testPoolables.clear() @@ -98,28 +98,28 @@ class ObjectPoolTest { testPoolables.add(TestPoolable.getInstance(3, 5)) testPoolables.add(TestPoolable.getInstance(6, 8)) testPoolables.add(TestPoolable.getInstance(8, 9)) - Assert.assertEquals(12, testPoolables[0].foo) - Assert.assertEquals(24, testPoolables[0].bar) - Assert.assertEquals(1, testPoolables[1].foo) - Assert.assertEquals(2, testPoolables[1].bar) - Assert.assertEquals(3, testPoolables[2].foo) - Assert.assertEquals(5, testPoolables[2].bar) - Assert.assertEquals(6, testPoolables[3].foo) - Assert.assertEquals(8, testPoolables[3].bar) - Assert.assertEquals(8, testPoolables[4].foo) - Assert.assertEquals(9, testPoolables[4].bar) + assertEquals(12, testPoolables[0].foo) + assertEquals(24, testPoolables[0].bar) + assertEquals(1, testPoolables[1].foo) + assertEquals(2, testPoolables[1].bar) + assertEquals(3, testPoolables[2].foo) + assertEquals(5, testPoolables[2].bar) + assertEquals(6, testPoolables[3].foo) + assertEquals(8, testPoolables[3].bar) + assertEquals(8, testPoolables[4].foo) + assertEquals(9, testPoolables[4].bar) poolCapacity = TestPoolable.getPool().poolCapacity poolCount = TestPoolable.getPool().poolCount - Assert.assertEquals(4, poolCapacity) - Assert.assertEquals(3, poolCount) + assertEquals(4, poolCapacity) + assertEquals(3, poolCount) TestPoolable.recycleInstances(testPoolables) poolCapacity = TestPoolable.getPool().poolCapacity poolCount = TestPoolable.getPool().poolCount - Assert.assertEquals(8, poolCapacity) - Assert.assertEquals(8, poolCount) + assertEquals(8, poolCapacity) + assertEquals(8, poolCount) testPoolables.clear() @@ -134,26 +134,26 @@ class ObjectPoolTest { testPoolables.add(TestPoolable.getInstance(12, 24)) testPoolables.add(TestPoolable.getInstance(6, 8)) testPoolables.add(TestPoolable.getInstance(6, 8)) - Assert.assertEquals(0, testPoolables[0].foo) - Assert.assertEquals(0, testPoolables[0].bar) - Assert.assertEquals(6, testPoolables[1].foo) - Assert.assertEquals(8, testPoolables[1].bar) - Assert.assertEquals(1, testPoolables[2].foo) - Assert.assertEquals(2, testPoolables[2].bar) - Assert.assertEquals(3, testPoolables[3].foo) - Assert.assertEquals(5, testPoolables[3].bar) - Assert.assertEquals(8, testPoolables[4].foo) - Assert.assertEquals(9, testPoolables[4].bar) - Assert.assertEquals(12, testPoolables[5].foo) - Assert.assertEquals(24, testPoolables[5].bar) - Assert.assertEquals(12, testPoolables[6].foo) - Assert.assertEquals(24, testPoolables[6].bar) - Assert.assertEquals(12, testPoolables[7].foo) - Assert.assertEquals(24, testPoolables[7].bar) - Assert.assertEquals(6, testPoolables[8].foo) - Assert.assertEquals(8, testPoolables[8].bar) - Assert.assertEquals(6, testPoolables[9].foo) - Assert.assertEquals(8, testPoolables[9].bar) + assertEquals(0, testPoolables[0].foo) + assertEquals(0, testPoolables[0].bar) + assertEquals(6, testPoolables[1].foo) + assertEquals(8, testPoolables[1].bar) + assertEquals(1, testPoolables[2].foo) + assertEquals(2, testPoolables[2].bar) + assertEquals(3, testPoolables[3].foo) + assertEquals(5, testPoolables[3].bar) + assertEquals(8, testPoolables[4].foo) + assertEquals(9, testPoolables[4].bar) + assertEquals(12, testPoolables[5].foo) + assertEquals(24, testPoolables[5].bar) + assertEquals(12, testPoolables[6].foo) + assertEquals(24, testPoolables[6].bar) + assertEquals(12, testPoolables[7].foo) + assertEquals(24, testPoolables[7].bar) + assertEquals(6, testPoolables[8].foo) + assertEquals(8, testPoolables[8].bar) + assertEquals(6, testPoolables[9].foo) + assertEquals(8, testPoolables[9].bar) for (p in testPoolables) { TestPoolable.recycleInstance(p) @@ -161,25 +161,25 @@ class ObjectPoolTest { poolCapacity = TestPoolable.getPool().poolCapacity poolCount = TestPoolable.getPool().poolCount - Assert.assertEquals(16, poolCapacity) - Assert.assertEquals(16, poolCount) + assertEquals(16, poolCapacity) + assertEquals(16, poolCount) testPoolable = TestPoolable.getInstance(9001, 9001) - Assert.assertEquals(9001, testPoolable.foo) - Assert.assertEquals(9001, testPoolable.bar) + assertEquals(9001, testPoolable.foo) + assertEquals(9001, testPoolable.bar) poolCapacity = TestPoolable.getPool().poolCapacity poolCount = TestPoolable.getPool().poolCount - Assert.assertEquals(16, poolCapacity) - Assert.assertEquals(15, poolCount) + assertEquals(16, poolCapacity) + assertEquals(15, poolCount) TestPoolable.recycleInstance(testPoolable) poolCapacity = TestPoolable.getPool().poolCapacity poolCount = TestPoolable.getPool().poolCount - Assert.assertEquals(16, poolCapacity) - Assert.assertEquals(16, poolCount) + assertEquals(16, poolCapacity) + assertEquals(16, poolCount) var e: Exception? = null try { @@ -188,7 +188,7 @@ class ObjectPoolTest { } catch (ex: Exception) { e = ex } finally { - Assert.assertEquals(e!!.message, true, true) + assertEquals(true, true, e!!.message) } testPoolables.clear() @@ -202,14 +202,14 @@ class ObjectPoolTest { poolCapacity = TestPoolable.getPool().poolCapacity poolCount = TestPoolable.getPool().poolCount - Assert.assertEquals(16, poolCapacity) - Assert.assertEquals(0, poolCount) + assertEquals(16, poolCapacity) + assertEquals(0, poolCount) testPoolables.add(TestPoolable.getInstance(0, 0)) poolCapacity = TestPoolable.getPool().poolCapacity poolCount = TestPoolable.getPool().poolCount - Assert.assertEquals(16, poolCapacity) - Assert.assertEquals(7, poolCount) + assertEquals(16, poolCapacity) + assertEquals(7, poolCount) } } \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 2e108c24..5fcb4371 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,4 +1,5 @@ include("chartLib") +include("chartLibCore") include("chartLibCompose") include("app") include("lint") From d8dd425fa6653ff0b909d8f63c097ea8ca0002be Mon Sep 17 00:00:00 2001 From: Hannes Achleitner Date: Sat, 26 Sep 2026 19:48:16 +0200 Subject: [PATCH 03/22] Full Entry family migrated to chartLibCore. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Designed and added ChartIcon — an expect abstract class in commonMain, actual typealiasd to android.graphics.drawable.Drawable on Android (zero behavior change there) and an empty placeholder on iOS/desktop for now. * Moved all 19 files of the entry hierarchy (BaseEntry, EntryFloat, EntryDouble, deprecated Entry, and all Bar/Pie/Radar/Bubble/Candle Float/Double/deprecated variants) into chartLibCore commonMain, same package names. * Removed Drawable→ChartIcon, dropped @SuppressLint, replaced Build.VERSION toString branching with ::class.simpleName, replaced Timber logging calls, and replaced the Utils.FLOAT_EPSILON/DOUBLE_EPSILON dependency with local common constants. * Flagged breaking change: EntryFloat/Entry no longer implement Parcelable/Serializable (no multiplatform equivalent, no internal usage found — documented as reversible via expect/actual if a consumer needs it back). * Verified: chartLibCore builds on Android/iOS×3/desktop; chartLib:compileDebugKotlin needed zero source changes; full ./gradlew test, chartLibCompose:assembleDebug, app:assembleDebug all pass. * Updated KotlinMultiplatform.md with the detailed change log and remaining scope. --- KotlinMultiplatform.md | 51 ++++++- .../info/appdev/charting/data/EntryFloat.kt | 144 ------------------ chartLibCore/build.gradle.kts | 4 + .../charting/utils/ChartIcon.android.kt | 8 + .../info/appdev/charting/data/BarEntry.kt | 6 +- .../appdev/charting/data/BarEntryDouble.kt | 8 +- .../appdev/charting/data/BarEntryFloat.kt | 12 +- .../info/appdev/charting/data/BaseEntry.kt | 12 +- .../info/appdev/charting/data/BubbleEntry.kt | 5 +- .../appdev/charting/data/BubbleEntryDouble.kt | 6 +- .../appdev/charting/data/BubbleEntryFloat.kt | 6 +- .../info/appdev/charting/data/CandleEntry.kt | 6 +- .../appdev/charting/data/CandleEntryDouble.kt | 8 +- .../appdev/charting/data/CandleEntryFloat.kt | 8 +- .../kotlin/info/appdev/charting/data/Entry.kt | 16 +- .../info/appdev/charting/data/EntryDouble.kt | 21 +-- .../info/appdev/charting/data/EntryFloat.kt | 95 ++++++++++++ .../info/appdev/charting/data/PieEntry.kt | 10 +- .../appdev/charting/data/PieEntryDouble.kt | 0 .../appdev/charting/data/PieEntryFloat.kt | 19 +-- .../info/appdev/charting/data/RadarEntry.kt | 0 .../appdev/charting/data/RadarEntryDouble.kt | 2 - .../appdev/charting/data/RadarEntryFloat.kt | 2 - .../info/appdev/charting/utils/ChartIcon.kt | 12 ++ .../charting/utils/ChartIcon.desktop.kt | 8 + .../appdev/charting/utils/ChartIcon.ios.kt | 7 + 26 files changed, 237 insertions(+), 239 deletions(-) delete mode 100644 chartLib/src/main/kotlin/info/appdev/charting/data/EntryFloat.kt create mode 100644 chartLibCore/src/androidMain/kotlin/info/appdev/charting/utils/ChartIcon.android.kt rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/data/BarEntry.kt (83%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/data/BarEntryDouble.kt (92%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/data/BarEntryFloat.kt (92%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/data/BaseEntry.kt (72%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/data/BubbleEntry.kt (87%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/data/BubbleEntryDouble.kt (85%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/data/BubbleEntryFloat.kt (86%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/data/CandleEntry.kt (95%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/data/CandleEntryDouble.kt (94%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/data/CandleEntryFloat.kt (94%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/data/Entry.kt (74%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/data/EntryDouble.kt (70%) create mode 100644 chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/EntryFloat.kt rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/data/PieEntry.kt (65%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/data/PieEntryDouble.kt (100%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/data/PieEntryFloat.kt (56%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/data/RadarEntry.kt (100%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/data/RadarEntryDouble.kt (94%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/data/RadarEntryFloat.kt (88%) create mode 100644 chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ChartIcon.kt create mode 100644 chartLibCore/src/desktopMain/kotlin/info/appdev/charting/utils/ChartIcon.desktop.kt create mode 100644 chartLibCore/src/iosMain/kotlin/info/appdev/charting/utils/ChartIcon.ios.kt diff --git a/KotlinMultiplatform.md b/KotlinMultiplatform.md index 50efcd7e..7a086138 100644 --- a/KotlinMultiplatform.md +++ b/KotlinMultiplatform.md @@ -96,8 +96,55 @@ lint Existing custom lint rules (unchanged) unavailable in `commonMain`). Revisit with `kotlinx-atomicfu` or a platform-specific `expect`/`actual` lock if thread-safety across pool instances becomes a real requirement on non-JVM targets. -- [ ] **Step A.2 — Design common replacement abstractions.** (see below) -- [ ] **Step A.3 — Migrate the data model (`data/`, `interfaces/`).** +- [x] **Step A.2 (partial) + first half of A.3 — `ChartIcon` abstraction + full + Entry family migration.** + - Added `expect abstract class ChartIcon` in `chartLibCore` commonMain, with + `actual typealias ChartIcon = android.graphics.drawable.Drawable` on Android + (zero source/behavior change for Android consumers — `entry.icon` is still + literally a `Drawable`) and empty placeholder `actual abstract class ChartIcon` + on iOS/desktop until Compose Multiplatform icon rendering (e.g. `Painter`) is + designed. Required adding `-Xexpect-actual-classes` to `chartLibCore`'s + `compilerOptions.freeCompilerArgs` (expect/actual classes are still Beta). + - Moved the entire `data/` entry hierarchy into `chartLibCore` commonMain, + unchanged in package/API shape: + `BaseEntry`, `EntryFloat`, `EntryDouble`, the deprecated `Entry` bridge class, + and all `Bar/Pie/Radar/Bubble/Candle` `*Entry`/`*EntryFloat`/`*EntryDouble` + variants (19 files total). + - Replaced `android.graphics.drawable.Drawable?` fields/params with `ChartIcon?` + throughout (transparent on Android via the typealias). + - Removed `android.annotation.SuppressLint`/`@TargetApi` (build-tool-only, + meaningless outside Android — safe to drop). + - **Breaking change (flagged, not silently made):** `EntryFloat` (and therefore + `Entry`) no longer implements `android.os.Parcelable` or `java.io.Serializable` + — both are JVM/Android-only types with no multiplatform equivalent. A repo-wide + search found **no internal usage** of `EntryFloat.CREATOR`, `is Parcelable`, + or entry serialization, so this is believed safe, but it **is** a public API + removal for any external consumer relying on putting entries into a `Bundle`/ + `Intent` via `Parcelable`/`Serializable`. If needed later, this can be restored + Android-only via the `expect class` + additional-supertypes-on-`actual` pattern + (an `actual` declaration is allowed to implement extra platform-specific + interfaces beyond what `expect` declares). + - Replaced the JVM-only `Build.VERSION.SDK_INT` branch in `toString()` + implementations with `this::class.simpleName` (works identically on all + Kotlin targets). + - Replaced `Utils.FLOAT_EPSILON`/`Utils.DOUBLE_EPSILON` (which pulled in the + still-Android-only `Utils` object) with local common constants + `ENTRY_FLOAT_EPSILON`/`ENTRY_DOUBLE_EPSILON` in `EntryFloat.kt`, built with the + common-Kotlin-stdlib `Float.fromBits(1)`/`Double.fromBits(1L)`. + - Removed two `timber.log.Timber.i(...)` informational log calls from + `PieEntryFloat`'s deprecated `x` accessor (Timber is Android-only; logging + facade design deferred — see the `timber.log.Timber` row in the abstractions + table below). + - `PieEntryDouble.kt` was moved as-is (it was already an empty file pre-existing + in `chartLib` — a gap in the deprecated-class migration table unrelated to + this KMP effort; left untouched, out of scope here). + - Verified: `chartLibCore` builds clean on Android/iOS×3/desktop-JVM; + `chartLib:compileDebugKotlin` succeeds **with zero source changes needed** in + `chartLib` itself (proof the package-preserving move is fully transparent to + existing consumers); full `./gradlew test :chartLibCore:allTests` and + `chartLibCompose:assembleDebug`/`app:assembleDebug` all pass. +- [ ] **Step A.3 (remainder) — Migrate `BaseDataSet`/`DataSet`/`ChartData` and + `interfaces/` (`IDataSet` family, `dataprovider` family).** - [ ] **Step A.4 — Migrate formatters (`formatter/`) and remaining highlighters (`highlight/`).** - [ ] **Step A.5 — Migrate `components/` (axes, legend, limit lines).** diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/EntryFloat.kt b/chartLib/src/main/kotlin/info/appdev/charting/data/EntryFloat.kt deleted file mode 100644 index f25202e9..00000000 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/EntryFloat.kt +++ /dev/null @@ -1,144 +0,0 @@ -package info.appdev.charting.data - -import android.graphics.drawable.Drawable -import android.os.Build -import android.os.Parcel -import android.os.ParcelFormatException -import android.os.Parcelable -import info.appdev.charting.utils.Utils -import java.io.Serializable -import kotlin.math.abs - -/** - * Class representing one entry in the chart. Might contain multiple values. - * Might only contain a single value depending on the used constructor. - */ -open class EntryFloat : BaseEntry, Parcelable, Serializable { - - constructor() - - /** - * An EntryFloat represents one single entry in the chart. - * - * @param x the x value - * @param y the y value (the actual value of the entry) - */ - constructor(x: Float, y: Float) : super(x = x, y = y) - - /** - * An EntryFloat represents one single entry in the chart. - * - * @param x the x value - * @param y the y value (the actual value of the entry) - * @param data Spot for additional data this Entry represents. - */ - constructor(x: Float, y: Float, data: Any?) : super(x = x, y = y, data = data) - - /** - * An EntryFloat represents one single entry in the chart. - * - * @param x the x value - * @param y the y value (the actual value of the entry) - * @param icon icon image - */ - constructor(x: Float, y: Float, icon: Drawable?) : super(x = x, y = y, icon = icon) - - /** - * An EntryFloat represents one single entry in the chart. - * - * @param x the x value - * @param y the y value (the actual value of the entry) - * @param icon icon image - * @param data Spot for additional data this EntryFloat represents. - */ - constructor(x: Float, y: Float, icon: Drawable?, data: Any?) : super(x = x, y = y, icon = icon, data = data) - - /** - * returns an exact copy of the entry - */ - open fun copy(): EntryFloat { - val e = EntryFloat( - x = x, - y = y, - data = data - ) - return e - } - - /** - * Compares value, xIndex and data of the entries. Returns true if entries - * are equal in those points, false if not. Does not check by hash-code like - * it's done by the "equals" method. - */ - fun equalTo(entryFloat: EntryFloat?): Boolean { - if (entryFloat == null) - return false - - if (entryFloat.data !== this.data) - return false - - if (abs((entryFloat.x - this.x).toDouble()) > Utils.FLOAT_EPSILON) - return false - - if (abs((entryFloat.y - this.y).toDouble()) > Utils.FLOAT_EPSILON) - return false - - return true - } - - /** - * returns a string representation of the entry containing x-index and value - */ - override fun toString(): String { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - "${this.javaClass.typeName.substringAfterLast(".")} x=$x y=$y" - } else { - "EntryFloat x=$x y=$y" - } - } - - override fun describeContents(): Int { - return 0 - } - - override fun writeToParcel(dest: Parcel, flags: Int) { - dest.writeFloat(this.x) - dest.writeFloat(this.y) - if (data != null) { - if (data is Parcelable) { - dest.writeInt(1) - dest.writeParcelable(data as Parcelable?, flags) - } else { - throw ParcelFormatException("Cannot parcel an EntryFloat with non-parcelable data") - } - } else { - dest.writeInt(0) - } - } - - protected constructor(`in`: Parcel) { - this.x = `in`.readFloat() - this.yBase = `in`.readFloat() - if (`in`.readInt() == 1) { - this.data = if (Build.VERSION.SDK_INT >= 33) { - `in`.readParcelable(Any::class.java.classLoader, Any::class.java) - } else { - @Suppress("DEPRECATION") - `in`.readParcelable(Any::class.java.classLoader) - } - } - } - - companion object { - @JvmField - val CREATOR: Parcelable.Creator = object : Parcelable.Creator { - override fun createFromParcel(source: Parcel): EntryFloat { - return EntryFloat(source) - } - - override fun newArray(size: Int): Array { - return arrayOfNulls(size) - } - } - } -} diff --git a/chartLibCore/build.gradle.kts b/chartLibCore/build.gradle.kts index bb88abc5..f21fd769 100644 --- a/chartLibCore/build.gradle.kts +++ b/chartLibCore/build.gradle.kts @@ -6,6 +6,10 @@ plugins { } kotlin { + compilerOptions { + freeCompilerArgs.add("-Xexpect-actual-classes") + } + android { namespace = "info.appdev.charting.core" compileSdk = 36 diff --git a/chartLibCore/src/androidMain/kotlin/info/appdev/charting/utils/ChartIcon.android.kt b/chartLibCore/src/androidMain/kotlin/info/appdev/charting/utils/ChartIcon.android.kt new file mode 100644 index 00000000..6c4b0299 --- /dev/null +++ b/chartLibCore/src/androidMain/kotlin/info/appdev/charting/utils/ChartIcon.android.kt @@ -0,0 +1,8 @@ +package info.appdev.charting.utils + +/** + * On Android, [ChartIcon] is literally an `android.graphics.drawable.Drawable`, so all + * existing renderer/consumer code using `entry.icon` as a `Drawable` continues to compile + * and behave exactly as before this abstraction was introduced. + */ +actual typealias ChartIcon = android.graphics.drawable.Drawable diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/BarEntry.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BarEntry.kt similarity index 83% rename from chartLib/src/main/kotlin/info/appdev/charting/data/BarEntry.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BarEntry.kt index eedc9b38..3b235315 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/BarEntry.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BarEntry.kt @@ -1,6 +1,6 @@ package info.appdev.charting.data -import android.graphics.drawable.Drawable +import info.appdev.charting.utils.ChartIcon @Deprecated( @@ -30,7 +30,7 @@ open class BarEntry : BarEntryFloat { * @param y * @param icon - icon image */ - constructor(x: Float, y: Float, icon: Drawable?) : super(x, y, icon) + constructor(x: Float, y: Float, icon: ChartIcon?) : super(x, y, icon) /** * Constructor for normal bars (not stacked). @@ -40,6 +40,6 @@ open class BarEntry : BarEntryFloat { * @param icon - icon image * @param data - Spot for additional data this Entry represents. */ - constructor(x: Float, y: Float, icon: Drawable?, data: Any?) : super(x, y, icon, data) + constructor(x: Float, y: Float, icon: ChartIcon?, data: Any?) : super(x, y, icon, data) } diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/BarEntryDouble.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BarEntryDouble.kt similarity index 92% rename from chartLib/src/main/kotlin/info/appdev/charting/data/BarEntryDouble.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BarEntryDouble.kt index 6560d5d2..6a621737 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/BarEntryDouble.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BarEntryDouble.kt @@ -1,6 +1,6 @@ package info.appdev.charting.data -import android.graphics.drawable.Drawable +import info.appdev.charting.utils.ChartIcon import info.appdev.charting.highlight.RangeDouble import kotlin.math.abs @@ -47,11 +47,11 @@ open class BarEntryDouble : BarEntryFloat { xDouble = x; yDouble = y } - constructor(x: Double, y: Double, icon: Drawable?) : super(x.toFloat(), y.toFloat(), icon) { + constructor(x: Double, y: Double, icon: ChartIcon?) : super(x.toFloat(), y.toFloat(), icon) { xDouble = x; yDouble = y } - constructor(x: Double, y: Double, icon: Drawable?, data: Any?) : super(x.toFloat(), y.toFloat(), icon, data) { + constructor(x: Double, y: Double, icon: ChartIcon?, data: Any?) : super(x.toFloat(), y.toFloat(), icon, data) { xDouble = x; yDouble = y } @@ -73,7 +73,7 @@ open class BarEntryDouble : BarEntryFloat { calcDoubleRanges() } - constructor(x: Double, vals: DoubleArray?, icon: Drawable?) : super(x.toFloat(), calcDoubleSum(vals).toFloat(), icon) { + constructor(x: Double, vals: DoubleArray?, icon: ChartIcon?) : super(x.toFloat(), calcDoubleSum(vals).toFloat(), icon) { xDouble = x yDouble = calcDoubleSum(vals) yValsDouble = vals diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/BarEntryFloat.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BarEntryFloat.kt similarity index 92% rename from chartLib/src/main/kotlin/info/appdev/charting/data/BarEntryFloat.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BarEntryFloat.kt index 1ce910a0..1c51c886 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/BarEntryFloat.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BarEntryFloat.kt @@ -1,14 +1,12 @@ package info.appdev.charting.data -import android.annotation.SuppressLint -import android.graphics.drawable.Drawable +import info.appdev.charting.utils.ChartIcon import info.appdev.charting.highlight.Range import kotlin.math.abs /** * Entry class for the BarChart. (especially stacked bars) */ -@SuppressLint("ParcelCreator") open class BarEntryFloat : EntryFloat { /** * Returns the stacked values this BarEntry represents, or null, if only a single value is represented (then, use @@ -56,7 +54,7 @@ open class BarEntryFloat : EntryFloat { * @param y * @param icon - icon image */ - constructor(x: Float, y: Float, icon: Drawable?) : super(x, y, icon) + constructor(x: Float, y: Float, icon: ChartIcon?) : super(x, y, icon) /** * Constructor for normal bars (not stacked). @@ -66,7 +64,7 @@ open class BarEntryFloat : EntryFloat { * @param icon - icon image * @param data - Spot for additional data this Entry represents. */ - constructor(x: Float, y: Float, icon: Drawable?, data: Any?) : super(x, y, icon, data) + constructor(x: Float, y: Float, icon: ChartIcon?, data: Any?) : super(x, y, icon, data) /** * Constructor for stacked bar entries. One data object for whole stack @@ -100,7 +98,7 @@ open class BarEntryFloat : EntryFloat { * @param vals - the stack values, use at least 2 * @param icon - icon image */ - constructor(x: Float, vals: FloatArray?, icon: Drawable?) : super(x, calcSum(vals), icon) { + constructor(x: Float, vals: FloatArray?, icon: ChartIcon?) : super(x, calcSum(vals), icon) { this.yVals = vals calcPosNegSum() calcRanges() @@ -114,7 +112,7 @@ open class BarEntryFloat : EntryFloat { * @param icon - icon image * @param data - Spot for additional data this Entry represents. */ - constructor(x: Float, vals: FloatArray?, icon: Drawable?, data: Any?) : super(x, calcSum(vals), icon, data) { + constructor(x: Float, vals: FloatArray?, icon: ChartIcon?, data: Any?) : super(x, calcSum(vals), icon, data) { this.yVals = vals calcPosNegSum() calcRanges() diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/BaseEntry.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BaseEntry.kt similarity index 72% rename from chartLib/src/main/kotlin/info/appdev/charting/data/BaseEntry.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BaseEntry.kt index fcc4bf3f..ceffb054 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/BaseEntry.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BaseEntry.kt @@ -1,6 +1,6 @@ package info.appdev.charting.data -import android.graphics.drawable.Drawable +import info.appdev.charting.utils.ChartIcon abstract class BaseEntry where T : Number, T : Comparable { @@ -20,7 +20,7 @@ abstract class BaseEntry where T : Number, T : Comparable { var data: Any? = null - var icon: Drawable? = null + var icon: ChartIcon? = null constructor() @@ -37,11 +37,11 @@ abstract class BaseEntry where T : Number, T : Comparable { this.data = data } - constructor(y: T, icon: Drawable?) : this(y) { + constructor(y: T, icon: ChartIcon?) : this(y) { this.icon = icon } - constructor(y: T, icon: Drawable?, data: Any?) : this(y = y) { + constructor(y: T, icon: ChartIcon?, data: Any?) : this(y = y) { this.icon = icon this.data = data } @@ -50,11 +50,11 @@ abstract class BaseEntry where T : Number, T : Comparable { this.data = data } - constructor(x: T, y: T, icon: Drawable?) : this(x = x, y = y) { + constructor(x: T, y: T, icon: ChartIcon?) : this(x = x, y = y) { this.icon = icon } - constructor(x: T, y: T, icon: Drawable?, data: Any?) : this(x = x, y = y) { + constructor(x: T, y: T, icon: ChartIcon?, data: Any?) : this(x = x, y = y) { this.icon = icon this.data = data } diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/BubbleEntry.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BubbleEntry.kt similarity index 87% rename from chartLib/src/main/kotlin/info/appdev/charting/data/BubbleEntry.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BubbleEntry.kt index af0986e7..ae4367f4 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/BubbleEntry.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BubbleEntry.kt @@ -1,7 +1,6 @@ package info.appdev.charting.data -import android.annotation.SuppressLint -import android.graphics.drawable.Drawable +import info.appdev.charting.utils.ChartIcon @Deprecated( message = "The replacement is BubbleEntryFloat, or use BubbleEntryDouble for higher precision. BubbleEntry is retained for backward compatibility but will be removed in a future version.", @@ -40,7 +39,7 @@ class BubbleEntry : BubbleEntryFloat { * @param size The size of the bubble. * @param icon Icon image */ - constructor(x: Float, y: Float, size: Float, icon: Drawable?) : super(x, y, size, icon) { + constructor(x: Float, y: Float, size: Float, icon: ChartIcon?) : super(x, y, size, icon) { this.size = size } } diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/BubbleEntryDouble.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BubbleEntryDouble.kt similarity index 85% rename from chartLib/src/main/kotlin/info/appdev/charting/data/BubbleEntryDouble.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BubbleEntryDouble.kt index 2fd3c333..446574be 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/BubbleEntryDouble.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BubbleEntryDouble.kt @@ -1,7 +1,6 @@ package info.appdev.charting.data -import android.annotation.SuppressLint -import android.graphics.drawable.Drawable +import info.appdev.charting.utils.ChartIcon import kotlin.math.abs /** @@ -9,7 +8,6 @@ import kotlin.math.abs * so it works seamlessly in the existing bubble chart rendering pipeline. * Use [xDouble], [yDouble] and [sizeDouble] for full-precision access. */ -@SuppressLint("ParcelCreator") open class BubbleEntryDouble : BubbleEntryFloat { var xDouble: Double = 0.0 @@ -34,7 +32,7 @@ open class BubbleEntryDouble : BubbleEntryFloat { this.size = size.toFloat() } - constructor(x: Double, y: Double, size: Double, icon: Drawable?) : super(x.toFloat(), y.toFloat(), size.toFloat(), icon) { + constructor(x: Double, y: Double, size: Double, icon: ChartIcon?) : super(x.toFloat(), y.toFloat(), size.toFloat(), icon) { xDouble = x; yDouble = y; sizeDouble = size this.size = size.toFloat() } diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/BubbleEntryFloat.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BubbleEntryFloat.kt similarity index 86% rename from chartLib/src/main/kotlin/info/appdev/charting/data/BubbleEntryFloat.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BubbleEntryFloat.kt index f9676aab..73ec7f65 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/BubbleEntryFloat.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BubbleEntryFloat.kt @@ -1,13 +1,11 @@ package info.appdev.charting.data -import android.annotation.SuppressLint -import android.graphics.drawable.Drawable +import info.appdev.charting.utils.ChartIcon /** * Subclass of Entry that holds a value for one entry in a BubbleChart. Bubble * chart implementation: Copyright 2015 Pierre-Marc Airoldi Licensed under Apache License 2.0 */ -@SuppressLint("ParcelCreator") open class BubbleEntryFloat : EntryFloat { /** * Returns the size of this entry (the size of the bubble). @@ -45,7 +43,7 @@ open class BubbleEntryFloat : EntryFloat { * @param size The size of the bubble. * @param icon Icon image */ - constructor(x: Float, y: Float, size: Float, icon: Drawable?) : super(x, y, icon) { + constructor(x: Float, y: Float, size: Float, icon: ChartIcon?) : super(x, y, icon) { this.size = size } diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/CandleEntry.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/CandleEntry.kt similarity index 95% rename from chartLib/src/main/kotlin/info/appdev/charting/data/CandleEntry.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/CandleEntry.kt index e7d9811b..b050bcb5 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/CandleEntry.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/CandleEntry.kt @@ -1,6 +1,6 @@ package info.appdev.charting.data -import android.graphics.drawable.Drawable +import info.appdev.charting.utils.ChartIcon @Deprecated( message = "The replacement is CandleEntryFloat, or use CandleEntryDouble for higher precision. CandleEntry is retained for backward compatibility but will be removed in a future version.", @@ -52,7 +52,7 @@ class CandleEntry : CandleEntryFloat { */ constructor( x: Float, shadowH: Float, shadowL: Float, open: Float, close: Float, - icon: Drawable? + icon: ChartIcon? ) : super(x, shadowH, shadowL, open, close) { this.high = shadowH this.low = shadowL @@ -71,7 +71,7 @@ class CandleEntry : CandleEntryFloat { */ constructor( x: Float, shadowH: Float, shadowL: Float, open: Float, close: Float, - icon: Drawable?, data: Any? + icon: ChartIcon?, data: Any? ) : super(x, shadowH, shadowL, open, close) { this.high = shadowH this.low = shadowL diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/CandleEntryDouble.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/CandleEntryDouble.kt similarity index 94% rename from chartLib/src/main/kotlin/info/appdev/charting/data/CandleEntryDouble.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/CandleEntryDouble.kt index 0ec9ccf0..72d5ad13 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/CandleEntryDouble.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/CandleEntryDouble.kt @@ -1,7 +1,6 @@ package info.appdev.charting.data -import android.annotation.SuppressLint -import android.graphics.drawable.Drawable +import info.appdev.charting.utils.ChartIcon import kotlin.math.abs /** @@ -9,7 +8,6 @@ import kotlin.math.abs * so it works seamlessly in the existing candlestick chart rendering pipeline. * Use [xDouble], [highDouble], [lowDouble], [openDouble], [closeDouble] for full-precision access. */ -@SuppressLint("ParcelCreator") open class CandleEntryDouble : CandleEntryFloat { var xDouble: Double = 0.0 @@ -39,7 +37,7 @@ open class CandleEntryDouble : CandleEntryFloat { } constructor( - x: Double, shadowH: Double, shadowL: Double, open: Double, close: Double, icon: Drawable? + x: Double, shadowH: Double, shadowL: Double, open: Double, close: Double, icon: ChartIcon? ) : super(x.toFloat(), shadowH.toFloat(), shadowL.toFloat(), open.toFloat(), close.toFloat(), icon) { xDouble = x; highDouble = shadowH; lowDouble = shadowL; openDouble = open; closeDouble = close this.high = shadowH.toFloat(); this.low = shadowL.toFloat() @@ -47,7 +45,7 @@ open class CandleEntryDouble : CandleEntryFloat { } constructor( - x: Double, shadowH: Double, shadowL: Double, open: Double, close: Double, icon: Drawable?, data: Any? + x: Double, shadowH: Double, shadowL: Double, open: Double, close: Double, icon: ChartIcon?, data: Any? ) : super(x.toFloat(), shadowH.toFloat(), shadowL.toFloat(), open.toFloat(), close.toFloat(), icon, data) { xDouble = x; highDouble = shadowH; lowDouble = shadowL; openDouble = open; closeDouble = close this.high = shadowH.toFloat(); this.low = shadowL.toFloat() diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/CandleEntryFloat.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/CandleEntryFloat.kt similarity index 94% rename from chartLib/src/main/kotlin/info/appdev/charting/data/CandleEntryFloat.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/CandleEntryFloat.kt index d6659c82..d331ae41 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/CandleEntryFloat.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/CandleEntryFloat.kt @@ -1,13 +1,11 @@ package info.appdev.charting.data -import android.annotation.SuppressLint -import android.graphics.drawable.Drawable +import info.appdev.charting.utils.ChartIcon import kotlin.math.abs /** * Subclass of Entry that holds all values for one entry in a CandleStickChart. */ -@SuppressLint("ParcelCreator") open class CandleEntryFloat : EntryFloat { /** * Returns the upper shadows highest value. @@ -73,7 +71,7 @@ open class CandleEntryFloat : EntryFloat { */ constructor( x: Float, shadowH: Float, shadowL: Float, open: Float, close: Float, - icon: Drawable? + icon: ChartIcon? ) : super(x, (shadowH + shadowL) / 2f, icon) { this.high = shadowH this.low = shadowL @@ -92,7 +90,7 @@ open class CandleEntryFloat : EntryFloat { */ constructor( x: Float, shadowH: Float, shadowL: Float, open: Float, close: Float, - icon: Drawable?, data: Any? + icon: ChartIcon?, data: Any? ) : super(x, (shadowH + shadowL) / 2f, icon, data) { this.high = shadowH this.low = shadowL diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/Entry.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/Entry.kt similarity index 74% rename from chartLib/src/main/kotlin/info/appdev/charting/data/Entry.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/Entry.kt index 9bc6dd11..584fcb61 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/Entry.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/Entry.kt @@ -1,16 +1,6 @@ package info.appdev.charting.data -import android.R.attr.data -import android.R.attr.x -import android.R.attr.y -import android.graphics.drawable.Drawable -import android.os.Build -import android.os.Parcel -import android.os.ParcelFormatException -import android.os.Parcelable -import info.appdev.charting.utils.Utils -import java.io.Serializable -import kotlin.math.abs +import info.appdev.charting.utils.ChartIcon /** * Class representing one entry in the chart. Might contain multiple values. @@ -48,7 +38,7 @@ class Entry : EntryFloat { * @param y the y value (the actual value of the entry) * @param icon icon image */ - constructor(x: Float, y: Float, icon: Drawable?) : super(x = x, y = y, icon = icon) + constructor(x: Float, y: Float, icon: ChartIcon?) : super(x = x, y = y, icon = icon) /** * An EntryFloat represents one single entry in the chart. @@ -58,6 +48,6 @@ class Entry : EntryFloat { * @param icon icon image * @param data Spot for additional data this EntryFloat represents. */ - constructor(x: Float, y: Float, icon: Drawable?, data: Any?) : super(x = x, y = y, icon = icon, data = data) + constructor(x: Float, y: Float, icon: ChartIcon?, data: Any?) : super(x = x, y = y, icon = icon, data = data) } diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/EntryDouble.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/EntryDouble.kt similarity index 70% rename from chartLib/src/main/kotlin/info/appdev/charting/data/EntryDouble.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/EntryDouble.kt index 31b3138a..63298550 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/EntryDouble.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/EntryDouble.kt @@ -1,17 +1,14 @@ package info.appdev.charting.data -import android.graphics.drawable.Drawable -import android.os.Build -import java.io.Serializable +import info.appdev.charting.utils.ChartIcon import kotlin.math.abs -import info.appdev.charting.utils.Utils /** * High-precision entry that stores x and y as Double, but extends EntryFloat * so it works seamlessly in the existing chart rendering pipeline. * Use [xDouble] and [yDouble] to access the full-precision values. */ -open class EntryDouble : EntryFloat, Serializable { +open class EntryDouble : EntryFloat { var xDouble: Double = 0.0 var yDouble: Double = 0.0 @@ -49,13 +46,13 @@ open class EntryDouble : EntryFloat, Serializable { this.data = data } - constructor(x: Double, y: Double, icon: Drawable?) : super() { + constructor(x: Double, y: Double, icon: ChartIcon?) : super() { this.xDouble = x this.yDouble = y this.icon = icon } - constructor(x: Double, y: Double, icon: Drawable?, data: Any?) : super() { + constructor(x: Double, y: Double, icon: ChartIcon?, data: Any?) : super() { this.xDouble = x this.yDouble = y this.icon = icon @@ -67,16 +64,12 @@ open class EntryDouble : EntryFloat, Serializable { fun equalTo(other: EntryDouble?): Boolean { if (other == null) return false if (other.data !== this.data) return false - if (abs(other.xDouble - this.xDouble) > Utils.DOUBLE_EPSILON) return false - if (abs(other.yDouble - this.yDouble) > Utils.DOUBLE_EPSILON) return false + if (abs(other.xDouble - this.xDouble) > ENTRY_DOUBLE_EPSILON) return false + if (abs(other.yDouble - this.yDouble) > ENTRY_DOUBLE_EPSILON) return false return true } override fun toString(): String { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - "${this.javaClass.typeName.substringAfterLast(".")} xDouble=$xDouble yDouble=$yDouble" - } else { - "EntryDouble xDouble=$xDouble yDouble=$yDouble" - } + return "${this::class.simpleName} xDouble=$xDouble yDouble=$yDouble" } } diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/EntryFloat.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/EntryFloat.kt new file mode 100644 index 00000000..fafa6b2f --- /dev/null +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/EntryFloat.kt @@ -0,0 +1,95 @@ +package info.appdev.charting.data + +import info.appdev.charting.utils.ChartIcon +import kotlin.math.abs + +/** Smallest positive Float greater than zero, used for approximate equality checks. */ +internal val ENTRY_FLOAT_EPSILON: Float = Float.fromBits(1) + +/** Smallest positive Double greater than zero, used for approximate equality checks. */ +internal val ENTRY_DOUBLE_EPSILON: Double = Double.fromBits(1L) + +/** + * Class representing one entry in the chart. Might contain multiple values. + * Might only contain a single value depending on the used constructor. + */ +open class EntryFloat : BaseEntry { + + constructor() + + /** + * An EntryFloat represents one single entry in the chart. + * + * @param x the x value + * @param y the y value (the actual value of the entry) + */ + constructor(x: Float, y: Float) : super(x = x, y = y) + + /** + * An EntryFloat represents one single entry in the chart. + * + * @param x the x value + * @param y the y value (the actual value of the entry) + * @param data Spot for additional data this Entry represents. + */ + constructor(x: Float, y: Float, data: Any?) : super(x = x, y = y, data = data) + + /** + * An EntryFloat represents one single entry in the chart. + * + * @param x the x value + * @param y the y value (the actual value of the entry) + * @param icon icon image + */ + constructor(x: Float, y: Float, icon: ChartIcon?) : super(x = x, y = y, icon = icon) + + /** + * An EntryFloat represents one single entry in the chart. + * + * @param x the x value + * @param y the y value (the actual value of the entry) + * @param icon icon image + * @param data Spot for additional data this EntryFloat represents. + */ + constructor(x: Float, y: Float, icon: ChartIcon?, data: Any?) : super(x = x, y = y, icon = icon, data = data) + + /** + * returns an exact copy of the entry + */ + open fun copy(): EntryFloat { + val e = EntryFloat( + x = x, + y = y, + data = data + ) + return e + } + + /** + * Compares value, xIndex and data of the entries. Returns true if entries + * are equal in those points, false if not. Does not check by hash-code like + * it's done by the "equals" method. + */ + fun equalTo(entryFloat: EntryFloat?): Boolean { + if (entryFloat == null) + return false + + if (entryFloat.data !== this.data) + return false + + if (abs((entryFloat.x - this.x).toDouble()) > ENTRY_FLOAT_EPSILON) + return false + + if (abs((entryFloat.y - this.y).toDouble()) > ENTRY_FLOAT_EPSILON) + return false + + return true + } + + /** + * returns a string representation of the entry containing x-index and value + */ + override fun toString(): String { + return "${this::class.simpleName} x=$x y=$y" + } +} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/PieEntry.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/PieEntry.kt similarity index 65% rename from chartLib/src/main/kotlin/info/appdev/charting/data/PieEntry.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/PieEntry.kt index dafa6e37..45e1a95c 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/PieEntry.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/PieEntry.kt @@ -1,6 +1,6 @@ package info.appdev.charting.data -import android.graphics.drawable.Drawable +import info.appdev.charting.utils.ChartIcon @Deprecated( message = "The replacement is PieEntryFloat, or use PieEntryDouble for higher precision. PieEntry is retained for backward compatibility but will be removed in a future version.", @@ -12,9 +12,9 @@ class PieEntry : PieEntryFloat { constructor(value: Float, data: Any?) : super(value, data) - constructor(value: Float, icon: Drawable?) : super(value, icon) + constructor(value: Float, icon: ChartIcon?) : super(value, icon) - constructor(value: Float, icon: Drawable?, data: Any?) : super(value, icon, data) + constructor(value: Float, icon: ChartIcon?, data: Any?) : super(value, icon, data) constructor(value: Float, label: String?) : super(value) { this.label = label @@ -24,11 +24,11 @@ class PieEntry : PieEntryFloat { this.label = label } - constructor(value: Float, label: String?, icon: Drawable?) : super(value, icon) { + constructor(value: Float, label: String?, icon: ChartIcon?) : super(value, icon) { this.label = label } - constructor(value: Float, label: String?, icon: Drawable?, data: Any?) : super(value, icon, data) { + constructor(value: Float, label: String?, icon: ChartIcon?, data: Any?) : super(value, icon, data) { this.label = label } diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/PieEntryDouble.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/PieEntryDouble.kt similarity index 100% rename from chartLib/src/main/kotlin/info/appdev/charting/data/PieEntryDouble.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/PieEntryDouble.kt diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/PieEntryFloat.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/PieEntryFloat.kt similarity index 56% rename from chartLib/src/main/kotlin/info/appdev/charting/data/PieEntryFloat.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/PieEntryFloat.kt index 2789fac5..ec9147b4 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/PieEntryFloat.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/PieEntryFloat.kt @@ -1,10 +1,7 @@ package info.appdev.charting.data -import android.annotation.SuppressLint -import android.graphics.drawable.Drawable -import timber.log.Timber +import info.appdev.charting.utils.ChartIcon -@SuppressLint("ParcelCreator") open class PieEntryFloat : EntryFloat { var label: String? = null @@ -12,9 +9,9 @@ open class PieEntryFloat : EntryFloat { constructor(value: Float, data: Any?) : super(0f, value, data) - constructor(value: Float, icon: Drawable?) : super(0f, value, icon) + constructor(value: Float, icon: ChartIcon?) : super(0f, value, icon) - constructor(value: Float, icon: Drawable?, data: Any?) : super(0f, value, icon, data) + constructor(value: Float, icon: ChartIcon?, data: Any?) : super(0f, value, icon, data) constructor(value: Float, label: String?) : super(0f, value) { this.label = label @@ -24,11 +21,11 @@ open class PieEntryFloat : EntryFloat { this.label = label } - constructor(value: Float, label: String?, icon: Drawable?) : super(0f, value, icon) { + constructor(value: Float, label: String?, icon: ChartIcon?) : super(0f, value, icon) { this.label = label } - constructor(value: Float, label: String?, icon: Drawable?, data: Any?) : super(0f, value, icon, data) { + constructor(value: Float, label: String?, icon: ChartIcon?, data: Any?) : super(0f, value, icon, data) { this.label = label } @@ -41,13 +38,9 @@ open class PieEntryFloat : EntryFloat { @get:Deprecated("") @set:Deprecated("") override var x: Float - get() { - Timber.i("Pie entries do not have x values") - return super.x - } + get() = super.x set(x) { super.x = x - Timber.i("Pie entries do not have x values") } override fun copy(): PieEntryFloat { diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/RadarEntry.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/RadarEntry.kt similarity index 100% rename from chartLib/src/main/kotlin/info/appdev/charting/data/RadarEntry.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/RadarEntry.kt diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/RadarEntryDouble.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/RadarEntryDouble.kt similarity index 94% rename from chartLib/src/main/kotlin/info/appdev/charting/data/RadarEntryDouble.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/RadarEntryDouble.kt index 03080dfe..913c1745 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/RadarEntryDouble.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/RadarEntryDouble.kt @@ -1,6 +1,5 @@ package info.appdev.charting.data -import android.annotation.SuppressLint /** * High-precision radar entry that stores the value as Double, extending [RadarEntryFloat] @@ -9,7 +8,6 @@ import android.annotation.SuppressLint * Radar entries have no meaningful x-axis value. */ @Suppress("DEPRECATION") -@SuppressLint("ParcelCreator") open class RadarEntryDouble : RadarEntryFloat { var valueDouble: Double = 0.0 diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/RadarEntryFloat.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/RadarEntryFloat.kt similarity index 88% rename from chartLib/src/main/kotlin/info/appdev/charting/data/RadarEntryFloat.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/RadarEntryFloat.kt index ad232d94..29ca4c09 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/RadarEntryFloat.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/RadarEntryFloat.kt @@ -1,8 +1,6 @@ package info.appdev.charting.data -import android.annotation.SuppressLint -@SuppressLint("ParcelCreator") open class RadarEntryFloat : EntryFloat { constructor(value: Float) : super(0f, value) diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ChartIcon.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ChartIcon.kt new file mode 100644 index 00000000..4b9ab41f --- /dev/null +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ChartIcon.kt @@ -0,0 +1,12 @@ +package info.appdev.charting.utils + +/** + * Opaque, platform-specific icon/image handle that can be attached to a chart entry + * (e.g. [BaseEntry.icon][info.appdev.charting.data.BaseEntry]). + * + * On Android this is a type alias for `android.graphics.drawable.Drawable`, so existing + * Android renderer code that treats `entry.icon` as a `Drawable` keeps compiling and + * behaving unchanged. Other platforms currently declare an empty placeholder until a + * Compose Multiplatform icon representation (e.g. a `Painter`) is designed. + */ +expect abstract class ChartIcon diff --git a/chartLibCore/src/desktopMain/kotlin/info/appdev/charting/utils/ChartIcon.desktop.kt b/chartLibCore/src/desktopMain/kotlin/info/appdev/charting/utils/ChartIcon.desktop.kt new file mode 100644 index 00000000..693aadc2 --- /dev/null +++ b/chartLibCore/src/desktopMain/kotlin/info/appdev/charting/utils/ChartIcon.desktop.kt @@ -0,0 +1,8 @@ +package info.appdev.charting.utils + +/** + * Placeholder [ChartIcon] implementation for the desktop (JVM) target until a Compose + * Multiplatform icon representation is designed; entry icons are not yet rendered on this + * platform. + */ +actual abstract class ChartIcon diff --git a/chartLibCore/src/iosMain/kotlin/info/appdev/charting/utils/ChartIcon.ios.kt b/chartLibCore/src/iosMain/kotlin/info/appdev/charting/utils/ChartIcon.ios.kt new file mode 100644 index 00000000..8aba87d4 --- /dev/null +++ b/chartLibCore/src/iosMain/kotlin/info/appdev/charting/utils/ChartIcon.ios.kt @@ -0,0 +1,7 @@ +package info.appdev.charting.utils + +/** + * Placeholder [ChartIcon] implementation for iOS until a Compose Multiplatform icon + * representation is designed; entry icons are not yet rendered on this platform. + */ +actual abstract class ChartIcon From 40611cce95f9e5d51505f576aa7438204988d80c Mon Sep 17 00:00:00 2001 From: Hannes Achleitner Date: Sun, 27 Sep 2026 07:25:24 +0200 Subject: [PATCH 04/22] DataSet-family groundwork Landed (in chartLibCore, reusable common types): AxisDependency, LegendForm enums (with nested-typealias back-compat on YAxis/Legend), PaintStyle enum (applied to CandleDataSet/ICandleDataSet + renderer/demo conversion), ChartTypeface expect/actual (applied to IDataSet/BaseDataSet/ChartData), DashEffect (designed, not yet wired), consolidated chartDensity/convertDpToPixel, and PointF (Parcelable dropped, moved to commonMain --- KotlinMultiplatform.md | 119 +++++++++++++++++- .../chartexample/CandleStickChartActivity.kt | 6 +- .../info/appdev/charting/components/Legend.kt | 37 +----- .../info/appdev/charting/components/YAxis.kt | 14 +-- .../info/appdev/charting/data/BaseDataSet.kt | 6 +- .../appdev/charting/data/CandleDataSet.kt | 10 +- .../info/appdev/charting/data/ChartData.kt | 4 +- .../interfaces/datasets/ICandleDataSet.kt | 6 +- .../charting/interfaces/datasets/IDataSet.kt | 4 +- .../renderer/CandleStickChartRenderer.kt | 5 +- .../appdev/charting/utils/ContextUtils.kt | 4 +- .../info/appdev/charting/utils/NumberUtils.kt | 18 --- .../charting/utils/PaintStyleAndroid.kt | 15 +++ .../charting/utils/ChartTypeface.android.kt | 3 + .../charting/components/AxisDependency.kt | 13 ++ .../appdev/charting/components/LegendForm.kt | 40 ++++++ .../appdev/charting/utils/ChartTypeface.kt | 9 ++ .../appdev/charting/utils/ColorTemplate.kt | 12 +- .../info/appdev/charting/utils/DashEffect.kt | 27 ++++ .../appdev/charting/utils/DisplayMetrics.kt | 14 +++ .../info/appdev/charting/utils/PaintStyle.kt | 12 ++ .../info/appdev/charting/utils/PointF.kt | 38 +----- .../charting/utils/ChartTypeface.desktop.kt | 6 + .../charting/utils/ChartTypeface.ios.kt | 6 + 24 files changed, 312 insertions(+), 116 deletions(-) create mode 100644 chartLib/src/main/kotlin/info/appdev/charting/utils/PaintStyleAndroid.kt create mode 100644 chartLibCore/src/androidMain/kotlin/info/appdev/charting/utils/ChartTypeface.android.kt create mode 100644 chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/AxisDependency.kt create mode 100644 chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/LegendForm.kt create mode 100644 chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ChartTypeface.kt create mode 100644 chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/DashEffect.kt create mode 100644 chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/DisplayMetrics.kt create mode 100644 chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/PaintStyle.kt rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/utils/PointF.kt (59%) create mode 100644 chartLibCore/src/desktopMain/kotlin/info/appdev/charting/utils/ChartTypeface.desktop.kt create mode 100644 chartLibCore/src/iosMain/kotlin/info/appdev/charting/utils/ChartTypeface.ios.kt diff --git a/KotlinMultiplatform.md b/KotlinMultiplatform.md index 7a086138..72ed803c 100644 --- a/KotlinMultiplatform.md +++ b/KotlinMultiplatform.md @@ -143,8 +143,104 @@ lint Existing custom lint rules (unchanged) `chartLib` itself (proof the package-preserving move is fully transparent to existing consumers); full `./gradlew test :chartLibCore:allTests` and `chartLibCompose:assembleDebug`/`app:assembleDebug` all pass. -- [ ] **Step A.3 (remainder) — Migrate `BaseDataSet`/`DataSet`/`ChartData` and - `interfaces/` (`IDataSet` family, `dataprovider` family).** +- [ ] **Step A.3 (remainder, partial) — DataSet-family groundwork: common + enums/abstractions landed; full `IDataSet`/`BaseDataSet`/`DataSet`/`ChartData` + move is BLOCKED, see below.** + - **Landed in `chartLibCore` (new, reusable common types):** + - `components/AxisDependency.kt` (top-level enum: `LEFT`, `RIGHT`) — + extracted from `YAxis`'s nested enum. `YAxis` keeps a nested + `typealias AxisDependency = info.appdev.charting.components.AxisDependency` + so `YAxis.AxisDependency.LEFT` and all existing imports/call sites keep + working unchanged (nested type aliases are a real, if lesser-known, + Kotlin feature — see kotlinlang.org's type-alias docs). + - `components/LegendForm.kt` (top-level enum: `NONE`, `EMPTY`, `DEFAULT`, + `SQUARE`, `CIRCLE`, `LINE`) — same nested-typealias treatment applied to + `Legend.LegendForm`. + - `utils/PaintStyle.kt` (top-level enum: `FILL`, `STROKE`, `FILL_AND_STROKE`) + — platform-independent replacement for `android.graphics.Paint.Style`, + used by `ICandleDataSet`/`CandleDataSet`. A new Android-only converter + `PaintStyle?.toAndroidPaintStyle()` (in `chartLib`'s + `utils/PaintStyleAndroid.kt`) bridges it back to `Paint.Style` at the two + `CandleStickChartRenderer` draw call sites; the demo app's + `CandleStickChartActivity` was updated to set `PaintStyle.FILL`/`STROKE` + instead of `Paint.Style.FILL`/`STROKE`. + - `utils/ChartTypeface.kt` (+ android/iOS/desktop actuals) — same + `expect`/`actual` pattern as `ChartIcon`. `expect open class ChartTypeface` + (must be `open`, not `abstract`, to match `android.graphics.Typeface`'s + modality) with `actual typealias ChartTypeface = android.graphics.Typeface` + on Android (zero-cost) and placeholder `actual open class ChartTypeface` on + iOS/desktop. Applied to `IDataSet.valueTypeface`, `BaseDataSet.mValueTypeface`, + and `ChartData.setValueTypeface(tf: ChartTypeface?)` — these three files + **stay in `chartLib`** for now (see blocker below) but their public API + surface is now common-type-ready. + - `utils/DashEffect.kt` — a common `class DashEffect(intervals: FloatArray, + phase: Float)` (manual `equals`/`hashCode` via `contentEquals`, since + Kotlin data classes don't do array content-equality automatically) designed + to eventually replace `android.graphics.DashPathEffect` in `IDataSet`/ + `BaseDataSet`/`ILineDataSet`/etc. **Not yet wired up** — the DashPathEffect + usages in `BaseDataSet.formLineDashEffect`, `LineDataSet.dashPathEffect`, + and `LineScatterCandleRadarDataSet.dashPathEffectHighlight` are consumed + directly as `android.graphics.DashPathEffect` at several renderer call + sites (`LegendRenderer`, `LineChartRenderer`, `CandleStickChartRenderer`, + etc.); swapping the property types requires updating every one of those + render call sites with an Android-side conversion helper (mirroring what + was just done for `PaintStyle`). Left as a distinct, separately-scoped + follow-up sub-slice ("A.3c") rather than folding it into an already-large + change. + - `utils/DisplayMetrics.kt` — a common `var chartDensity: Float = 1f` + + `Float.convertDpToPixel()` extension, replacing the old Android-only + `Float.convertDpToPixel()` in `chartLib`'s `NumberUtils.kt` (which read a + nullable `android.util.DisplayMetrics` global and logged a Timber warning + if uninitialized). `chartLib`'s `Context.initUtils()` now sets + `chartDensity = this.resources.displayMetrics.density` directly. This is a + **minor documented behavior change**: the old "Utils NOT INITIALIZED" + Timber warning is gone — `chartDensity` simply defaults to `1f` (no + scaling) until a platform sets it, rather than warning and passing the + value through unscaled. + - `utils/PointF.kt` moved from `chartLib` to `chartLibCore` commonMain, + dropping `android.os.Parcelable`/`Parcel` support (same rationale/breaking- + change disclosure as `EntryFloat` in the previous slice — no internal usage + of `PointF.CREATOR` found repo-wide) and replacing the JVM-only + `Math.toRadians(...)` call in `changePosition(...)` with a manual + `degrees * PI / 180.0` conversion using `kotlin.math.PI`. + - `ColorTemplate.argb(r, g, b)` made `public` (was `private`) and two new + public constants `ColorTemplate.WHITE`/`ColorTemplate.BLACK` added, as + reusable platform-independent replacements for + `android.graphics.Color.rgb(...)`/`Color.WHITE`/`Color.BLACK` — not yet + applied inside `LineDataSet`/`RadarDataSet`/`BarLineScatterCandleBubbleDataSet` + (deferred along with the rest of those classes, see blocker below). + - **Key blocker discovered (documents a correction to the migration order in + "Migration order rationale" below):** `IDataSet.valueFormatter` is typed + `IValueFormatter`, and `IValueFormatter.getFormattedValue(...)` takes a + `ViewPortHandler?` parameter; `ILineDataSet.fillFormatter` is typed + `IFillFormatter`, whose `getFillLinePosition(...)` takes a + `LineDataProvider` (a chart-view-facing `interfaces/dataprovider` type). + `ViewPortHandler` is a 595-line `Matrix`/`RectF`/`View`-coupled geometry + engine (planned Step A.6), and `LineDataProvider` is transitively coupled to + the whole View-based rendering pipeline (planned to move alongside/after + Compose Multiplatform rendering work, not before). Because `IDataSet` and + `ILineDataSet` reference these types directly in their member signatures, + **`IDataSet`/`BaseDataSet`/`DataSet`/`ChartData` and every concrete + `*DataSet` subclass cannot be moved to `chartLibCore` until Step A.6 + (`ViewPortHandler` abstraction) lands**, and a common formatter/dataprovider + story is designed. This revises the previously-assumed ordering (A.3 fully + before A.4/A.6) — **A.6 must now at least partially precede full A.3 + completion.** `interfaces/dataprovider/*` was also confirmed to stay + Android-only in general (it exposes `RectF`, `Transformer`, and other + View-pipeline types) rather than move to `chartLibCore`. + - Also confirmed `interfaces/datasets/IBarDataSet` (depends on `utils/Fill.kt`, + a `Shader`/`LinearGradient`/`Drawable`-heavy gradient-fill abstraction) and + `IScatterDataSet` (depends on `renderer/scatter/IShapeRenderer`, which takes + `Canvas`/`Paint` directly) have their own separate Android-only blockers + beyond the `IValueFormatter`/`IFillFormatter` one — `BarDataSet` and + `ScatterDataSet` will need dedicated `Fill`/`IShapeRenderer` abstraction + work even after Step A.6 unblocks the rest of the family. + - Verified: `chartLibCore` builds/tests green on Android/iOS×3/desktop-JVM; + `chartLib:compileDebugKotlin`, full `./gradlew test`, + `chartLibCompose:assembleDebug`, and `app:assembleDebug` all pass with the + `YAxis`/`Legend`/`CandleDataSet`/`ICandleDataSet`/`IDataSet`/`BaseDataSet`/ + `ChartData`/`CandleStickChartRenderer`/`CandleStickChartActivity` changes + described above. - [ ] **Step A.4 — Migrate formatters (`formatter/`) and remaining highlighters (`highlight/`).** - [ ] **Step A.5 — Migrate `components/` (axes, legend, limit lines).** @@ -222,6 +318,25 @@ at least one of them: stay Android-only (`androidMain`) — they are fundamentally platform IO/graphics concerns, not shared chart logic. +> **Correction found while executing step 1 (see the Status changelog above):** +> `interfaces/datasets/IDataSet.valueFormatter` is typed `IValueFormatter`, whose +> `getFormattedValue(...)` takes a `ViewPortHandler?`, and +> `ILineDataSet.fillFormatter` is typed `IFillFormatter`, whose +> `getFillLinePosition(...)` takes a `LineDataProvider` (`interfaces/dataprovider`). +> This means step 1 (`data/`+`interfaces/`) **cannot fully complete before** step 5 +> (`ViewPortHandler`/`Transformer`) at least partially lands, and before a +> common-friendly design exists for `interfaces/dataprovider` (which today exposes +> `RectF`/`Transformer` and is tightly coupled to the concrete `View`-based chart +> classes). The enums/typedefs that *don't* transitively depend on these +> (`AxisDependency`, `LegendForm`, `PaintStyle`, `ChartTypeface`, `DashEffect`, +> `PointF`, `chartDensity`/`convertDpToPixel`) were still landed in `chartLibCore` +> as groundwork, and applied to their Android-only call sites in `chartLib` where +> that was safe to do without moving the whole class. `IBarDataSet`/`BarDataSet` +> (via `utils/Fill.kt`) and `IScatterDataSet`/`ScatterDataSet` (via +> `renderer/scatter/IShapeRenderer`) have their own independent blockers on top of +> this and will need dedicated abstraction work regardless of when steps 1/5 +> otherwise complete. + ## Step A.7+ — Compose Multiplatform renderer Once `chartLibCore` covers data/formatters/highlighters/components/viewport math, diff --git a/app/src/main/kotlin/info/appdev/chartexample/CandleStickChartActivity.kt b/app/src/main/kotlin/info/appdev/chartexample/CandleStickChartActivity.kt index 04760f6a..8f21c439 100644 --- a/app/src/main/kotlin/info/appdev/chartexample/CandleStickChartActivity.kt +++ b/app/src/main/kotlin/info/appdev/chartexample/CandleStickChartActivity.kt @@ -4,7 +4,6 @@ import android.Manifest import android.content.Intent import android.content.pm.PackageManager import android.graphics.Color -import android.graphics.Paint import android.os.Bundle import android.view.Menu import android.view.MenuItem @@ -21,6 +20,7 @@ import info.appdev.charting.components.YAxis.AxisDependency import info.appdev.charting.data.CandleData import info.appdev.charting.data.CandleDataSet import info.appdev.charting.data.CandleEntryFloat +import info.appdev.charting.utils.PaintStyle class CandleStickChartActivity : DemoBase(), OnSeekBarChangeListener { @@ -108,9 +108,9 @@ class CandleStickChartActivity : DemoBase(), OnSeekBarChangeListener { set1.shadowColor = Color.DKGRAY set1.shadowWidth = 0.7f set1.decreasingColor = Color.BLUE - set1.decreasingPaintStyle = Paint.Style.FILL + set1.decreasingPaintStyle = PaintStyle.FILL set1.increasingColor = Color.rgb(122, 242, 84) - set1.increasingPaintStyle = Paint.Style.STROKE + set1.increasingPaintStyle = PaintStyle.STROKE set1.neutralColor = Color.BLUE //set1.setHighlightLineWidth(1f); diff --git a/chartLib/src/main/kotlin/info/appdev/charting/components/Legend.kt b/chartLib/src/main/kotlin/info/appdev/charting/components/Legend.kt index 1c94e9fc..344b2940 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/components/Legend.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/components/Legend.kt @@ -20,37 +20,12 @@ import kotlin.math.min * The legend object is NOT available before setting data to the chart. */ class Legend() : ComponentBase() { - enum class LegendForm { - /** - * Avoid drawing a form - */ - NONE, - - /** - * Do not draw the form, but leave space for it - */ - EMPTY, - - /** - * Use default (default dataset's form to the legend's form) - */ - DEFAULT, - - /** - * Draw a square - */ - SQUARE, - - /** - * Draw a circle - */ - CIRCLE, - - /** - * Draw a horizontal line - */ - LINE - } + /** + * Kept as a nested type alias (rather than a nested enum) so that the actual enum can live + * in the platform-independent `chartLibCore` module while `Legend.LegendForm` keeps + * working unchanged for existing callers. + */ + typealias LegendForm = info.appdev.charting.components.LegendForm enum class LegendHorizontalAlignment { LEFT, CENTER, RIGHT diff --git a/chartLib/src/main/kotlin/info/appdev/charting/components/YAxis.kt b/chartLib/src/main/kotlin/info/appdev/charting/components/YAxis.kt index 16ad7034..4d869ea9 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/components/YAxis.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/components/YAxis.kt @@ -17,6 +17,13 @@ import kotlin.math.min * setting data for the chart. */ open class YAxis : AxisBase { + /** + * Kept as a nested type alias (rather than a nested enum) so that the actual enum can live + * in the platform-independent `chartLibCore` module while `YAxis.AxisDependency` keeps + * working unchanged for existing callers. + */ + typealias AxisDependency = info.appdev.charting.components.AxisDependency + /** * indicates if the bottom y-label entry is drawn or not */ @@ -96,13 +103,6 @@ open class YAxis : AxisBase { */ var maxWidth: Float = Float.POSITIVE_INFINITY - /** - * Enum that specifies the axis a DataSet should be plotted against, either LEFT or RIGHT. - */ - enum class AxisDependency { - LEFT, RIGHT - } - constructor() : super() { this.axisDependency = AxisDependency.LEFT this.mYOffset = 0f 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 110ae26c..7a7a67c2 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/BaseDataSet.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/data/BaseDataSet.kt @@ -3,7 +3,7 @@ package info.appdev.charting.data import android.content.Context import android.graphics.Color import android.graphics.DashPathEffect -import android.graphics.Typeface +import info.appdev.charting.utils.ChartTypeface import androidx.annotation.ColorInt import androidx.core.content.ContextCompat import info.appdev.charting.components.Legend.LegendForm @@ -56,7 +56,7 @@ abstract class BaseDataSet>() : IDataSet { /** * the typeface used for the value text */ - protected var mValueTypeface: Typeface? = null + protected var mValueTypeface: ChartTypeface? = null private var mForm = LegendForm.DEFAULT private var mFormSize = Float.NaN @@ -280,7 +280,7 @@ abstract class BaseDataSet>() : IDataSet { return mValueColors[value % mValueColors.size] } - override var valueTypeface: Typeface? + override var valueTypeface: ChartTypeface? get() = mValueTypeface set(value) { mValueTypeface = value diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/CandleDataSet.kt b/chartLib/src/main/kotlin/info/appdev/charting/data/CandleDataSet.kt index 79e4d142..dd461ce2 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/CandleDataSet.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/data/CandleDataSet.kt @@ -1,9 +1,9 @@ package info.appdev.charting.data -import android.graphics.Paint import androidx.annotation.ColorInt import info.appdev.charting.interfaces.datasets.ICandleDataSet import info.appdev.charting.utils.ColorTemplate +import info.appdev.charting.utils.PaintStyle import info.appdev.charting.utils.convertDpToPixel /** @@ -37,13 +37,13 @@ open class CandleDataSet(yVals: MutableList, label: String = " * paint style when open < close * increasing candlesticks are traditionally hollow */ - protected var mIncreasingPaintStyle: Paint.Style? = Paint.Style.STROKE + protected var mIncreasingPaintStyle: PaintStyle? = PaintStyle.STROKE /** * paint style when open > close * decreasing candlesticks are traditionally filled */ - protected var mDecreasingPaintStyle: Paint.Style? = Paint.Style.FILL + protected var mDecreasingPaintStyle: PaintStyle? = PaintStyle.FILL /** * color for open == close @@ -172,7 +172,7 @@ open class CandleDataSet(yVals: MutableList, label: String = " */ mDecreasingColor = value } - override var increasingPaintStyle: Paint.Style? + override var increasingPaintStyle: PaintStyle? get() = mIncreasingPaintStyle set(value) { /** @@ -180,7 +180,7 @@ open class CandleDataSet(yVals: MutableList, label: String = " */ mIncreasingPaintStyle = value } - override var decreasingPaintStyle: Paint.Style? + override var decreasingPaintStyle: PaintStyle? get() = mDecreasingPaintStyle set(value) { /** diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/ChartData.kt b/chartLib/src/main/kotlin/info/appdev/charting/data/ChartData.kt index dc247286..bdce5fea 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/ChartData.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/data/ChartData.kt @@ -1,7 +1,7 @@ package info.appdev.charting.data import android.annotation.SuppressLint -import android.graphics.Typeface +import info.appdev.charting.utils.ChartTypeface import info.appdev.charting.components.YAxis.AxisDependency import info.appdev.charting.formatter.IValueFormatter import info.appdev.charting.highlight.Highlight @@ -560,7 +560,7 @@ abstract class ChartData> : Serializable { /** * Sets the Typeface for all value-labels for all DataSets this data object contains. */ - fun setValueTypeface(tf: Typeface?) { + fun setValueTypeface(tf: ChartTypeface?) { for (set in this.dataSets) { set.valueTypeface = tf } diff --git a/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/ICandleDataSet.kt b/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/ICandleDataSet.kt index ce86fccf..749ef03c 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/ICandleDataSet.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/ICandleDataSet.kt @@ -1,6 +1,6 @@ package info.appdev.charting.interfaces.datasets -import android.graphics.Paint +import info.appdev.charting.utils.PaintStyle import info.appdev.charting.data.CandleEntryFloat interface ICandleDataSet : ILineScatterCandleRadarDataSet { @@ -44,12 +44,12 @@ interface ICandleDataSet : ILineScatterCandleRadarDataSet { /** * Returns paint style when open < close */ - val increasingPaintStyle: Paint.Style? + val increasingPaintStyle: PaintStyle? /** * Returns paint style when open > close */ - val decreasingPaintStyle: Paint.Style? + val decreasingPaintStyle: PaintStyle? /** * Is the shadow color same as the candle color? diff --git a/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/IDataSet.kt b/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/IDataSet.kt index d0f0ceef..da94998b 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/IDataSet.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/IDataSet.kt @@ -1,7 +1,7 @@ package info.appdev.charting.interfaces.datasets import android.graphics.DashPathEffect -import android.graphics.Typeface +import info.appdev.charting.utils.ChartTypeface import info.appdev.charting.components.Legend import info.appdev.charting.components.YAxis import info.appdev.charting.data.DataSet @@ -238,7 +238,7 @@ interface IDataSet> { /** * Sets a Typeface for the value-labels of this DataSet. */ - var valueTypeface: Typeface? + var valueTypeface: ChartTypeface? /** * Sets the text-size of the value-labels of this DataSet in dp. diff --git a/chartLib/src/main/kotlin/info/appdev/charting/renderer/CandleStickChartRenderer.kt b/chartLib/src/main/kotlin/info/appdev/charting/renderer/CandleStickChartRenderer.kt index 261c138b..0b29101f 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/renderer/CandleStickChartRenderer.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/renderer/CandleStickChartRenderer.kt @@ -11,6 +11,7 @@ import info.appdev.charting.utils.PointF import info.appdev.charting.utils.ViewPortHandler import info.appdev.charting.utils.convertDpToPixel import info.appdev.charting.utils.drawImage +import info.appdev.charting.utils.toAndroidPaintStyle open class CandleStickChartRenderer( var dataProvider: CandleDataProvider, @@ -117,7 +118,7 @@ open class CandleStickChartRenderer( paintRender.color = dataSet.decreasingColor } - paintRender.style = dataSet.decreasingPaintStyle + paintRender.style = dataSet.decreasingPaintStyle.toAndroidPaintStyle() canvas.drawRect( bodyBuffers[0], bodyBuffers[3], @@ -131,7 +132,7 @@ open class CandleStickChartRenderer( paintRender.color = dataSet.increasingColor } - paintRender.style = dataSet.increasingPaintStyle + paintRender.style = dataSet.increasingPaintStyle.toAndroidPaintStyle() canvas.drawRect( bodyBuffers[0], bodyBuffers[1], diff --git a/chartLib/src/main/kotlin/info/appdev/charting/utils/ContextUtils.kt b/chartLib/src/main/kotlin/info/appdev/charting/utils/ContextUtils.kt index 060a8410..c005d562 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/utils/ContextUtils.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/utils/ContextUtils.kt @@ -2,10 +2,8 @@ package info.appdev.charting.utils import android.content.Context import android.os.Build -import android.util.DisplayMetrics import android.view.ViewConfiguration -var metrics: DisplayMetrics? = null var minimumFlingVelocity = 0 var maximumFlingVelocity = 0 @@ -14,7 +12,7 @@ fun Context.initUtils() { minimumFlingVelocity = viewConfiguration.scaledMinimumFlingVelocity maximumFlingVelocity = viewConfiguration.scaledMaximumFlingVelocity - metrics = this.resources.displayMetrics + chartDensity = this.resources.displayMetrics.density } fun getSDKInt() = Build.VERSION.SDK_INT diff --git a/chartLib/src/main/kotlin/info/appdev/charting/utils/NumberUtils.kt b/chartLib/src/main/kotlin/info/appdev/charting/utils/NumberUtils.kt index 3eeaaa1a..d210eb8d 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/utils/NumberUtils.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/utils/NumberUtils.kt @@ -1,6 +1,5 @@ package info.appdev.charting.utils -import timber.log.Timber import kotlin.math.ceil import kotlin.math.log10 import kotlin.math.pow @@ -36,23 +35,6 @@ fun Double.roundToNextSignificant(): Float { return shifted / magnitude } -/** - * This method converts dp unit to equivalent pixels, depending on device - * density. NEEDS UTILS TO BE INITIALIZED BEFORE USAGE. - * - * dp A value in dp (density independent pixels) unit. Which we need - * to convert into pixels - * @return A float value to represent px equivalent to dp depending on - * device density - */ -fun Float.convertDpToPixel(): Float { - if (metrics == null) { - Timber.e("Utils NOT INITIALIZED. You need to call Utils.init(...) at least once before calling Utils.convertDpToPixel(...). Otherwise conversion does not take place.") - return this - } else - return this * metrics!!.density -} - fun Float.formatNumber(digitCount: Int, separateThousands: Boolean, separateChar: Char = '.'): String { var number = this var digitCount = digitCount diff --git a/chartLib/src/main/kotlin/info/appdev/charting/utils/PaintStyleAndroid.kt b/chartLib/src/main/kotlin/info/appdev/charting/utils/PaintStyleAndroid.kt new file mode 100644 index 00000000..23ce79d4 --- /dev/null +++ b/chartLib/src/main/kotlin/info/appdev/charting/utils/PaintStyleAndroid.kt @@ -0,0 +1,15 @@ +package info.appdev.charting.utils + +import android.graphics.Paint + +/** + * Converts the platform-independent [PaintStyle] used by the data model (e.g. + * [info.appdev.charting.interfaces.datasets.ICandleDataSet]) to the `android.graphics.Paint.Style` + * expected by the Android rendering pipeline. + */ +fun PaintStyle?.toAndroidPaintStyle(default: Paint.Style = Paint.Style.FILL): Paint.Style = when (this) { + PaintStyle.FILL -> Paint.Style.FILL + PaintStyle.STROKE -> Paint.Style.STROKE + PaintStyle.FILL_AND_STROKE -> Paint.Style.FILL_AND_STROKE + null -> default +} diff --git a/chartLibCore/src/androidMain/kotlin/info/appdev/charting/utils/ChartTypeface.android.kt b/chartLibCore/src/androidMain/kotlin/info/appdev/charting/utils/ChartTypeface.android.kt new file mode 100644 index 00000000..80d2735b --- /dev/null +++ b/chartLibCore/src/androidMain/kotlin/info/appdev/charting/utils/ChartTypeface.android.kt @@ -0,0 +1,3 @@ +package info.appdev.charting.utils + +actual typealias ChartTypeface = android.graphics.Typeface diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/AxisDependency.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/AxisDependency.kt new file mode 100644 index 00000000..104a4a3f --- /dev/null +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/AxisDependency.kt @@ -0,0 +1,13 @@ +package info.appdev.charting.components + +/** + * Specifies which y-axis (LEFT or RIGHT) a DataSet should be plotted against. + * + * Platform-independent home for this enum; [info.appdev.charting.components.YAxis] + * (Android-only for now) exposes it as a nested `YAxis.AxisDependency` type alias + * to preserve the existing public API. + */ +enum class AxisDependency { + LEFT, + RIGHT +} diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/LegendForm.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/LegendForm.kt new file mode 100644 index 00000000..0d5a6863 --- /dev/null +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/LegendForm.kt @@ -0,0 +1,40 @@ +package info.appdev.charting.components + +/** + * Describes how a DataSet's legend entry ("form") should be drawn. + * + * Platform-independent home for this enum; [info.appdev.charting.components.Legend] + * (Android-only for now) exposes it as a nested `Legend.LegendForm` type alias + * to preserve the existing public API. + */ +enum class LegendForm { + /** + * Avoid drawing a form + */ + NONE, + + /** + * Do not draw the form, but leave space for it + */ + EMPTY, + + /** + * Use default (default dataset's form to the legend's form) + */ + DEFAULT, + + /** + * Draw a square + */ + SQUARE, + + /** + * Draw a circle + */ + CIRCLE, + + /** + * Draw a horizontal line + */ + LINE +} diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ChartTypeface.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ChartTypeface.kt new file mode 100644 index 00000000..cafd373d --- /dev/null +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ChartTypeface.kt @@ -0,0 +1,9 @@ +package info.appdev.charting.utils + +/** + * Platform-independent placeholder for a font/typeface handle used to draw value labels. + * + * On Android this is a transparent type alias for [android.graphics.Typeface], so existing + * Android code that reads/writes `Typeface` values via the data model keeps working unchanged. + */ +expect open class ChartTypeface diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ColorTemplate.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ColorTemplate.kt index 1809971b..a22a9d31 100644 --- a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ColorTemplate.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ColorTemplate.kt @@ -98,11 +98,21 @@ object ColorTemplate { return result } + /** + * Fully opaque white, platform-independent replacement for android.graphics.Color.WHITE. + */ + val WHITE: Int = argb(255, 255, 255) + + /** + * Fully opaque black, platform-independent replacement for android.graphics.Color.BLACK. + */ + val BLACK: Int = argb(0, 0, 0) + /** * Builds a fully opaque ARGB color int from the given red/green/blue components (0-255), * platform-independent replacement for android.graphics.Color.rgb(r, g, b). */ - private fun argb(red: Int, green: Int, blue: Int): Int { + fun argb(red: Int, green: Int, blue: Int): Int { return (0xFF shl 24) or ((red and 0xFF) shl 16) or ((green and 0xFF) shl 8) or (blue and 0xFF) } } diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/DashEffect.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/DashEffect.kt new file mode 100644 index 00000000..55627631 --- /dev/null +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/DashEffect.kt @@ -0,0 +1,27 @@ +package info.appdev.charting.utils + +/** + * Platform-independent replacement for `android.graphics.DashPathEffect`, describing a + * dashed-line pattern for lines drawn by the chart (legend forms, highlight lines, etc.). + * + * @param intervals alternating "on" and "off" lengths, in pixels; must have an even size >= 2 + * @param phase offset into the intervals array at which drawing begins + */ +class DashEffect(val intervals: FloatArray, val phase: Float) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is DashEffect) return false + return intervals.contentEquals(other.intervals) && phase == other.phase + } + + override fun hashCode(): Int { + var result = intervals.contentHashCode() + result = 31 * result + phase.hashCode() + return result + } + + override fun toString(): String { + return "DashEffect(intervals=${intervals.contentToString()}, phase=$phase)" + } +} diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/DisplayMetrics.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/DisplayMetrics.kt new file mode 100644 index 00000000..d8da64b6 --- /dev/null +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/DisplayMetrics.kt @@ -0,0 +1,14 @@ +package info.appdev.charting.utils + +/** + * Current display density scale factor (pixels per dp), used by [convertDpToPixel] to convert + * dp values to pixels. Must be set once per platform before dp-based sizes are used + * (e.g. via `Context.initUtils()` on Android). Defaults to 1f (no scaling) so that non-Android + * targets still produce a sane (if unscaled) result before an explicit density is provided. + */ +var chartDensity: Float = 1f + +/** + * Converts a dp (density-independent pixels) value to pixels, depending on [chartDensity]. + */ +fun Float.convertDpToPixel(): Float = this * chartDensity diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/PaintStyle.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/PaintStyle.kt new file mode 100644 index 00000000..c72e7c66 --- /dev/null +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/PaintStyle.kt @@ -0,0 +1,12 @@ +package info.appdev.charting.utils + +/** + * Platform-independent replacement for `android.graphics.Paint.Style`. + * Used by the data model (e.g. [info.appdev.charting.interfaces.datasets.ICandleDataSet]) + * to describe how a shape should be painted, without depending on `android.graphics.Paint`. + */ +enum class PaintStyle { + FILL, + STROKE, + FILL_AND_STROKE +} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/utils/PointF.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/PointF.kt similarity index 59% rename from chartLib/src/main/kotlin/info/appdev/charting/utils/PointF.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/PointF.kt index e6f60fc8..c92f80f4 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/utils/PointF.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/PointF.kt @@ -1,8 +1,7 @@ package info.appdev.charting.utils -import android.os.Parcel -import android.os.Parcelable import info.appdev.charting.utils.ObjectPool.Poolable +import kotlin.math.PI import kotlin.math.cos import kotlin.math.sin @@ -17,18 +16,6 @@ class PointF : Poolable { this.y = y } - /** - * Set the point's coordinates from the data stored in the specified - * parcel. To write a point to a parcel, call writeToParcel(). - * Provided to support older Android devices. - * - * @param in The parcel to read the point's coordinates from - */ - fun my_readFromParcel(`in`: Parcel) { - x = `in`.readFloat() - y = `in`.readFloat() - } - override fun instantiate(): PointF { return PointF(0f, 0f) } @@ -68,24 +55,6 @@ class PointF : Poolable { fun recycleInstances(instances: MutableList) { pool.recycle(instances) } - - val CREATOR: Parcelable.Creator = object : Parcelable.Creator { - /** - * Return a new point from the data in the specified parcel. - */ - override fun createFromParcel(`in`: Parcel): PointF { - val r = PointF(0f, 0f) - r.my_readFromParcel(`in`) - return r - } - - /** - * Return an array of rectangles of the specified size. - */ - override fun newArray(size: Int): Array { - return arrayOfNulls(size) - } - } } } @@ -103,6 +72,7 @@ fun PointF.getPosition(dist: Float, angle: Float): PointF { } fun PointF.changePosition(dist: Float, angle: Float, outputPoint: PointF) { - outputPoint.x = (this.x + dist * cos(Math.toRadians(angle.toDouble()))).toFloat() - outputPoint.y = (this.y + dist * sin(Math.toRadians(angle.toDouble()))).toFloat() + val radians = angle.toDouble() * PI / 180.0 + outputPoint.x = (this.x + dist * cos(radians)).toFloat() + outputPoint.y = (this.y + dist * sin(radians)).toFloat() } \ No newline at end of file diff --git a/chartLibCore/src/desktopMain/kotlin/info/appdev/charting/utils/ChartTypeface.desktop.kt b/chartLibCore/src/desktopMain/kotlin/info/appdev/charting/utils/ChartTypeface.desktop.kt new file mode 100644 index 00000000..c8c53e73 --- /dev/null +++ b/chartLibCore/src/desktopMain/kotlin/info/appdev/charting/utils/ChartTypeface.desktop.kt @@ -0,0 +1,6 @@ +package info.appdev.charting.utils + +/** + * Placeholder implementation until a native (java.awt.Font-based) typeface abstraction is designed. + */ +actual open class ChartTypeface diff --git a/chartLibCore/src/iosMain/kotlin/info/appdev/charting/utils/ChartTypeface.ios.kt b/chartLibCore/src/iosMain/kotlin/info/appdev/charting/utils/ChartTypeface.ios.kt new file mode 100644 index 00000000..bdb0aa5b --- /dev/null +++ b/chartLibCore/src/iosMain/kotlin/info/appdev/charting/utils/ChartTypeface.ios.kt @@ -0,0 +1,6 @@ +package info.appdev.charting.utils + +/** + * Placeholder implementation until a native (CoreText-based) typeface abstraction is designed. + */ +actual open class ChartTypeface From da3a643ae36598c04e5e728254ab63f2736103fa Mon Sep 17 00:00:00 2001 From: Hannes Achleitner Date: Sun, 27 Sep 2026 07:31:29 +0200 Subject: [PATCH 05/22] CI fix --- KotlinMultiplatform.md | 22 ++++++++++++++++++++++ chartLibCore/build.gradle.kts | 1 + gradle.properties | 5 +++++ 3 files changed, 28 insertions(+) diff --git a/KotlinMultiplatform.md b/KotlinMultiplatform.md index 72ed803c..6c6e992a 100644 --- a/KotlinMultiplatform.md +++ b/KotlinMultiplatform.md @@ -143,6 +143,28 @@ lint Existing custom lint rules (unchanged) `chartLib` itself (proof the package-preserving move is fully transparent to existing consumers); full `./gradlew test :chartLibCore:allTests` and `chartLibCompose:assembleDebug`/`app:assembleDebug` all pass. +- [x] **CI fix — `chartLibCore` KMP module breaks the Linux "Check" CI job.** + The `com.android.kotlin.multiplatform.library` plugin doesn't enable JVM-based + unit tests for the `android` target by default, and Gradle errors out (rather + than just warning) when it hits *disabled* Kotlin/Native targets — like the + iOS targets on a non-macOS CI runner — unless explicitly told those are okay + to skip. Fixed both: + - Added `withHostTest {}` to `chartLibCore/build.gradle.kts`'s `android { }` + block, which enables a `testAndroidHostTest` task that runs `commonTest` + (currently just `ObjectPoolTest`) on the JVM, no emulator/device needed — + this is what makes `chartLibCore`'s tests actually participate in `./gradlew + test` at all. + - Added `kotlin.native.ignoreDisabledTargets=true` to the root + `gradle.properties`, so Gradle treats disabled iOS Kotlin/Native targets + (unbuildable on Linux CI runners, which lack Xcode) as a no-op instead of a + hard failure. iOS targets still build/test normally on macOS (verified + locally on this machine, which has Xcode). + - Verified locally: `./gradlew test`, `:chartLibCore:build`, + `:chartLibCore:allTests`, `:chartLibCore:testAndroidHostTest`, + `:chartLibCompose:assembleDebug`, `:app:assembleDebug` all still pass on + macOS with Xcode present (all iOS/android/desktop targets still compile and + test normally; nothing is being silently skipped here — the flag only + matters on hosts that can't build iOS at all). - [ ] **Step A.3 (remainder, partial) — DataSet-family groundwork: common enums/abstractions landed; full `IDataSet`/`BaseDataSet`/`DataSet`/`ChartData` move is BLOCKED, see below.** diff --git a/chartLibCore/build.gradle.kts b/chartLibCore/build.gradle.kts index f21fd769..5c701744 100644 --- a/chartLibCore/build.gradle.kts +++ b/chartLibCore/build.gradle.kts @@ -17,6 +17,7 @@ kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } + withHostTest {} } jvm("desktop") diff --git a/gradle.properties b/gradle.properties index 2f17915e..085e5e2e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,3 +7,8 @@ SONATYPE_HOST=CENTRAL_PORTAL RELEASE_SIGNING_ENABLED=true POM_ARTIFACT_ID=chart android.nonTransitiveRClass=false + +# chartLibCore is a Kotlin Multiplatform module with iOS targets, which cannot be +# compiled/tested on non-macOS CI runners (e.g. the Linux "Check" job). Without this, +# Gradle fails the build instead of just skipping those disabled native tasks. +kotlin.native.ignoreDisabledTargets=true From defeb3960ad1cc47ef44285b14b8afc09108d6a8 Mon Sep 17 00:00:00 2001 From: Hannes Achleitner Date: Sun, 27 Sep 2026 07:40:42 +0200 Subject: [PATCH 06/22] Highlight/IHighlighter moved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Landed: highlight/Highlight.kt (dropped Serializable, switched to the top-level common AxisDependency) and highlight/IHighlighter.kt moved to chartLibCore — small but real, verified-green. * Blocked: All 10 formatter/ files and the remaining 7 highlight/ files (ChartHighlighter, BarHighlighter, HorizontalBarHighlighter, CombinedHighlighter, PieHighlighter, RadarHighlighter, PieRadarHighlighter) transitively depend on ViewPortHandler (Step A.6), AxisBase (Step A.5), IDataSet/ILineDataSet/LineDataProvider (blocked per last slice's finding), or concrete PieChart/RadarChart/PieRadarChartBase view classes. --- KotlinMultiplatform.md | 39 ++++++++++++++++++- .../appdev/charting/highlight/Highlight.kt | 5 +-- .../appdev/charting/highlight/IHighlighter.kt | 0 3 files changed, 39 insertions(+), 5 deletions(-) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/highlight/Highlight.kt (96%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/highlight/IHighlighter.kt (100%) diff --git a/KotlinMultiplatform.md b/KotlinMultiplatform.md index 6c6e992a..df865083 100644 --- a/KotlinMultiplatform.md +++ b/KotlinMultiplatform.md @@ -263,8 +263,43 @@ lint Existing custom lint rules (unchanged) `YAxis`/`Legend`/`CandleDataSet`/`ICandleDataSet`/`IDataSet`/`BaseDataSet`/ `ChartData`/`CandleStickChartRenderer`/`CandleStickChartActivity` changes described above. -- [ ] **Step A.4 — Migrate formatters (`formatter/`) and remaining highlighters - (`highlight/`).** +- [ ] **Step A.4 (partial) — `Highlight`/`IHighlighter` moved; the rest of + `formatter/` and `highlight/` is BLOCKED by the same Step A.6 dependency + found while investigating Step A.3.** + - **Landed:** `highlight/Highlight.kt` and `highlight/IHighlighter.kt` moved + to `chartLibCore` commonMain. `Highlight` dropped `java.io.Serializable` + (same disclosed-breaking-change rationale as `EntryFloat`/`PointF` — no + internal usage of Highlight serialization found repo-wide) and now imports + the top-level common `info.appdev.charting.components.AxisDependency` + instead of the nested `YAxis.AxisDependency` type alias (which itself still + resolves to the exact same type — this is purely so `Highlight` doesn't + need to reference the Android-only `YAxis` class to get at the enum). + `IHighlighter` needed no changes at all (it only referenced `Highlight`). + - **Everything else in `formatter/` is blocked:** every one of the 10 files in + `formatter/` (`IValueFormatter`, `DefaultValueFormatter`, + `IAxisValueFormatter`, `DefaultAxisValueFormatter`, `IndexAxisValueFormatter`, + `LargeValueFormatter`, `PercentFormatter`, `StackedValueFormatter`, + `IFillFormatter`, `DefaultFillFormatter`, `ColorFormatter`) imports at least + one of: `utils/ViewPortHandler` (Step A.6, not yet done), + `components/AxisBase` (Step A.5, not yet done), + `interfaces/datasets/IDataSet`/`ILineDataSet` (blocked per the Step A.3 + finding), or `interfaces/dataprovider/LineDataProvider` (Android-only, tied + to the View rendering pipeline). None can move yet. + - **Everything else in `highlight/` is blocked** for the same reason: + `ChartHighlighter`, `BarHighlighter`, `HorizontalBarHighlighter`, + `CombinedHighlighter` all import `interfaces/datasets/IDataSet` and/or + `interfaces/dataprovider/*`; `PieHighlighter`/`RadarHighlighter`/ + `PieRadarHighlighter` additionally import concrete `charts/PieChart`, + `charts/RadarChart`, `charts/PieRadarChartBase` `View` subclasses directly. + - **Conclusion:** Step A.4 cannot meaningfully proceed further until Step A.6 + (`ViewPortHandler`/`Transformer` common abstraction) and enough of Step A.5 + (`AxisBase` at minimum) land, exactly as flagged in the Step A.3 changelog + entry above. Recommend doing **Step A.6 next**, since it's the single + blocker unblocking the largest amount of remaining work (rest of A.3, all + of A.4, most of A.5). + - Verified: `chartLibCore` build/tests, `chartLib:compileDebugKotlin` (zero + source changes needed), full `./gradlew test`, + `chartLibCompose:assembleDebug`, `app:assembleDebug` all pass. - [ ] **Step A.5 — Migrate `components/` (axes, legend, limit lines).** - [ ] **Step A.6 — Migrate `utils/` geometry & viewport math (`ViewPortHandler`, `Transformer`, `PointF`).** diff --git a/chartLib/src/main/kotlin/info/appdev/charting/highlight/Highlight.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/highlight/Highlight.kt similarity index 96% rename from chartLib/src/main/kotlin/info/appdev/charting/highlight/Highlight.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/highlight/Highlight.kt index 902fc840..045c5f2d 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/highlight/Highlight.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/highlight/Highlight.kt @@ -1,12 +1,11 @@ package info.appdev.charting.highlight -import info.appdev.charting.components.YAxis.AxisDependency -import java.io.Serializable +import info.appdev.charting.components.AxisDependency /** * Contains information needed to determine the highlighted value. */ -class Highlight : Serializable { +class Highlight { /** * the x-value of the highlighted value */ diff --git a/chartLib/src/main/kotlin/info/appdev/charting/highlight/IHighlighter.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/highlight/IHighlighter.kt similarity index 100% rename from chartLib/src/main/kotlin/info/appdev/charting/highlight/IHighlighter.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/highlight/IHighlighter.kt From 0bd7fe237681b2450eb8ac68ebe535486a62302f Mon Sep 17 00:00:00 2001 From: Hannes Achleitner Date: Sun, 27 Sep 2026 07:50:30 +0200 Subject: [PATCH 07/22] Migrate components/ (axes, legend, limit lines) --- .../info/appdev/charting/utils/Matrix.kt | 203 ++++++++++++++++++ .../info/appdev/charting/utils/RectF.kt | 55 +++++ .../info/appdev/charting/utils/MatrixTest.kt | 156 ++++++++++++++ 3 files changed, 414 insertions(+) create mode 100644 chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/Matrix.kt create mode 100644 chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/RectF.kt create mode 100644 chartLibCore/src/commonTest/kotlin/info/appdev/charting/utils/MatrixTest.kt diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/Matrix.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/Matrix.kt new file mode 100644 index 00000000..9d496ba1 --- /dev/null +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/Matrix.kt @@ -0,0 +1,203 @@ +package info.appdev.charting.utils + +import kotlin.math.abs + +/** + * Platform-independent 3x3 affine/perspective transform matrix, API-compatible (in spirit) with + * `android.graphics.Matrix`'s affine subset that the chart rendering pipeline relies on + * (`postTranslate`, `postScale`, `setScale`, `setTranslate`, `postConcat`, `invert`, `mapPoints`, + * `mapRect`, `getValues`/`setValues`). + * + * Values are stored row-major, matching Android's layout, so [getValues]/[setValues] round-trip + * the same way existing `ViewPortHandler` buffer-manipulation code expects: + * ``` + * [ MSCALE_X MSKEW_X MTRANS_X ] + * [ MSKEW_Y MSCALE_Y MTRANS_Y ] + * [ MPERSP_0 MPERSP_1 MPERSP_2 ] + * ``` + */ +class Matrix { + private val values = FloatArray(9) + + init { + reset() + } + + fun reset() { + for (i in 0..8) values[i] = if (i == MSCALE_X || i == MSCALE_Y || i == MPERSP_2) 1f else 0f + } + + fun set(src: Matrix) { + src.values.copyInto(values) + } + + fun getValues(dst: FloatArray) { + values.copyInto(dst) + } + + fun setValues(src: FloatArray) { + src.copyInto(values) + } + + /** + * Replaces this matrix with a pure translation. + */ + fun setTranslate(dx: Float, dy: Float) { + reset() + values[MTRANS_X] = dx + values[MTRANS_Y] = dy + } + + /** + * Replaces this matrix with a scale about the given pivot point. + */ + fun setScale(sx: Float, sy: Float, px: Float = 0f, py: Float = 0f) { + reset() + values[MSCALE_X] = sx + values[MSCALE_Y] = sy + values[MTRANS_X] = px - sx * px + values[MTRANS_Y] = py - sy * py + } + + /** + * Post-concatenates a translation onto this matrix: `this = translate(dx, dy) * this`. + */ + fun postTranslate(dx: Float, dy: Float) { + val t = Matrix() + t.setTranslate(dx, dy) + postConcat(t) + } + + /** + * Post-concatenates a scale about the given pivot onto this matrix. + */ + fun postScale(sx: Float, sy: Float, px: Float = 0f, py: Float = 0f) { + val s = Matrix() + s.setScale(sx, sy, px, py) + postConcat(s) + } + + /** + * Post-concatenates [other] onto this matrix: `this = other * this`, i.e. [other] is applied + * *after* this matrix's existing transform when mapping a point. + */ + fun postConcat(other: Matrix) { + val result = multiply(other.values, this.values) + result.copyInto(values) + } + + /** + * Transforms an array of points in place: `(x0, y0, x1, y1, ...)`. + */ + fun mapPoints(pts: FloatArray) { + var i = 0 + while (i + 1 < pts.size) { + val x = pts[i] + val y = pts[i + 1] + + val nx = values[MSCALE_X] * x + values[MSKEW_X] * y + values[MTRANS_X] + val ny = values[MSKEW_Y] * x + values[MSCALE_Y] * y + values[MTRANS_Y] + val w = values[MPERSP_0] * x + values[MPERSP_1] * y + values[MPERSP_2] + + if (w == 1f || w == 0f) { + pts[i] = nx + pts[i + 1] = ny + } else { + pts[i] = nx / w + pts[i + 1] = ny / w + } + i += 2 + } + } + + /** + * Transforms [r] to the bounding box of its four transformed corners. + */ + fun mapRect(r: RectF) { + val pts = floatArrayOf( + r.left, r.top, + r.right, r.top, + r.right, r.bottom, + r.left, r.bottom + ) + mapPoints(pts) + + var minX = pts[0] + var maxX = pts[0] + var minY = pts[1] + var maxY = pts[1] + var i = 2 + while (i < pts.size) { + if (pts[i] < minX) minX = pts[i] + if (pts[i] > maxX) maxX = pts[i] + if (pts[i + 1] < minY) minY = pts[i + 1] + if (pts[i + 1] > maxY) maxY = pts[i + 1] + i += 2 + } + r.left = minX + r.top = minY + r.right = maxX + r.bottom = maxY + } + + /** + * Computes the inverse of this matrix into [dst]. Returns false (leaving [dst] unmodified) + * if this matrix is not invertible. + */ + fun invert(dst: Matrix): Boolean { + val m = values + val a = m[0]; val b = m[1]; val c = m[2] + val d = m[3]; val e = m[4]; val f = m[5] + val g = m[6]; val h = m[7]; val i = m[8] + + val det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g) + if (abs(det) < 1e-12f) return false + + val invDet = 1f / det + + val r = FloatArray(9) + r[0] = (e * i - f * h) * invDet + r[1] = (c * h - b * i) * invDet + r[2] = (b * f - c * e) * invDet + r[3] = (f * g - d * i) * invDet + r[4] = (a * i - c * g) * invDet + r[5] = (c * d - a * f) * invDet + r[6] = (d * h - e * g) * invDet + r[7] = (b * g - a * h) * invDet + r[8] = (a * e - b * d) * invDet + + r.copyInto(dst.values) + return true + } + + override fun toString(): String = "Matrix" + values.joinToString(prefix = "[", postfix = "]") + + companion object { + const val MSCALE_X = 0 + const val MSKEW_X = 1 + const val MTRANS_X = 2 + const val MSKEW_Y = 3 + const val MSCALE_Y = 4 + const val MTRANS_Y = 5 + const val MPERSP_0 = 6 + const val MPERSP_1 = 7 + const val MPERSP_2 = 8 + + /** + * Standard row-major 3x3 matrix multiplication: `result = left * right`. + */ + private fun multiply(left: FloatArray, right: FloatArray): FloatArray { + val r = FloatArray(9) + for (row in 0..2) { + for (col in 0..2) { + var sum = 0f + for (k in 0..2) { + sum += left[row * 3 + k] * right[k * 3 + col] + } + r[row * 3 + col] = sum + } + } + return r + } + } +} diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/RectF.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/RectF.kt new file mode 100644 index 00000000..2e83b8d9 --- /dev/null +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/RectF.kt @@ -0,0 +1,55 @@ +package info.appdev.charting.utils + +/** + * Platform-independent replacement for `android.graphics.RectF`, covering the subset of the API + * the chart rendering pipeline relies on. + */ +class RectF( + var left: Float = 0f, + var top: Float = 0f, + var right: Float = 0f, + var bottom: Float = 0f +) { + fun width(): Float = right - left + + fun height(): Float = bottom - top + + fun centerX(): Float = (left + right) / 2f + + fun centerY(): Float = (top + bottom) / 2f + + /** + * Also enables the `rect[left, top, right] = bottom` indexed-assignment syntax used by + * `ViewPortHandler`, since `operator fun set` supports both call styles. + */ + operator fun set(left: Float, top: Float, right: Float, bottom: Float) { + this.left = left + this.top = top + this.right = right + this.bottom = bottom + } + + fun set(other: RectF) { + set(other.left, other.top, other.right, other.bottom) + } + + fun setEmpty() { + set(0f, 0f, 0f, 0f) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is RectF) return false + return left == other.left && top == other.top && right == other.right && bottom == other.bottom + } + + override fun hashCode(): Int { + var result = left.hashCode() + result = 31 * result + top.hashCode() + result = 31 * result + right.hashCode() + result = 31 * result + bottom.hashCode() + return result + } + + override fun toString(): String = "RectF($left, $top, $right, $bottom)" +} diff --git a/chartLibCore/src/commonTest/kotlin/info/appdev/charting/utils/MatrixTest.kt b/chartLibCore/src/commonTest/kotlin/info/appdev/charting/utils/MatrixTest.kt new file mode 100644 index 00000000..44d9ca59 --- /dev/null +++ b/chartLibCore/src/commonTest/kotlin/info/appdev/charting/utils/MatrixTest.kt @@ -0,0 +1,156 @@ +package info.appdev.charting.utils + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class MatrixTest { + + private fun assertClose(expected: Float, actual: Float, tolerance: Float = 1e-4f) { + assertTrue(kotlin.math.abs(expected - actual) < tolerance, "expected $expected but was $actual") + } + + @Test + fun reset_isIdentity() { + val m = Matrix() + val pts = floatArrayOf(3f, 4f) + m.mapPoints(pts) + assertClose(3f, pts[0]) + assertClose(4f, pts[1]) + } + + @Test + fun postTranslate_shiftsPoints() { + val m = Matrix() + m.postTranslate(10f, -5f) + val pts = floatArrayOf(1f, 1f) + m.mapPoints(pts) + assertClose(11f, pts[0]) + assertClose(-4f, pts[1]) + } + + @Test + fun postScale_scalesAboutOrigin() { + val m = Matrix() + m.postScale(2f, 3f) + val pts = floatArrayOf(2f, 2f) + m.mapPoints(pts) + assertClose(4f, pts[0]) + assertClose(6f, pts[1]) + } + + @Test + fun postScale_scalesAboutPivot() { + val m = Matrix() + m.postScale(2f, 2f, 10f, 10f) + val pts = floatArrayOf(10f, 10f) + m.mapPoints(pts) + // pivot point should be invariant under a scale about itself + assertClose(10f, pts[0]) + assertClose(10f, pts[1]) + } + + @Test + fun postTranslateThenPostScale_appliesInOrder() { + // translate(+10,0) then scale(2,2): scaling is "post", so it applies to the + // already-translated result -> point (0,0) -> (10,0) -> (20,0) + val m = Matrix() + m.postTranslate(10f, 0f) + m.postScale(2f, 2f) + val pts = floatArrayOf(0f, 0f) + m.mapPoints(pts) + assertClose(20f, pts[0]) + assertClose(0f, pts[1]) + } + + @Test + fun setValuesAndGetValues_roundTrip() { + val m = Matrix() + m.postTranslate(3f, 4f) + val buffer = FloatArray(9) + m.getValues(buffer) + + val m2 = Matrix() + m2.setValues(buffer) + + val pts = floatArrayOf(1f, 1f) + m2.mapPoints(pts) + assertClose(4f, pts[0]) + assertClose(5f, pts[1]) + } + + @Test + fun invert_undoesTransform() { + val m = Matrix() + m.postScale(2f, 4f) + m.postTranslate(3f, -1f) + + val inverse = Matrix() + val success = m.invert(inverse) + assertTrue(success) + + val original = floatArrayOf(5f, 7f) + val pts = original.copyOf() + m.mapPoints(pts) + inverse.mapPoints(pts) + + assertClose(original[0], pts[0]) + assertClose(original[1], pts[1]) + } + + @Test + fun mapRect_computesBoundingBoxAfterTransform() { + val m = Matrix() + m.postScale(2f, 3f) + m.postTranslate(1f, 1f) + + val r = RectF(0f, 0f, 10f, 20f) + m.mapRect(r) + + assertClose(1f, r.left) + assertClose(1f, r.top) + assertClose(21f, r.right) + assertClose(61f, r.bottom) + } + + @Test + fun postConcat_composesInExpectedOrder() { + val a = Matrix() + a.postTranslate(5f, 0f) + + val b = Matrix() + b.postScale(2f, 2f) + + // a.postConcat(b) => new = b * a, i.e. b applied after a + a.postConcat(b) + + val pts = floatArrayOf(0f, 0f) + a.mapPoints(pts) + assertEquals(10f, pts[0]) + assertEquals(0f, pts[1]) + } + + @Test + fun setScale_replacesExistingTransform() { + val m = Matrix() + m.postTranslate(100f, 100f) + m.setScale(2f, 2f) + + val pts = floatArrayOf(3f, 3f) + m.mapPoints(pts) + assertClose(6f, pts[0]) + assertClose(6f, pts[1]) + } + + @Test + fun setTranslate_replacesExistingTransform() { + val m = Matrix() + m.postScale(5f, 5f) + m.setTranslate(2f, 3f) + + val pts = floatArrayOf(1f, 1f) + m.mapPoints(pts) + assertClose(3f, pts[0]) + assertClose(4f, pts[1]) + } +} From 8d56dc90845ca0b384533fc4af1ffa1cb2d0cb73 Mon Sep 17 00:00:00 2001 From: Hannes Achleitner Date: Sun, 27 Sep 2026 08:09:33 +0200 Subject: [PATCH 08/22] Step A.6: migrate ViewPortHandler/Transformer to chartLibCore Moves the viewport geometry engine to commonMain, built on the new common Matrix/RectF value types: - ViewPortHandler moved to chartLibCore as-is, using common Matrix/ RectF; its View? parameter on refresh()/centerViewPort() became a platform-agnostic invalidate callback lambda, and its one Timber log call became a plain println(). - Transformer split into a common TransformerCore base (chartLibCore) holding all matrix/rect math, and a slim Android Transformer subclass (chartLib) that keeps only the dataset-family (generateTransformedValues*) and Path-based (pathValueToPixel(s)) methods that can't move yet. - Added Android-side Matrix/RectF conversion helpers (MatrixAndroid.kt, RectFAndroid.kt) and updated ~20 call sites (Chart/BarChart/ HorizontalBarChart/BarLineChartBase, BarLineChartTouchListener, jobs, axis/bar renderers) to convert at the Canvas-drawing boundary. This unblocks the rest of Step A.3 (IDataSet/BaseDataSet/DataSet/ ChartData family, since IValueFormatter/IFillFormatter no longer need an Android-only ViewPortHandler/LineDataProvider), most of Step A.4 (formatter, highlight), and Step A.5 groundwork. Verified: chartLibCore builds/tests green on Android host, desktop- JVM, and iOS simulator; chartLib:compileDebugKotlin, full ./gradlew test, chartLibCompose:assembleDebug, and app:assembleDebug all pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- KotlinMultiplatform.md | 91 ++++++- .../info/appdev/charting/charts/BarChart.kt | 6 +- .../charting/charts/BarLineChartBase.kt | 29 +-- .../info/appdev/charting/charts/Chart.kt | 3 +- .../charting/charts/HorizontalBarChart.kt | 6 +- .../charting/jobs/AnimatedMoveViewJob.kt | 2 +- .../appdev/charting/jobs/AnimatedZoomJob.kt | 6 +- .../info/appdev/charting/jobs/MoveViewJob.kt | 2 +- .../info/appdev/charting/jobs/ZoomJob.kt | 6 +- .../listener/BarLineChartTouchListener.kt | 6 +- .../charting/renderer/BarChartRenderer.kt | 10 +- .../renderer/HorizontalBarChartRenderer.kt | 10 +- .../renderer/RoundedBarChartRenderer.kt | 6 +- .../RoundedHorizontalBarChartRenderer.kt | 6 +- .../appdev/charting/renderer/XAxisRenderer.kt | 5 +- .../XAxisRendererHorizontalBarChart.kt | 5 +- .../appdev/charting/renderer/YAxisRenderer.kt | 9 +- .../YAxisRendererHorizontalBarChart.kt | 7 +- .../appdev/charting/utils/MatrixAndroid.kt | 24 ++ .../appdev/charting/utils/RectFAndroid.kt | 26 ++ .../info/appdev/charting/utils/Transformer.kt | 227 +----------------- .../appdev/charting/utils/TransformerCore.kt | 223 +++++++++++++++++ .../appdev/charting/utils/ViewPortHandler.kt | 23 +- 23 files changed, 462 insertions(+), 276 deletions(-) create mode 100644 chartLib/src/main/kotlin/info/appdev/charting/utils/MatrixAndroid.kt create mode 100644 chartLib/src/main/kotlin/info/appdev/charting/utils/RectFAndroid.kt create mode 100644 chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/TransformerCore.kt rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/utils/ViewPortHandler.kt (96%) diff --git a/KotlinMultiplatform.md b/KotlinMultiplatform.md index df865083..65aa9fe6 100644 --- a/KotlinMultiplatform.md +++ b/KotlinMultiplatform.md @@ -300,9 +300,96 @@ lint Existing custom lint rules (unchanged) - Verified: `chartLibCore` build/tests, `chartLib:compileDebugKotlin` (zero source changes needed), full `./gradlew test`, `chartLibCompose:assembleDebug`, `app:assembleDebug` all pass. +- [x] **Step A.6 — Migrate `utils/` geometry & viewport math + (`Matrix`/`RectF` value types, `ViewPortHandler`, `Transformer`).** + - **Common `Matrix`/`RectF` foundation** (landed first, verified in isolation): + `chartLibCore/utils/Matrix.kt` — a full custom 3×3 affine/perspective matrix + implementation, row-major and index-compatible with `android.graphics.Matrix` + (`MSCALE_X=0` … `MPERSP_2=8`), with `reset`, `set`, `getValues`/`setValues`, + `setTranslate`/`setScale` (with pivot), `postTranslate`/`postScale` (with + pivot), `postConcat`, `mapPoints`, `mapRect`, and `invert` (cofactor/adjugate, + returns `false` below a `1e-12f` determinant threshold instead of throwing). + `chartLibCore/utils/RectF.kt` — a common rectangle type with `width`/`height`/ + `centerX`/`centerY`, `set(...)` overloads (including an `operator fun set` + supporting the `rect[l,t,r] = b` indexed-assignment idiom `ViewPortHandler` + relies on), and custom `equals`/`hashCode`/`toString`. 11 unit tests in + `MatrixTest.kt` cover identity/reset, translate/scale (origin and pivot), + composition order, `getValues`/`setValues` round-trips, `invert` correctness, + and `mapRect` bounding-box computation — all pass on JVM (Android host), + desktop, and iOS simulator. + - **`ViewPortHandler` moved to `chartLibCore` commonMain**, rebuilt on the + common `Matrix`/`RectF` instead of `android.graphics.Matrix`/`RectF`. Its + only two Android touches were removed: the `android.view.View?` parameter on + `refresh(...)`/`centerViewPort(...)` became a platform-agnostic + `invalidate: (() -> Unit)?` callback lambda, and the single `Timber.i(...)` + debug log became a plain `println(...)` (this is a rarely-hit path gated + behind a `logging: Boolean = false` default, not worth a full logging + facade for one call site). + - **`Transformer` split into a common base + Android subclass**, since its + `generateTransformedValues{Line,Bubble,Candle,Scatter}` methods take + `ILineDataSet`/`IBubbleDataSet`/`ICandleDataSet`/`IScatterDataSet` params + (still Android-only, blocked per the Step A.3 finding) and its + `pathValueToPixel`/`pathValuesToPixel` methods use `android.graphics.Path` + (no multiplatform equivalent yet): + - `chartLibCore/utils/TransformerCore.kt` (new, common, open class) — holds + every matrix/rect operation that doesn't depend on the dataset family or + `Path`: `prepareMatrixValuePx`, `prepareMatrixOffset` (open, overridden by + `TransformerHorizontalBarChart`), `pointValuesToPixel`, `rectValueToPixel` + (+ phase/horizontal variants), `pixelsToValue`, `getValuesByTouchPoint`, + `getPixelForValues`, `valueToPixelMatrix`/`pixelToValueMatrix`. The + `FloatArray?`/`RectF?` nullable parameter types from the original + Android-`Matrix`-flavored signatures were tightened to non-null (the + common `Matrix.mapPoints`/`mapRect` API is non-null, and a repo-wide + check confirmed no caller ever passes `null` here). + - `chartLib/utils/Transformer.kt` (rewritten) — now `open class Transformer + : TransformerCore(viewPortHandler)`, keeping only the + `generateTransformedValues*` and `pathValueToPixel`/`pathValuesToPixel` + methods. The two `Path` methods convert the common `Matrix` to + `android.graphics.Matrix` via a new `toAndroidMatrix()` extension before + calling `Path.transform(...)`, since `Path` itself can't move. + - `TransformerHorizontalBarChart` needed no changes — its override of + `prepareMatrixOffset` only touches matrices/`ViewPortHandler` accessors, + all already common. + - **New Android-side conversion helpers** (`chartLib/utils/MatrixAndroid.kt`, + `RectFAndroid.kt`) bridge the common `Matrix`/`RectF` back to + `android.graphics.Matrix`/`RectF` at the handful of places that must still + call native Canvas/Path APIs directly: `toAndroidMatrix()`/`toCommonMatrix()`, + `toAndroidRectF()`/`toCommonRectF()`, and `copyInto(target)` overloads in + both directions (for buffer-style rects that get mutated in place, e.g. + `mGridClippingRect`, `mBarShadowRectBuffer`, `barRect`). + - **~20 files updated at the Canvas-drawing boundary** to route through these + converters — the actual blast radius anticipated in the Step A.6 planning + note: `Chart.kt` (public `contentRect: RectF` property now converts once at + the getter), `BarChart.kt`/`HorizontalBarChart.kt` (`getBarBounds` converts + the caller-supplied `outputRect` round-trip), `BarLineChartBase.kt` (all + `canvas.clipRect`/`c.drawRect(contentRect, ...)` calls, all `viewPortHandler + .refresh(...)`/`.centerViewPort(...)` calls swapped from `this`/`View` to + `{ invalidate() }`), `BarLineChartTouchListener.kt` (switched its `Matrix` + import to the common type; `refresh(...)` calls now wrap `chart` in + `{ chart.invalidate() }`), the 4 zoom/pan `jobs/*.kt` files (same `View` → + lambda swap, common `Matrix` import), and 8 renderer files + (`XAxisRenderer(HorizontalBarChart)`, `YAxisRenderer(HorizontalBarChart)`, + `BarChartRenderer`, `HorizontalBarChartRenderer`, + `Rounded(Horizontal)BarChartRenderer`) whose clipping-rect buffers now + `copyInto`/`toCommonRectF()` at the `viewPortHandler.contentRect`/ + `rectValueToPixel(...)` boundary instead of relying on same-type `.set(...)`. + - **Not touched / explicitly out of scope for this slice:** `RadarChart.kt`, + `PieRadarChartBase.kt` read `viewPortHandler.contentRect.width()`/`.height()`/ + `.left` etc. only — already source-compatible with the common `RectF`, no + changes needed. `CombinedChart.kt`'s `contentRect` overrides delegate to the + (already-converting) `Chart.contentRect` property, also untouched. + - Verified: `chartLibCore` builds/tests green (Android host, desktop-JVM, + iOS simulator — `Matrix`/`RectF`/`ViewPortHandler`/`TransformerCore` all + compile with zero platform-specific code), `chartLib:compileDebugKotlin`, + full `./gradlew test`, `chartLibCompose:assembleDebug`, and + `app:assembleDebug` all pass. + - **Now unblocked:** the rest of Step A.3 (`IDataSet`/`BaseDataSet`/ + `DataSet`/`ChartData`/concrete `*DataSet` subclasses, since + `IValueFormatter`/`IFillFormatter` no longer need an Android-only + `ViewPortHandler`/`LineDataProvider`), most of Step A.4 (`formatter/` + package, most of `highlight/`), and Step A.5 groundwork. - [ ] **Step A.5 — Migrate `components/` (axes, legend, limit lines).** -- [ ] **Step A.6 — Migrate `utils/` geometry & viewport math - (`ViewPortHandler`, `Transformer`, `PointF`).** + - [ ] **Step A.7 — New `chartLibComposeMultiplatform` module**: Compose Multiplatform renderers built on `chartLibCore` using `DrawScope`. - [ ] **Step A.8 — Gesture handling** with `pointerInput`/`detectTransformGestures` diff --git a/chartLib/src/main/kotlin/info/appdev/charting/charts/BarChart.kt b/chartLib/src/main/kotlin/info/appdev/charting/charts/BarChart.kt index 61757175..22fbb327 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/charts/BarChart.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/charts/BarChart.kt @@ -10,6 +10,8 @@ import info.appdev.charting.highlight.BarHighlighter import info.appdev.charting.highlight.Highlight import info.appdev.charting.interfaces.dataprovider.BarDataProvider import info.appdev.charting.renderer.BarChartRenderer +import info.appdev.charting.utils.copyInto +import info.appdev.charting.utils.toCommonRectF import timber.log.Timber import java.util.Locale @@ -178,7 +180,9 @@ open class BarChart : BarLineChartBase, BarDataProvider { outputRect.set(left, top, right, bottom) - getTransformer(set.axisDependency).rectValueToPixel(outputRect) + val commonRect = outputRect.toCommonRectF() + getTransformer(set.axisDependency).rectValueToPixel(commonRect) + commonRect.copyInto(outputRect) } } diff --git a/chartLib/src/main/kotlin/info/appdev/charting/charts/BarLineChartBase.kt b/chartLib/src/main/kotlin/info/appdev/charting/charts/BarLineChartBase.kt index f29cf15f..cd41e74f 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/charts/BarLineChartBase.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/charts/BarLineChartBase.kt @@ -4,7 +4,6 @@ import android.annotation.SuppressLint import android.content.Context import android.graphics.Canvas import android.graphics.Color -import android.graphics.Matrix import android.graphics.Paint import android.graphics.RectF import android.util.AttributeSet @@ -37,6 +36,8 @@ import info.appdev.charting.utils.PointF.Companion.getInstance import info.appdev.charting.utils.PointF.Companion.recycleInstance import info.appdev.charting.utils.Transformer import info.appdev.charting.utils.convertDpToPixel +import info.appdev.charting.utils.toAndroidRectF +import info.appdev.charting.utils.Matrix import timber.log.Timber import kotlin.math.abs import kotlin.math.max @@ -242,7 +243,7 @@ abstract class BarLineChartBase>> : ViewGroup, I * (into which the actual values are drawn). */ override val contentRect: RectF - get() = viewPortHandler.contentRect + get() = viewPortHandler.contentRect.toAndroidRectF() /** * disables intercept touch events diff --git a/chartLib/src/main/kotlin/info/appdev/charting/charts/HorizontalBarChart.kt b/chartLib/src/main/kotlin/info/appdev/charting/charts/HorizontalBarChart.kt index 33506ae2..62b4bf50 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/charts/HorizontalBarChart.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/charts/HorizontalBarChart.kt @@ -20,6 +20,8 @@ import info.appdev.charting.utils.PointF import info.appdev.charting.utils.PointF.Companion.getInstance import info.appdev.charting.utils.TransformerHorizontalBarChart import info.appdev.charting.utils.convertDpToPixel +import info.appdev.charting.utils.copyInto +import info.appdev.charting.utils.toCommonRectF import timber.log.Timber import kotlin.math.max import kotlin.math.min @@ -222,7 +224,9 @@ open class HorizontalBarChart : BarChart { outputRect.set(left, top, right, bottom) - getTransformer(set!!.axisDependency).rectValueToPixel(outputRect) + val commonRect = outputRect.toCommonRectF() + getTransformer(set!!.axisDependency).rectValueToPixel(commonRect) + commonRect.copyInto(outputRect) } } diff --git a/chartLib/src/main/kotlin/info/appdev/charting/jobs/AnimatedMoveViewJob.kt b/chartLib/src/main/kotlin/info/appdev/charting/jobs/AnimatedMoveViewJob.kt index 38b00423..070034d5 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/jobs/AnimatedMoveViewJob.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/jobs/AnimatedMoveViewJob.kt @@ -23,7 +23,7 @@ class AnimatedMoveViewJob( pts[1] = yOrigin + (yValue - yOrigin) * phase mTrans?.pointValuesToPixel(pts) - mViewPortHandler.centerViewPort(pts, view) + mViewPortHandler.centerViewPort(pts) { view?.invalidate() } } override fun recycleSelf() { diff --git a/chartLib/src/main/kotlin/info/appdev/charting/jobs/AnimatedZoomJob.kt b/chartLib/src/main/kotlin/info/appdev/charting/jobs/AnimatedZoomJob.kt index 946532f3..1b94c6d9 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/jobs/AnimatedZoomJob.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/jobs/AnimatedZoomJob.kt @@ -3,7 +3,7 @@ package info.appdev.charting.jobs import android.animation.Animator import android.animation.ValueAnimator import android.annotation.SuppressLint -import android.graphics.Matrix +import info.appdev.charting.utils.Matrix import android.view.View import info.appdev.charting.charts.BarLineChartBase import info.appdev.charting.components.YAxis @@ -46,7 +46,7 @@ open class AnimatedZoomJob @SuppressLint("NewApi") constructor( val save = mOnAnimationUpdateMatrixBuffer mViewPortHandler.setZoom(scaleX, scaleY, save) - mViewPortHandler.refresh(save, view, false) + mViewPortHandler.refresh(save, { view?.invalidate() }, false) val valsInView = yAxis.axisRange / mViewPortHandler.scaleY val xsInView = xAxisRange / mViewPortHandler.scaleX @@ -57,7 +57,7 @@ open class AnimatedZoomJob @SuppressLint("NewApi") constructor( mTrans?.pointValuesToPixel(pts) mViewPortHandler.translate(pts, save) - mViewPortHandler.refresh(save, view, true) + mViewPortHandler.refresh(save, { view?.invalidate() }, true) } override fun onAnimationEnd(animation: Animator) { diff --git a/chartLib/src/main/kotlin/info/appdev/charting/jobs/MoveViewJob.kt b/chartLib/src/main/kotlin/info/appdev/charting/jobs/MoveViewJob.kt index df1ca7ea..a093e7c1 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/jobs/MoveViewJob.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/jobs/MoveViewJob.kt @@ -12,7 +12,7 @@ open class MoveViewJob(viewPortHandler: ViewPortHandler, xValue: Float, yValue: pts[1] = yValue mTrans?.pointValuesToPixel(pts) - mViewPortHandler.centerViewPort(pts, view) + mViewPortHandler.centerViewPort(pts) { view?.invalidate() } recycleInstance(this) } diff --git a/chartLib/src/main/kotlin/info/appdev/charting/jobs/ZoomJob.kt b/chartLib/src/main/kotlin/info/appdev/charting/jobs/ZoomJob.kt index 8714ecae..9eea9049 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/jobs/ZoomJob.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/jobs/ZoomJob.kt @@ -1,6 +1,6 @@ package info.appdev.charting.jobs -import android.graphics.Matrix +import info.appdev.charting.utils.Matrix import android.view.View import info.appdev.charting.charts.BarLineChartBase import info.appdev.charting.components.YAxis.AxisDependency @@ -17,7 +17,7 @@ open class ZoomJob( override fun run() { val save = mRunMatrixBuffer mViewPortHandler.zoom(scaleX, scaleY, save) - mViewPortHandler.refresh(save, view, false) + mViewPortHandler.refresh(save, { view?.invalidate() }, false) (view as? BarLineChartBase<*>)?.let { view -> val yValsInView = view.getAxis(axisDependency).axisRange / mViewPortHandler.scaleY @@ -29,7 +29,7 @@ open class ZoomJob( mTrans?.pointValuesToPixel(pts) mViewPortHandler.translate(pts, save) - mViewPortHandler.refresh(save, view, false) + mViewPortHandler.refresh(save, { view.invalidate() }, false) view.calculateOffsets() view.postInvalidate() diff --git a/chartLib/src/main/kotlin/info/appdev/charting/listener/BarLineChartTouchListener.kt b/chartLib/src/main/kotlin/info/appdev/charting/listener/BarLineChartTouchListener.kt index faaaeca8..31a010f9 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/listener/BarLineChartTouchListener.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/listener/BarLineChartTouchListener.kt @@ -1,7 +1,6 @@ package info.appdev.charting.listener import android.annotation.SuppressLint -import android.graphics.Matrix import android.view.MotionEvent import android.view.VelocityTracker import android.view.View @@ -15,6 +14,7 @@ import info.appdev.charting.interfaces.datasets.IDataSet import info.appdev.charting.utils.PointF import info.appdev.charting.utils.Utils import info.appdev.charting.utils.convertDpToPixel +import info.appdev.charting.utils.Matrix import timber.log.Timber import kotlin.math.abs import kotlin.math.sqrt @@ -259,7 +259,7 @@ class BarLineChartTouchListener( } // perform the transformation, update the chart - matrix = chart.viewPortHandler.refresh(matrix, chart, true) + matrix = chart.viewPortHandler.refresh(matrix, { chart.invalidate() }, true) return true // indicate event was handled } @@ -582,7 +582,7 @@ class BarLineChartTouchListener( performDrag(event, dragDistanceX, dragDistanceY) event.recycle() - matrix = chart.viewPortHandler.refresh(matrix, chart, false) + matrix = chart.viewPortHandler.refresh(matrix, { chart.invalidate() }, false) decelerationLastTime = currentTime diff --git a/chartLib/src/main/kotlin/info/appdev/charting/renderer/BarChartRenderer.kt b/chartLib/src/main/kotlin/info/appdev/charting/renderer/BarChartRenderer.kt index 1eab9121..0d26f25a 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/renderer/BarChartRenderer.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/renderer/BarChartRenderer.kt @@ -15,7 +15,9 @@ import info.appdev.charting.utils.Transformer import info.appdev.charting.utils.ViewPortHandler import info.appdev.charting.utils.calcTextHeight import info.appdev.charting.utils.convertDpToPixel +import info.appdev.charting.utils.copyInto import info.appdev.charting.utils.drawImage +import info.appdev.charting.utils.toCommonRectF import kotlin.math.ceil import kotlin.math.min @@ -133,7 +135,9 @@ open class BarChartRenderer( barShadowRectBuffer.left = x - barWidthHalf barShadowRectBuffer.right = x + barWidthHalf - trans!!.rectValueToPixel(barShadowRectBuffer) + val commonShadowRect = barShadowRectBuffer.toCommonRectF() + trans!!.rectValueToPixel(commonShadowRect) + commonShadowRect.copyInto(barShadowRectBuffer) } if (!viewPortHandler.isInBoundsLeft(barShadowRectBuffer.right)) { i++ @@ -245,7 +249,9 @@ open class BarChartRenderer( barRect[left, y1, right] = y2 - trans.rectToPixelPhase(barRect, animator.phaseY) + val commonBarRect = barRect.toCommonRectF() + trans.rectToPixelPhase(commonBarRect, animator.phaseY) + commonBarRect.copyInto(barRect) } override fun drawValues(canvas: Canvas) { diff --git a/chartLib/src/main/kotlin/info/appdev/charting/renderer/HorizontalBarChartRenderer.kt b/chartLib/src/main/kotlin/info/appdev/charting/renderer/HorizontalBarChartRenderer.kt index 870796ff..7b5bc7de 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/renderer/HorizontalBarChartRenderer.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/renderer/HorizontalBarChartRenderer.kt @@ -16,7 +16,9 @@ import info.appdev.charting.utils.ViewPortHandler import info.appdev.charting.utils.calcTextHeight import info.appdev.charting.utils.calcTextWidth import info.appdev.charting.utils.convertDpToPixel +import info.appdev.charting.utils.copyInto import info.appdev.charting.utils.drawImage +import info.appdev.charting.utils.toCommonRectF import kotlin.math.ceil import kotlin.math.min @@ -78,7 +80,9 @@ open class HorizontalBarChartRenderer( mBarShadowRectBuffer.top = x - barWidthHalf mBarShadowRectBuffer.bottom = x + barWidthHalf - trans!!.rectValueToPixel(mBarShadowRectBuffer) + val commonShadowRect = mBarShadowRectBuffer.toCommonRectF() + trans!!.rectValueToPixel(commonShadowRect) + commonShadowRect.copyInto(mBarShadowRectBuffer) if (!viewPortHandler.isInBoundsTop(mBarShadowRectBuffer.bottom)) { i++ @@ -455,7 +459,9 @@ open class HorizontalBarChartRenderer( barRect[y1, top, y2] = bottom - trans.rectToPixelPhaseHorizontal(barRect, animator.phaseY) + val commonBarRect = barRect.toCommonRectF() + trans.rectToPixelPhaseHorizontal(commonBarRect, animator.phaseY) + commonBarRect.copyInto(barRect) } override fun setHighlightDrawPos(high: Highlight, bar: RectF) { diff --git a/chartLib/src/main/kotlin/info/appdev/charting/renderer/RoundedBarChartRenderer.kt b/chartLib/src/main/kotlin/info/appdev/charting/renderer/RoundedBarChartRenderer.kt index 8e8e2851..0b2fe5c6 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/renderer/RoundedBarChartRenderer.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/renderer/RoundedBarChartRenderer.kt @@ -11,6 +11,8 @@ import info.appdev.charting.interfaces.dataprovider.BarDataProvider import info.appdev.charting.interfaces.datasets.IBarDataSet import info.appdev.charting.utils.ViewPortHandler import info.appdev.charting.utils.convertDpToPixel +import info.appdev.charting.utils.copyInto +import info.appdev.charting.utils.toCommonRectF import kotlin.math.min class RoundedBarChartRenderer( @@ -47,7 +49,9 @@ class RoundedBarChartRenderer( mBarShadowRectBuffer.left = x - barWidthHalf mBarShadowRectBuffer.right = x + barWidthHalf } - trans!!.rectValueToPixel(mBarShadowRectBuffer) + val commonShadowRect = mBarShadowRectBuffer.toCommonRectF() + trans!!.rectValueToPixel(commonShadowRect) + commonShadowRect.copyInto(mBarShadowRectBuffer) if (!viewPortHandler.isInBoundsLeft(mBarShadowRectBuffer.right)) { i++ continue diff --git a/chartLib/src/main/kotlin/info/appdev/charting/renderer/RoundedHorizontalBarChartRenderer.kt b/chartLib/src/main/kotlin/info/appdev/charting/renderer/RoundedHorizontalBarChartRenderer.kt index 5c6d2cac..d1103946 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/renderer/RoundedHorizontalBarChartRenderer.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/renderer/RoundedHorizontalBarChartRenderer.kt @@ -9,6 +9,8 @@ import info.appdev.charting.interfaces.dataprovider.BarDataProvider import info.appdev.charting.interfaces.datasets.IBarDataSet import info.appdev.charting.utils.ViewPortHandler import info.appdev.charting.utils.convertDpToPixel +import info.appdev.charting.utils.copyInto +import info.appdev.charting.utils.toCommonRectF import kotlin.math.min class RoundedHorizontalBarChartRenderer( @@ -48,7 +50,9 @@ class RoundedHorizontalBarChartRenderer( mBarShadowRectBuffer.top = x - barWidthHalf mBarShadowRectBuffer.bottom = x + barWidthHalf } - trans!!.rectValueToPixel(mBarShadowRectBuffer) + val commonShadowRect = mBarShadowRectBuffer.toCommonRectF() + trans!!.rectValueToPixel(commonShadowRect) + commonShadowRect.copyInto(mBarShadowRectBuffer) if (!viewPortHandler.isInBoundsTop(mBarShadowRectBuffer.bottom)) { i++ continue diff --git a/chartLib/src/main/kotlin/info/appdev/charting/renderer/XAxisRenderer.kt b/chartLib/src/main/kotlin/info/appdev/charting/renderer/XAxisRenderer.kt index dd93481c..a68e0ba1 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/renderer/XAxisRenderer.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/renderer/XAxisRenderer.kt @@ -22,6 +22,7 @@ import info.appdev.charting.utils.calcTextHeight import info.appdev.charting.utils.calcTextSize import info.appdev.charting.utils.calcTextWidth import info.appdev.charting.utils.convertDpToPixel +import info.appdev.charting.utils.copyInto import info.appdev.charting.utils.drawXAxisValue import kotlin.math.roundToInt @@ -285,7 +286,7 @@ open class XAxisRenderer( open val gridClippingRect: RectF? get() { - mGridClippingRect.set(viewPortHandler.contentRect) + viewPortHandler.contentRect.copyInto(mGridClippingRect) mGridClippingRect.inset(-axis.gridLineWidth, 0f) return mGridClippingRect } @@ -357,7 +358,7 @@ open class XAxisRenderer( limitLines.forEach { limitLine -> if (limitLine.isEnabled) { canvas.withSave { - mLimitLineClippingRect.set(viewPortHandler.contentRect) + viewPortHandler.contentRect.copyInto(mLimitLineClippingRect) mLimitLineClippingRect.inset(-limitLine.lineWidth, 0f) canvas.clipRect(mLimitLineClippingRect) diff --git a/chartLib/src/main/kotlin/info/appdev/charting/renderer/XAxisRendererHorizontalBarChart.kt b/chartLib/src/main/kotlin/info/appdev/charting/renderer/XAxisRendererHorizontalBarChart.kt index 02edf366..911a67f9 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/renderer/XAxisRendererHorizontalBarChart.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/renderer/XAxisRendererHorizontalBarChart.kt @@ -18,6 +18,7 @@ import info.appdev.charting.utils.ViewPortHandler import info.appdev.charting.utils.calcTextHeight import info.appdev.charting.utils.calcTextSize import info.appdev.charting.utils.convertDpToPixel +import info.appdev.charting.utils.copyInto import kotlin.math.roundToInt @Suppress("MemberVisibilityCanBePrivate") @@ -162,7 +163,7 @@ open class XAxisRendererHorizontalBarChart( override val gridClippingRect: RectF get() { - mGridClippingRect.set(viewPortHandler.contentRect) + viewPortHandler.contentRect.copyInto(mGridClippingRect) mGridClippingRect.inset(0f, -axis.gridLineWidth) return mGridClippingRect } @@ -226,7 +227,7 @@ open class XAxisRendererHorizontalBarChart( if (!limitLine.isEnabled) continue canvas.withSave { - mLimitLineClippingRect.set(viewPortHandler.contentRect) + viewPortHandler.contentRect.copyInto(mLimitLineClippingRect) mLimitLineClippingRect.inset(0f, -limitLine.lineWidth) canvas.clipRect(mLimitLineClippingRect) diff --git a/chartLib/src/main/kotlin/info/appdev/charting/renderer/YAxisRenderer.kt b/chartLib/src/main/kotlin/info/appdev/charting/renderer/YAxisRenderer.kt index 5ab918c4..84a3c0a2 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/renderer/YAxisRenderer.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/renderer/YAxisRenderer.kt @@ -16,6 +16,7 @@ import info.appdev.charting.utils.Transformer import info.appdev.charting.utils.ViewPortHandler import info.appdev.charting.utils.calcTextHeight import info.appdev.charting.utils.convertDpToPixel +import info.appdev.charting.utils.copyInto open class YAxisRenderer( viewPortHandler: ViewPortHandler, @@ -172,7 +173,7 @@ open class YAxisRenderer( open val gridClippingRect: RectF? get() { - mGridClippingRect.set(viewPortHandler.contentRect) + viewPortHandler.contentRect.copyInto(mGridClippingRect) mGridClippingRect.inset(0f, -axis.gridLineWidth) return mGridClippingRect } @@ -229,7 +230,7 @@ open class YAxisRenderer( */ protected open fun drawZeroLine(canvas: Canvas) { canvas.withSave { - zeroLineClippingRect.set(viewPortHandler.contentRect) + viewPortHandler.contentRect.copyInto(zeroLineClippingRect) zeroLineClippingRect.inset(0f, -yAxis.zeroLineWidth) canvas.clipRect(zeroLineClippingRect) @@ -284,7 +285,7 @@ open class YAxisRenderer( continue canvas.withSave { - limitLineClippingRect.set(viewPortHandler.contentRect) + viewPortHandler.contentRect.copyInto(limitLineClippingRect) limitLineClippingRect.inset(0f, -limitLine.lineWidth) canvas.clipRect(limitLineClippingRect) @@ -385,7 +386,7 @@ open class YAxisRenderer( continue canvas.withSave { - limitLineClippingRect.set(viewPortHandler.contentRect) + viewPortHandler.contentRect.copyInto(limitLineClippingRect) limitLineClippingRect.inset(0f, -limitRange.lineWidth) canvas.clipRect(limitLineClippingRect) diff --git a/chartLib/src/main/kotlin/info/appdev/charting/renderer/YAxisRendererHorizontalBarChart.kt b/chartLib/src/main/kotlin/info/appdev/charting/renderer/YAxisRendererHorizontalBarChart.kt index 3ecaa402..1766bd96 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/renderer/YAxisRendererHorizontalBarChart.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/renderer/YAxisRendererHorizontalBarChart.kt @@ -15,6 +15,7 @@ import info.appdev.charting.utils.Transformer import info.appdev.charting.utils.ViewPortHandler import info.appdev.charting.utils.calcTextHeight import info.appdev.charting.utils.convertDpToPixel +import info.appdev.charting.utils.copyInto @Suppress("MemberVisibilityCanBePrivate") open class YAxisRendererHorizontalBarChart( @@ -174,7 +175,7 @@ open class YAxisRendererHorizontalBarChart( override val gridClippingRect: RectF get() { - mGridClippingRect.set(viewPortHandler.contentRect) + viewPortHandler.contentRect.copyInto(mGridClippingRect) mGridClippingRect.inset(-axis.gridLineWidth, 0f) return mGridClippingRect } @@ -188,7 +189,7 @@ open class YAxisRendererHorizontalBarChart( override fun drawZeroLine(canvas: Canvas) { canvas.withSave { - zeroLineClippingRect.set(viewPortHandler.contentRect) + viewPortHandler.contentRect.copyInto(zeroLineClippingRect) zeroLineClippingRect.inset(-yAxis.zeroLineWidth, 0f) canvas.clipRect(limitLineClippingRect) @@ -243,7 +244,7 @@ open class YAxisRendererHorizontalBarChart( if (!limitLine.isEnabled) continue canvas.withSave { - limitLineClippingRect.set(viewPortHandler.contentRect) + viewPortHandler.contentRect.copyInto(limitLineClippingRect) limitLineClippingRect.inset(-limitLine.lineWidth, 0f) canvas.clipRect(limitLineClippingRect) diff --git a/chartLib/src/main/kotlin/info/appdev/charting/utils/MatrixAndroid.kt b/chartLib/src/main/kotlin/info/appdev/charting/utils/MatrixAndroid.kt new file mode 100644 index 00000000..e73757c8 --- /dev/null +++ b/chartLib/src/main/kotlin/info/appdev/charting/utils/MatrixAndroid.kt @@ -0,0 +1,24 @@ +package info.appdev.charting.utils + +/** + * Converts the platform-independent [Matrix] to an `android.graphics.Matrix`, for the few call + * sites that must hand a matrix to native Android APIs (e.g. `Path.transform(Matrix)`). + */ +fun Matrix.toAndroidMatrix(): android.graphics.Matrix { + val values = FloatArray(9) + getValues(values) + val androidMatrix = android.graphics.Matrix() + androidMatrix.setValues(values) + return androidMatrix +} + +/** + * Converts an `android.graphics.Matrix` to the platform-independent [Matrix]. + */ +fun android.graphics.Matrix.toCommonMatrix(): Matrix { + val values = FloatArray(9) + getValues(values) + val commonMatrix = Matrix() + commonMatrix.setValues(values) + return commonMatrix +} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/utils/RectFAndroid.kt b/chartLib/src/main/kotlin/info/appdev/charting/utils/RectFAndroid.kt new file mode 100644 index 00000000..790c88d3 --- /dev/null +++ b/chartLib/src/main/kotlin/info/appdev/charting/utils/RectFAndroid.kt @@ -0,0 +1,26 @@ +package info.appdev.charting.utils + +/** + * Converts the platform-independent [RectF] to an `android.graphics.RectF`, for the call sites + * that must hand a rect to native Android Canvas/Path APIs. + */ +fun RectF.toAndroidRectF(): android.graphics.RectF = android.graphics.RectF(left, top, right, bottom) + +/** + * Copies the values of this platform-independent [RectF] into an existing `android.graphics.RectF`. + */ +fun RectF.copyInto(target: android.graphics.RectF) { + target.set(left, top, right, bottom) +} + +/** + * Converts an `android.graphics.RectF` to the platform-independent [RectF]. + */ +fun android.graphics.RectF.toCommonRectF(): RectF = RectF(left, top, right, bottom) + +/** + * Copies the values of this `android.graphics.RectF` into an existing platform-independent [RectF]. + */ +fun android.graphics.RectF.copyInto(target: RectF) { + target.set(left, top, right, bottom) +} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/utils/Transformer.kt b/chartLib/src/main/kotlin/info/appdev/charting/utils/Transformer.kt index 9d5831ed..add59f1a 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/utils/Transformer.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/utils/Transformer.kt @@ -1,72 +1,22 @@ package info.appdev.charting.utils import android.annotation.SuppressLint -import android.graphics.Matrix import android.graphics.Path -import android.graphics.RectF import info.appdev.charting.data.EntryFloat import info.appdev.charting.interfaces.datasets.IBubbleDataSet import info.appdev.charting.interfaces.datasets.ICandleDataSet import info.appdev.charting.interfaces.datasets.ILineDataSet import info.appdev.charting.interfaces.datasets.IScatterDataSet -import info.appdev.charting.utils.PointD.Companion.getInstance /** * Transformer class that contains all matrices and is responsible for * transforming values into pixels on the screen and backwards. + * + * The platform-agnostic matrix/rect logic lives in the common [TransformerCore] base class + * (`chartLibCore`). This subclass adds the pieces that still depend on Android-only types + * (`android.graphics.Path`) or on the dataset family (not yet available in `commonMain`). */ -open class Transformer(protected var viewPortHandler: ViewPortHandler) { - /** - * matrix to map the values to the screen pixels - */ - var valueMatrix: Matrix = Matrix() - protected set - - /** - * matrix for handling the different offsets of the chart - */ - var offsetMatrix: Matrix = Matrix() - protected set - - /** - * Prepares the matrix that transforms values to pixels. Calculates the - * scale factors from the charts size and offsets. - */ - fun prepareMatrixValuePx(xChartMin: Float, deltaX: Float, deltaY: Float, yChartMin: Float) { - var scaleX = viewPortHandler.contentWidth() / deltaX - var scaleY = viewPortHandler.contentHeight() / deltaY - - if (scaleX.isInfinite()) { - scaleX = 0f - } - if (scaleY.isInfinite()) { - scaleY = 0f - } - - // setup all matrices - valueMatrix.reset() - valueMatrix.postTranslate(-xChartMin, -yChartMin) - valueMatrix.postScale(scaleX, -scaleY) - } - - /** - * Prepares the matrix that contains all offsets. - */ - open fun prepareMatrixOffset(inverted: Boolean) { - offsetMatrix.reset() - - // offset.postTranslate(mOffsetLeft, getHeight() - mOffsetBottom); - if (!inverted) offsetMatrix.postTranslate( - viewPortHandler.offsetLeft(), - viewPortHandler.chartHeight - viewPortHandler.offsetBottom() - ) - else { - offsetMatrix - .setTranslate(viewPortHandler.offsetLeft(), -viewPortHandler.offsetTop()) - offsetMatrix.postScale(1.0f, -1.0f) - } - } - +open class Transformer(viewPortHandler: ViewPortHandler) : TransformerCore(viewPortHandler) { protected var valuePointsForGenerateTransformedValuesScatter: FloatArray = FloatArray(1) /** @@ -215,9 +165,9 @@ open class Transformer(protected var viewPortHandler: ViewPortHandler) { * to value-touch-offset */ fun pathValueToPixel(path: Path) { - path.transform(this.valueMatrix) - path.transform(viewPortHandler.matrixTouch) - path.transform(this.offsetMatrix) + path.transform(this.valueMatrix.toAndroidMatrix()) + path.transform(viewPortHandler.matrixTouch.toAndroidMatrix()) + path.transform(this.offsetMatrix.toAndroidMatrix()) } /** @@ -225,166 +175,7 @@ open class Transformer(protected var viewPortHandler: ViewPortHandler) { */ fun pathValuesToPixel(paths: MutableList) { for (i in paths.indices) { - pathValueToPixel(paths.get(i)!!) + pathValueToPixel(paths[i]!!) } } - - /** - * Transform an array of points with all matrices. VERY IMPORTANT: Keep - * matrix order "value-touch-offset" when transforming. - */ - fun pointValuesToPixel(pts: FloatArray?) { - valueMatrix.mapPoints(pts) - viewPortHandler.matrixTouch.mapPoints(pts) - offsetMatrix.mapPoints(pts) - } - - /** - * Transform a rectangle with all matrices. - */ - fun rectValueToPixel(r: RectF?) { - valueMatrix.mapRect(r) - viewPortHandler.matrixTouch.mapRect(r) - offsetMatrix.mapRect(r) - } - - /** - * Transform a rectangle with all matrices with potential animation phases. - */ - fun rectToPixelPhase(r: RectF, phaseY: Float) { - // multiply the height of the rect with the phase - - r.top *= phaseY - r.bottom *= phaseY - - valueMatrix.mapRect(r) - viewPortHandler.matrixTouch.mapRect(r) - offsetMatrix.mapRect(r) - } - - fun rectToPixelPhaseHorizontal(r: RectF, phaseY: Float) { - // multiply the height of the rect with the phase - - r.left *= phaseY - r.right *= phaseY - - valueMatrix.mapRect(r) - viewPortHandler.matrixTouch.mapRect(r) - offsetMatrix.mapRect(r) - } - - /** - * Transform a rectangle with all matrices with potential animation phases. - */ - fun rectValueToPixelHorizontal(r: RectF?) { - valueMatrix.mapRect(r) - viewPortHandler.matrixTouch.mapRect(r) - offsetMatrix.mapRect(r) - } - - /** - * Transform a rectangle with all matrices with potential animation phases. - */ - fun rectValueToPixelHorizontal(r: RectF, phaseY: Float) { - // multiply the height of the rect with the phase - - r.left *= phaseY - r.right *= phaseY - - valueMatrix.mapRect(r) - viewPortHandler.matrixTouch.mapRect(r) - offsetMatrix.mapRect(r) - } - - /** - * transforms multiple rects with all matrices - */ - fun rectValuesToPixel(rects: MutableList) { - val m = this.valueToPixelMatrix - - for (i in rects.indices) m.mapRect(rects.get(i)) - } - - protected var mPixelToValueMatrixBuffer: Matrix = Matrix() - - /** - * Transforms the given array of touch positions (pixels) (x, y, x, y, ...) - * into values on the chart. - */ - fun pixelsToValue(pixels: FloatArray?) { - val tmp = mPixelToValueMatrixBuffer - tmp.reset() - - // invert all matrixes to convert back to the original value - offsetMatrix.invert(tmp) - tmp.mapPoints(pixels) - - viewPortHandler.matrixTouch.invert(tmp) - tmp.mapPoints(pixels) - - valueMatrix.invert(tmp) - tmp.mapPoints(pixels) - } - - /** - * buffer for performance - */ - var ptsBuffer: FloatArray = FloatArray(2) - - /** - * Returns a recyclable PointD instance. - * returns the x and y values in the chart at the given touch point - * (encapsulated in a PointD). This method transforms pixel coordinates to - * coordinates / values in the chart. This is the opposite method to - * getPixelForValues(...). - */ - fun getValuesByTouchPoint(x: Float, y: Float): PointD { - val result = getInstance(0.0, 0.0) - getValuesByTouchPoint(x, y, result) - return result - } - - fun getValuesByTouchPoint(x: Float, y: Float, outputPoint: PointD) { - ptsBuffer[0] = x - ptsBuffer[1] = y - - pixelsToValue(ptsBuffer) - - outputPoint.x = ptsBuffer[0].toDouble() - outputPoint.y = ptsBuffer[1].toDouble() - } - - /** - * Returns a recyclable PointD instance. - * Returns the x and y coordinates (pixels) for a given x and y value in the chart. - */ - fun getPixelForValues(x: Float, y: Float): PointD { - ptsBuffer[0] = x - ptsBuffer[1] = y - - pointValuesToPixel(ptsBuffer) - - val xPx = ptsBuffer[0].toDouble() - val yPx = ptsBuffer[1].toDouble() - - return getInstance(xPx, yPx) - } - - private val mMBuffer1 = Matrix() - - val valueToPixelMatrix: Matrix - get() { - mMBuffer1.set(this.valueMatrix) - mMBuffer1.postConcat(viewPortHandler.matrixTouch) - mMBuffer1.postConcat(this.offsetMatrix) - return mMBuffer1 - } - - private val mMBuffer2 = Matrix() - - val pixelToValueMatrix: Matrix - get() { - this.valueToPixelMatrix.invert(mMBuffer2) - return mMBuffer2 - } } diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/TransformerCore.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/TransformerCore.kt new file mode 100644 index 00000000..3145c21c --- /dev/null +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/TransformerCore.kt @@ -0,0 +1,223 @@ +package info.appdev.charting.utils + +/** + * Transformer class that contains all matrices and is responsible for + * transforming values into pixels on the screen and backwards. + * + * This common base holds every matrix operation that does not depend on Android-only types + * (`Path`) or on the dataset family (`ILineDataSet`, `IBubbleDataSet`, `ICandleDataSet`, + * `IScatterDataSet`), which are not yet available in `commonMain`. The Android-specific pieces + * live in `info.appdev.charting.utils.Transformer` in the `chartLib` module, which extends this + * class. + */ +open class TransformerCore(protected var viewPortHandler: ViewPortHandler) { + /** + * matrix to map the values to the screen pixels + */ + var valueMatrix: Matrix = Matrix() + protected set + + /** + * matrix for handling the different offsets of the chart + */ + var offsetMatrix: Matrix = Matrix() + protected set + + /** + * Prepares the matrix that transforms values to pixels. Calculates the + * scale factors from the charts size and offsets. + */ + fun prepareMatrixValuePx(xChartMin: Float, deltaX: Float, deltaY: Float, yChartMin: Float) { + var scaleX = viewPortHandler.contentWidth() / deltaX + var scaleY = viewPortHandler.contentHeight() / deltaY + + if (scaleX.isInfinite()) { + scaleX = 0f + } + if (scaleY.isInfinite()) { + scaleY = 0f + } + + // setup all matrices + valueMatrix.reset() + valueMatrix.postTranslate(-xChartMin, -yChartMin) + valueMatrix.postScale(scaleX, -scaleY) + } + + /** + * Prepares the matrix that contains all offsets. + */ + open fun prepareMatrixOffset(inverted: Boolean) { + offsetMatrix.reset() + + // offset.postTranslate(mOffsetLeft, getHeight() - mOffsetBottom); + if (!inverted) offsetMatrix.postTranslate( + viewPortHandler.offsetLeft(), + viewPortHandler.chartHeight - viewPortHandler.offsetBottom() + ) + else { + offsetMatrix + .setTranslate(viewPortHandler.offsetLeft(), -viewPortHandler.offsetTop()) + offsetMatrix.postScale(1.0f, -1.0f) + } + } + + /** + * Transform an array of points with all matrices. VERY IMPORTANT: Keep + * matrix order "value-touch-offset" when transforming. + */ + fun pointValuesToPixel(pts: FloatArray) { + valueMatrix.mapPoints(pts) + viewPortHandler.matrixTouch.mapPoints(pts) + offsetMatrix.mapPoints(pts) + } + + /** + * Transform a rectangle with all matrices. + */ + fun rectValueToPixel(r: RectF) { + valueMatrix.mapRect(r) + viewPortHandler.matrixTouch.mapRect(r) + offsetMatrix.mapRect(r) + } + + /** + * Transform a rectangle with all matrices with potential animation phases. + */ + fun rectToPixelPhase(r: RectF, phaseY: Float) { + // multiply the height of the rect with the phase + + r.top *= phaseY + r.bottom *= phaseY + + valueMatrix.mapRect(r) + viewPortHandler.matrixTouch.mapRect(r) + offsetMatrix.mapRect(r) + } + + fun rectToPixelPhaseHorizontal(r: RectF, phaseY: Float) { + // multiply the height of the rect with the phase + + r.left *= phaseY + r.right *= phaseY + + valueMatrix.mapRect(r) + viewPortHandler.matrixTouch.mapRect(r) + offsetMatrix.mapRect(r) + } + + /** + * Transform a rectangle with all matrices with potential animation phases. + */ + fun rectValueToPixelHorizontal(r: RectF) { + valueMatrix.mapRect(r) + viewPortHandler.matrixTouch.mapRect(r) + offsetMatrix.mapRect(r) + } + + /** + * Transform a rectangle with all matrices with potential animation phases. + */ + fun rectValueToPixelHorizontal(r: RectF, phaseY: Float) { + // multiply the height of the rect with the phase + + r.left *= phaseY + r.right *= phaseY + + valueMatrix.mapRect(r) + viewPortHandler.matrixTouch.mapRect(r) + offsetMatrix.mapRect(r) + } + + /** + * transforms multiple rects with all matrices + */ + fun rectValuesToPixel(rects: MutableList) { + val m = this.valueToPixelMatrix + + for (i in rects.indices) m.mapRect(rects[i]) + } + + protected var mPixelToValueMatrixBuffer: Matrix = Matrix() + + /** + * Transforms the given array of touch positions (pixels) (x, y, x, y, ...) + * into values on the chart. + */ + fun pixelsToValue(pixels: FloatArray) { + val tmp = mPixelToValueMatrixBuffer + tmp.reset() + + // invert all matrixes to convert back to the original value + offsetMatrix.invert(tmp) + tmp.mapPoints(pixels) + + viewPortHandler.matrixTouch.invert(tmp) + tmp.mapPoints(pixels) + + valueMatrix.invert(tmp) + tmp.mapPoints(pixels) + } + + /** + * buffer for performance + */ + var ptsBuffer: FloatArray = FloatArray(2) + + /** + * Returns a recyclable PointD instance. + * returns the x and y values in the chart at the given touch point + * (encapsulated in a PointD). This method transforms pixel coordinates to + * coordinates / values in the chart. This is the opposite method to + * getPixelForValues(...). + */ + fun getValuesByTouchPoint(x: Float, y: Float): PointD { + val result = PointD.getInstance(0.0, 0.0) + getValuesByTouchPoint(x, y, result) + return result + } + + fun getValuesByTouchPoint(x: Float, y: Float, outputPoint: PointD) { + ptsBuffer[0] = x + ptsBuffer[1] = y + + pixelsToValue(ptsBuffer) + + outputPoint.x = ptsBuffer[0].toDouble() + outputPoint.y = ptsBuffer[1].toDouble() + } + + /** + * Returns a recyclable PointD instance. + * Returns the x and y coordinates (pixels) for a given x and y value in the chart. + */ + fun getPixelForValues(x: Float, y: Float): PointD { + ptsBuffer[0] = x + ptsBuffer[1] = y + + pointValuesToPixel(ptsBuffer) + + val xPx = ptsBuffer[0].toDouble() + val yPx = ptsBuffer[1].toDouble() + + return PointD.getInstance(xPx, yPx) + } + + private val mMBuffer1 = Matrix() + + val valueToPixelMatrix: Matrix + get() { + mMBuffer1.set(this.valueMatrix) + mMBuffer1.postConcat(viewPortHandler.matrixTouch) + mMBuffer1.postConcat(this.offsetMatrix) + return mMBuffer1 + } + + private val mMBuffer2 = Matrix() + + val pixelToValueMatrix: Matrix + get() { + this.valueToPixelMatrix.invert(mMBuffer2) + return mMBuffer2 + } +} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/utils/ViewPortHandler.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ViewPortHandler.kt similarity index 96% rename from chartLib/src/main/kotlin/info/appdev/charting/utils/ViewPortHandler.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ViewPortHandler.kt index c6ec5006..a7942a53 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/utils/ViewPortHandler.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ViewPortHandler.kt @@ -1,9 +1,5 @@ package info.appdev.charting.utils -import android.graphics.Matrix -import android.graphics.RectF -import android.view.View -import timber.log.Timber import kotlin.math.max import kotlin.math.min @@ -112,8 +108,9 @@ open class ViewPortHandler { preserveViewPortOnResize(previousWidth, previousHeight) - if (logging) - Timber.i(contentRect.toString()) + if (logging) { + println(contentRect.toString()) + } } /** @@ -348,8 +345,9 @@ open class ViewPortHandler { * setScaleMinima(...) method. * * @param transformedPts the position to center view viewport to + * @param invalidate optional callback invoked when the host view should be redrawn */ - fun centerViewPort(transformedPts: FloatArray, view: View?) { + fun centerViewPort(transformedPts: FloatArray, invalidate: (() -> Unit)? = null) { val save = mCenterViewPortMatrixBuffer save.reset() save.set(matrixTouch) @@ -359,7 +357,7 @@ open class ViewPortHandler { save.postTranslate(-x, -y) - refresh(save, view, true) + refresh(save, invalidate, true) } /** @@ -369,15 +367,18 @@ open class ViewPortHandler { /** * call this method to refresh the graph with a given matrix + * + * @param invalidate optional callback invoked when the host view should be redrawn */ - fun refresh(newMatrix: Matrix, view: View?, invalidate: Boolean): Matrix { + fun refresh(newMatrix: Matrix, invalidate: (() -> Unit)?, doInvalidate: Boolean): Matrix { matrixTouch.set(newMatrix) // make sure scale and translation are within their bounds limitTransAndScale(matrixTouch, contentRect) - if (invalidate) - view?.invalidate() + if (doInvalidate) { + invalidate?.invoke() + } newMatrix.set(matrixTouch) return newMatrix From 83a270c6a955600263c68a32d23f553cea590482 Mon Sep 17 00:00:00 2001 From: Hannes Achleitner Date: Sun, 27 Sep 2026 08:29:10 +0200 Subject: [PATCH 09/22] Step A.3 (completion): migrate IDataSet/BaseDataSet/DataSet/ChartData to chartLibCore Unblocked by Step A.6 (ViewPortHandler migration), this completes the DataSet-family migration to the multiplatform chartLibCore module: - Move interfaces/datasets/IDataSet.kt, data/BaseDataSet.kt, data/DataSet.kt (incl. nested Rounding enum), and data/ChartData.kt to chartLibCore commonMain, preserving package/class names so no downstream imports change. - Wire up the previously-unused common DashEffect type: IDataSet/BaseDataSet/ Legend/LegendEntry now use DashEffect instead of android.graphics. DashPathEffect for formLineDashEffect, with a new Android-only DashEffectAndroid.kt converter at the one real Canvas boundary in LegendRenderer. Demo app call sites updated accordingly. - IDataSet now imports the top-level common AxisDependency/LegendForm enums directly instead of importing YAxis/Legend for their nested type aliases. - BaseDataSet: drop @ColorInt/@Transient annotations, replace android.graphics.Color helpers with new platform-independent ColorTemplate.argb/alpha/red/green/blue helpers, extract the Context-based setColors(IntArray, Context) overload into a chartLib-only extension function (BaseDataSetAndroid.kt), and replace Utils.defaultValueFormatter with a local companion default. - DataSet/ChartData: drop @SuppressLint, java.io.Serializable, and Timber logging (replaced with println), matching the precedent set by EntryFloat/Highlight/ViewPortHandler. - Move the now-unblocked formatter cluster (IValueFormatter, DefaultValueFormatter, StackedValueFormatter, ColorFormatter) to chartLibCore. DefaultValueFormatter/StackedValueFormatter no longer use java.text.DecimalFormat (no KMP equivalent); rewritten against a new shared utils/DecimalFormatting.kt helper with equivalent rounding/grouping behavior, covered by 5 new unit tests. Verified: chartLibCore build/allTests/testAndroidHostTest (Android host, desktop, iOS simulator), chartLib:compileDebugKotlin, full ./gradlew test, chartLibCompose:assembleDebug, app:assembleDebug all green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- KotlinMultiplatform.md | 100 +++++++++++++++++- .../info/appdev/chartexample/DataTools.kt | 4 +- .../SpecificPositionsLineChartActivity.kt | 4 +- .../info/appdev/charting/components/Legend.kt | 4 +- .../appdev/charting/components/LegendEntry.kt | 6 +- .../charting/data/BaseDataSetAndroid.kt | 21 ++++ .../charting/renderer/LegendRenderer.kt | 3 +- .../charting/utils/DashEffectAndroid.kt | 9 ++ .../info/appdev/charting/data/BaseDataSet.kt | 50 +++++---- .../info/appdev/charting/data/ChartData.kt | 12 +-- .../info/appdev/charting/data/DataSet.kt | 11 +- .../charting/formatter/ColorFormatter.kt | 0 .../formatter/DefaultValueFormatter.kt | 26 ++--- .../charting/formatter/IValueFormatter.kt | 0 .../formatter/StackedValueFormatter.kt | 19 +--- .../charting/interfaces/datasets/IDataSet.kt | 16 +-- .../appdev/charting/utils/ColorTemplate.kt | 32 ++++++ .../charting/utils/DecimalFormatting.kt | 37 +++++++ .../formatter/DefaultValueFormatterTest.kt | 44 ++++++++ 19 files changed, 305 insertions(+), 93 deletions(-) create mode 100644 chartLib/src/main/kotlin/info/appdev/charting/data/BaseDataSetAndroid.kt create mode 100644 chartLib/src/main/kotlin/info/appdev/charting/utils/DashEffectAndroid.kt rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/data/BaseDataSet.kt (88%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/data/ChartData.kt (98%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/data/DataSet.kt (95%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/formatter/ColorFormatter.kt (100%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/formatter/DefaultValueFormatter.kt (65%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/formatter/IValueFormatter.kt (100%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/formatter/StackedValueFormatter.kt (72%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/interfaces/datasets/IDataSet.kt (97%) create mode 100644 chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/DecimalFormatting.kt create mode 100644 chartLibCore/src/commonTest/kotlin/info/appdev/charting/formatter/DefaultValueFormatterTest.kt diff --git a/KotlinMultiplatform.md b/KotlinMultiplatform.md index 65aa9fe6..27ec8132 100644 --- a/KotlinMultiplatform.md +++ b/KotlinMultiplatform.md @@ -165,7 +165,7 @@ lint Existing custom lint rules (unchanged) macOS with Xcode present (all iOS/android/desktop targets still compile and test normally; nothing is being silently skipped here — the flag only matters on hosts that can't build iOS at all). -- [ ] **Step A.3 (remainder, partial) — DataSet-family groundwork: common +- [x] **Step A.3 (remainder, partial) — DataSet-family groundwork: common enums/abstractions landed; full `IDataSet`/`BaseDataSet`/`DataSet`/`ChartData` move is BLOCKED, see below.** - **Landed in `chartLibCore` (new, reusable common types):** @@ -263,6 +263,104 @@ lint Existing custom lint rules (unchanged) `YAxis`/`Legend`/`CandleDataSet`/`ICandleDataSet`/`IDataSet`/`BaseDataSet`/ `ChartData`/`CandleStickChartRenderer`/`CandleStickChartActivity` changes described above. +- [x] **Step A.3 (completion) — `IDataSet`/`BaseDataSet`/`DataSet`/`ChartData` + fully migrated to `chartLibCore` commonMain, unblocked by Step A.6.** + - **Moved to `chartLibCore` commonMain** (same package/class names, so + zero import churn for any downstream consumer): + `interfaces/datasets/IDataSet.kt`, `data/BaseDataSet.kt`, `data/DataSet.kt` + (including its nested `enum class Rounding { UP, DOWN, CLOSEST }`, which + simply moved with the file — no separate top-level extraction needed since + the whole class relocated), `data/ChartData.kt`. + - **`DashEffect` finally wired up**: `IDataSet.formLineDashEffect`, + `BaseDataSet.mFormLineDashEffect`/`formLineDashEffect`, + `components/Legend.formLineDashEffect`, and + `components/LegendEntry.formLineDashEffect` all switched from + `android.graphics.DashPathEffect?` to the common `utils/DashEffect.kt` + (landed unwired in the previous slice). `Legend`/`LegendEntry` themselves + **stay in `chartLib`** (still blocked by `android.graphics.Paint` usage + elsewhere in `Legend`), so a new Android-only + `chartLib/utils/DashEffectAndroid.kt` (`DashEffect.toAndroidDashPathEffect()`) + converts at the one real Canvas boundary, + `LegendRenderer.kt`'s `formPaint.pathEffect = ...` assignment. Two demo-app + call sites (`DataTools.kt`, `SpecificPositionsLineChartActivity.kt`) that + constructed `DashPathEffect(...)` for `formLineDashEffect` were updated to + construct `DashEffect(...)` instead — a small source-level (not behavioral) + breaking change for any external code doing the same. + - **`IDataSet.kt`'s nested-typealias imports replaced**: now imports the + top-level common `components/AxisDependency`/`components/LegendForm` + directly instead of importing `components/YAxis`/`components/Legend` just + to reach their nested type aliases (same pattern used for `Highlight` in + Step A.4). + - **`BaseDataSet.kt` Android-coupling removed**: + - `@ColorInt`/`@Transient` annotations dropped (lint/serialization hints + with no multiplatform equivalent; `@Transient` was already meaningless + once `Serializable` was dropped from `DataSet`). + - `android.graphics.Color.rgb/argb/red/green/blue(...)` calls replaced with + new platform-independent equivalents added to `chartLibCore`'s + `utils/ColorTemplate.kt`: `argb(alpha, r, g, b)` (4-arg overload, the + existing 3-arg `argb(r, g, b)` stayed for the fully-opaque case), + `alpha(color)`, `red(color)`, `green(color)`, `blue(color)`. + - The one `Context`-taking overload, `setColors(colors: IntArray, context: + Context)` (resolves Android color resources via `ContextCompat`), was + extracted out of the class body into a new Android-only extension + function in `chartLib/data/BaseDataSetAndroid.kt`, implemented purely in + terms of `BaseDataSet`'s existing public `resetColors()`/`addColor(...)` + API (no `protected` member access needed). Call-site syntax + (`dataSet.setColors(colors, context)`) is unchanged since Kotlin extension + functions resolve identically to member functions at call sites; a + repo-wide grep found zero existing callers of this overload. + - `Utils.defaultValueFormatter` (an Android-only `object Utils` singleton + that can't be referenced from commonMain, since dependencies only flow + `chartLibCore` → `chartLib`, never the reverse) replaced with a private + `companion object { private val defaultValueFormatter: IValueFormatter by + lazy { DefaultValueFormatter(1) } }` directly on `BaseDataSet`. + - **`DataSet.kt`/`ChartData.kt` cleanup**: dropped `@SuppressLint(...)` + (meaningless outside `chartLib`, which is the only module with the custom + `:lint` checks applied via `lintChecks(project(":lint"))`), dropped + `java.io.Serializable` (same disclosed-breaking-change rationale as + `EntryFloat`/`PointF`/`Highlight` — no internal usage of `DataSet`/ + `ChartData` serialization found repo-wide), and replaced the two + `Timber.e(...)` calls with `println(...)` (same rare-error-log rationale as + `ViewPortHandler`'s `Timber.i(...)` → `println(...)` in Step A.6). + - **Formatter cluster partly unblocked and moved as a natural follow-on**, + since `IDataSet.valueFormatter: IValueFormatter` now lives in + `chartLibCore`: `formatter/IValueFormatter.kt` (no changes needed) and + `formatter/DefaultValueFormatter.kt`/`formatter/StackedValueFormatter.kt` + (both previously used `java.text.DecimalFormat`, which has no multiplatform + equivalent — rewritten in terms of a new shared common helper, + `utils/DecimalFormatting.kt`'s `formatGroupedDecimal(value, digits)`, + implementing the same rounding + thousands-grouping behavior as the + `"###,###,###,##0.00"`-style `DecimalFormat` patterns using only + `kotlin.math`). `formatter/ColorFormatter.kt` also moved (only depended on + `EntryFloat`/`IDataSet`, both already common). Added + `chartLibCore/commonTest/formatter/DefaultValueFormatterTest.kt` (5 new + unit tests covering zero/nonzero decimal digits, thousands grouping, + negative values, and negative-zero rounding) since this rewrite had no + prior test coverage. + - **Still blocked / explicitly out of scope for this slice:** + `IAxisValueFormatter`, `DefaultAxisValueFormatter`, + `IndexAxisValueFormatter`, `LargeValueFormatter`, `PercentFormatter` + (blocked by `components/AxisBase`, Step A.5); `IFillFormatter`, + `DefaultFillFormatter` (blocked by `interfaces/dataprovider/LineDataProvider` + and `ILineDataSet`); every concrete `*DataSet` subclass + (`LineDataSet`, `BarDataSet`, `ScatterDataSet`, etc. — most still use + `android.graphics.Color`/`Paint`/`Shader` extensively, and `IBarDataSet`/ + `IScatterDataSet` have their own separate `Fill`/`IShapeRenderer` blockers + noted in the prior Step A.3 entry); `LineDataSet.dashPathEffect`/ + `LineScatterCandleRadarDataSet.dashPathEffectHighlight` (a *different* + dash-effect property from `formLineDashEffect`, tied to `ILineDataSet`, + left as `android.graphics.DashPathEffect` since `ILineDataSet` itself isn't + moving yet). + - Verified: `:chartLibCore:build` (Android host + desktop-JVM + iOS×3 + simulator/device targets all compile and pass tests, including the 5 new + `DefaultValueFormatterTest` cases), `chartLib:compileDebugKotlin` + (zero warnings/errors beyond pre-existing unrelated ones), full + `./gradlew test`, `chartLibCompose:assembleDebug`, and `app:assembleDebug` + all pass. + - **Now unblocked:** highlighter classes in Step A.4 that depend on + `IDataSet` (though most still also depend on `interfaces/dataprovider/*`, + so remain blocked for that separate reason); `AxisBase`/Step A.5 can now + freely reference `IDataSet`-family types if needed. - [ ] **Step A.4 (partial) — `Highlight`/`IHighlighter` moved; the rest of `formatter/` and `highlight/` is BLOCKED by the same Step A.6 dependency found while investigating Step A.3.** diff --git a/app/src/main/kotlin/info/appdev/chartexample/DataTools.kt b/app/src/main/kotlin/info/appdev/chartexample/DataTools.kt index 0ae78e98..87d3742d 100644 --- a/app/src/main/kotlin/info/appdev/chartexample/DataTools.kt +++ b/app/src/main/kotlin/info/appdev/chartexample/DataTools.kt @@ -2,7 +2,7 @@ package info.appdev.chartexample import android.content.Context import android.graphics.Color -import android.graphics.DashPathEffect +import info.appdev.charting.utils.DashEffect import androidx.core.content.ContextCompat import info.appdev.charting.charts.LineChart import info.appdev.charting.data.EntryFloat @@ -214,7 +214,7 @@ class DataTools { // customize legend entry lineDataSet01.formLineWidth = 1f - lineDataSet01.formLineDashEffect = DashPathEffect(floatArrayOf(10f, 5f), 0f) + lineDataSet01.formLineDashEffect = DashEffect(floatArrayOf(10f, 5f), 0f) lineDataSet01.formSize = 15f // text size of values diff --git a/app/src/main/kotlin/info/appdev/chartexample/SpecificPositionsLineChartActivity.kt b/app/src/main/kotlin/info/appdev/chartexample/SpecificPositionsLineChartActivity.kt index 212d9608..8abb5d61 100644 --- a/app/src/main/kotlin/info/appdev/chartexample/SpecificPositionsLineChartActivity.kt +++ b/app/src/main/kotlin/info/appdev/chartexample/SpecificPositionsLineChartActivity.kt @@ -2,7 +2,7 @@ package info.appdev.chartexample import android.annotation.SuppressLint import android.graphics.Color -import android.graphics.DashPathEffect +import info.appdev.charting.utils.DashEffect import android.graphics.Typeface import android.os.Bundle import android.view.Menu @@ -272,7 +272,7 @@ class SpecificPositionsLineChartActivity : DemoBase(), OnSeekBarChangeListener, set11.valueTextSize = 9f set11.isDrawFilled = true set11.formLineWidth = 1f - set11.formLineDashEffect = DashPathEffect(floatArrayOf(10f, 5f), 0f) + set11.formLineDashEffect = DashEffect(floatArrayOf(10f, 5f), 0f) set11.formSize = 15f if (getSDKInt() >= 18) { // fill drawable only supported on api level 18 and above diff --git a/chartLib/src/main/kotlin/info/appdev/charting/components/Legend.kt b/chartLib/src/main/kotlin/info/appdev/charting/components/Legend.kt index 344b2940..a42336df 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/components/Legend.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/components/Legend.kt @@ -1,8 +1,8 @@ package info.appdev.charting.components -import android.graphics.DashPathEffect import android.graphics.Paint import info.appdev.charting.utils.ColorTemplate +import info.appdev.charting.utils.DashEffect import info.appdev.charting.utils.FSize import info.appdev.charting.utils.ViewPortHandler import info.appdev.charting.utils.calcTextHeight @@ -107,7 +107,7 @@ class Legend() : ComponentBase() { /** * Line dash path effect used for shapes that consist of lines. */ - var formLineDashEffect: DashPathEffect? = null + var formLineDashEffect: DashEffect? = null /** * the space between the legend entries on a horizontal axis, default 6f diff --git a/chartLib/src/main/kotlin/info/appdev/charting/components/LegendEntry.kt b/chartLib/src/main/kotlin/info/appdev/charting/components/LegendEntry.kt index 1f6401de..09256b7b 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/components/LegendEntry.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/components/LegendEntry.kt @@ -1,6 +1,6 @@ package info.appdev.charting.components -import android.graphics.DashPathEffect +import info.appdev.charting.utils.DashEffect import androidx.annotation.ColorInt import info.appdev.charting.components.Legend.LegendForm import info.appdev.charting.utils.ColorTemplate @@ -23,7 +23,7 @@ class LegendEntry { form: LegendForm, formSize: Float, formLineWidth: Float, - formLineDashEffect: DashPathEffect?, + formLineDashEffect: DashEffect?, @ColorInt formColor: Int ) { this.label = label @@ -68,7 +68,7 @@ class LegendEntry { * * Set to null to use the legend's default */ - var formLineDashEffect: DashPathEffect? = null + var formLineDashEffect: DashEffect? = null /** * The color for drawing the form diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/BaseDataSetAndroid.kt b/chartLib/src/main/kotlin/info/appdev/charting/data/BaseDataSetAndroid.kt new file mode 100644 index 00000000..58c0af19 --- /dev/null +++ b/chartLib/src/main/kotlin/info/appdev/charting/data/BaseDataSetAndroid.kt @@ -0,0 +1,21 @@ +package info.appdev.charting.data + +import android.content.Context +import androidx.core.content.ContextCompat + +/** + * Sets the colors that should be used for this DataSet. Colors are reused + * as soon as the number of Entries the DataSet represents is higher than + * the size of the colors array. You can use + * "new int[] { R.color.red, R.color.green, ... }" to provide colors for + * this method. Internally, the colors are resolved using + * ContextCompat.getColor(context,...) + * + * Android-only extension since it needs a `Context` to resolve color resources. + */ +fun BaseDataSet<*>.setColors(colors: IntArray, context: Context) { + resetColors() + for (color in colors) { + addColor(ContextCompat.getColor(context, color)) + } +} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/renderer/LegendRenderer.kt b/chartLib/src/main/kotlin/info/appdev/charting/renderer/LegendRenderer.kt index 408f57db..531f2203 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/renderer/LegendRenderer.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/renderer/LegendRenderer.kt @@ -23,6 +23,7 @@ import info.appdev.charting.utils.calcTextWidth import info.appdev.charting.utils.convertDpToPixel import info.appdev.charting.utils.getLineHeight import info.appdev.charting.utils.getLineSpacing +import info.appdev.charting.utils.toAndroidDashPathEffect import java.util.Collections import kotlin.math.min @@ -486,7 +487,7 @@ open class LegendRenderer( entry.formLineDashEffect formPaint.style = Paint.Style.STROKE formPaint.strokeWidth = formLineWidth - formPaint.pathEffect = formLineDashEffect + formPaint.pathEffect = formLineDashEffect?.toAndroidDashPathEffect() mLineFormPath.reset() mLineFormPath.moveTo(x, y) diff --git a/chartLib/src/main/kotlin/info/appdev/charting/utils/DashEffectAndroid.kt b/chartLib/src/main/kotlin/info/appdev/charting/utils/DashEffectAndroid.kt new file mode 100644 index 00000000..6ee4bf1b --- /dev/null +++ b/chartLib/src/main/kotlin/info/appdev/charting/utils/DashEffectAndroid.kt @@ -0,0 +1,9 @@ +package info.appdev.charting.utils + +import android.graphics.DashPathEffect + +/** + * Converts a platform-independent [DashEffect] into an Android [DashPathEffect], + * for use at Canvas/Paint drawing boundaries (e.g. `Paint.pathEffect`). + */ +fun DashEffect.toAndroidDashPathEffect(): DashPathEffect = DashPathEffect(intervals, phase) diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/BaseDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BaseDataSet.kt similarity index 88% rename from chartLib/src/main/kotlin/info/appdev/charting/data/BaseDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BaseDataSet.kt index 7a7a67c2..7ea75842 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/BaseDataSet.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BaseDataSet.kt @@ -1,18 +1,14 @@ package info.appdev.charting.data -import android.content.Context -import android.graphics.Color -import android.graphics.DashPathEffect -import info.appdev.charting.utils.ChartTypeface -import androidx.annotation.ColorInt -import androidx.core.content.ContextCompat -import info.appdev.charting.components.Legend.LegendForm -import info.appdev.charting.components.YAxis.AxisDependency +import info.appdev.charting.components.AxisDependency +import info.appdev.charting.components.LegendForm +import info.appdev.charting.formatter.DefaultValueFormatter import info.appdev.charting.formatter.IValueFormatter import info.appdev.charting.interfaces.datasets.IDataSet +import info.appdev.charting.utils.ChartTypeface import info.appdev.charting.utils.ColorTemplate +import info.appdev.charting.utils.DashEffect import info.appdev.charting.utils.PointF -import info.appdev.charting.utils.Utils import info.appdev.charting.utils.convertDpToPixel /** @@ -23,13 +19,11 @@ abstract class BaseDataSet>() : IDataSet { /** * List representing all colors that are used for this DataSet */ - @ColorInt protected var mColors: MutableList /** * List representing all colors that are used for drawing the actual values for this DataSet */ - @ColorInt protected var mValueColors: MutableList /** @@ -50,7 +44,6 @@ abstract class BaseDataSet>() : IDataSet { /** * custom formatter that is used instead of the auto-formatter if set */ - @Transient protected var mValueFormatter: IValueFormatter? = null /** @@ -61,7 +54,7 @@ abstract class BaseDataSet>() : IDataSet { private var mForm = LegendForm.DEFAULT private var mFormSize = Float.NaN private var mFormLineWidth = Float.NaN - private var mFormLineDashEffect: DashPathEffect? = null + private var mFormLineDashEffect: DashEffect? = null private var mIsVisible = true private var mIsDrawValues = true private var mIsDrawIcons = true @@ -96,8 +89,8 @@ abstract class BaseDataSet>() : IDataSet { mValueColors = ArrayList() // default color - mColors.add(Color.rgb(140, 234, 255)) - mValueColors.add(Color.BLACK) + mColors.add(ColorTemplate.argb(140, 234, 255)) + mValueColors.add(ColorTemplate.BLACK) } constructor(label: String) : this() { @@ -117,7 +110,7 @@ abstract class BaseDataSet>() : IDataSet { */ fun setColor(color: Int, alpha: Int) { mColors.clear() - mColors.add(Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color))) + mColors.add(ColorTemplate.argb(alpha, ColorTemplate.red(color), ColorTemplate.green(color), ColorTemplate.blue(color))) } override var color: Int @@ -134,7 +127,7 @@ abstract class BaseDataSet>() : IDataSet { return mColors[index % mColors.size] } - override var formLineDashEffect: DashPathEffect? + override var formLineDashEffect: DashEffect? get() = mFormLineDashEffect set(value) { mFormLineDashEffect = value @@ -197,14 +190,11 @@ abstract class BaseDataSet>() : IDataSet { * "new int[] { R.color.red, R.color.green, ... }" to provide colors for * this method. Internally, the colors are resolved using * ContextCompat.getColor(context,...) + * + * Note: this is provided as an Android-only extension function, see + * `BaseDataSetAndroid.kt`, since it needs an Android `Context` to resolve + * color resources. */ - fun setColors(colors: IntArray, context: Context) { - mColors.clear() - - for (color in colors) { - mColors.add(ContextCompat.getColor(context, color)) - } - } /** * Adds a new color to the colors array of the DataSet. @@ -219,7 +209,7 @@ abstract class BaseDataSet>() : IDataSet { fun setColors(colors: IntArray, alpha: Int) { resetColors() for (color in colors) { - addColor(Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color))) + addColor(ColorTemplate.argb(alpha, ColorTemplate.red(color), ColorTemplate.green(color), ColorTemplate.blue(color))) } } @@ -249,7 +239,7 @@ abstract class BaseDataSet>() : IDataSet { override var valueFormatter: IValueFormatter get() = if (needsFormatter()) - Utils.defaultValueFormatter + defaultValueFormatter else mValueFormatter!! set(value) { @@ -367,4 +357,12 @@ abstract class BaseDataSet>() : IDataSet { baseDataSet.mValueTextSize = mValueTextSize baseDataSet.mVisible = mVisible } + + companion object { + /** + * The default value formatter used when no custom formatter is set, + * platform-independent replacement for the former `Utils.defaultValueFormatter`. + */ + private val defaultValueFormatter: IValueFormatter by lazy { DefaultValueFormatter(1) } + } } diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/ChartData.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/ChartData.kt similarity index 98% rename from chartLib/src/main/kotlin/info/appdev/charting/data/ChartData.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/ChartData.kt index bdce5fea..769e45a3 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/ChartData.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/ChartData.kt @@ -1,19 +1,16 @@ package info.appdev.charting.data -import android.annotation.SuppressLint -import info.appdev.charting.utils.ChartTypeface -import info.appdev.charting.components.YAxis.AxisDependency +import info.appdev.charting.components.AxisDependency import info.appdev.charting.formatter.IValueFormatter import info.appdev.charting.highlight.Highlight import info.appdev.charting.interfaces.datasets.IDataSet -import timber.log.Timber -import java.io.Serializable +import info.appdev.charting.utils.ChartTypeface /** * Class that holds all relevant data that represents the chart. That involves at least one (or more) DataSets, and an array of x-values. */ @Suppress("unused") -abstract class ChartData> : Serializable { +abstract class ChartData> { /** * maximum y-value in the value array across all axes */ @@ -339,7 +336,7 @@ abstract class ChartData> : Serializable { calcMinMax(entryFloat, set.axisDependency) } else { - Timber.e("Cannot add Entry because dataSetIndex too high or too low.") + println("Cannot add Entry because dataSetIndex too high or too low.") } } @@ -441,7 +438,6 @@ abstract class ChartData> : Serializable { * specified index. Returns true if an Entry was removed, false if no Entry * was found that meets the specified requirements. */ - @SuppressLint("RawTypeDataSet") open fun removeEntry(xValue: Float, dataSetIndex: Int): Boolean { if (dataSetIndex >= dataSets.size) { return false diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/DataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/DataSet.kt similarity index 95% rename from chartLib/src/main/kotlin/info/appdev/charting/data/DataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/DataSet.kt index 4073809f..2ab25b69 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/DataSet.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/DataSet.kt @@ -1,8 +1,5 @@ package info.appdev.charting.data -import android.annotation.SuppressLint -import timber.log.Timber -import java.io.Serializable import kotlin.math.abs /** @@ -14,7 +11,7 @@ import kotlin.math.abs abstract class DataSet>( protected var entriesInternal: MutableList, label: String = "" -) : BaseDataSet(label), Serializable { +) : BaseDataSet(label) { /** * maximum y-value in the value array */ @@ -131,7 +128,7 @@ abstract class DataSet>( */ abstract fun copy(): DataSet? - protected fun copy(@SuppressLint("RawTypeDataSet") dataSet: DataSet<*>) { + protected fun copy(dataSet: DataSet<*>) { super.copy(dataSet) } @@ -199,10 +196,10 @@ abstract class DataSet>( override fun getEntryForIndex(index: Int): T? { if (index < 0) { - Timber.e("index $index is < 0 for getEntryForIndex") + println("index $index is < 0 for getEntryForIndex") return null } else if (index >= entriesInternal.size) { - Timber.e("index $index / ${entriesInternal.size} is out of range for getEntryForIndex") + println("index $index / ${entriesInternal.size} is out of range for getEntryForIndex") return null } return entriesInternal[index] diff --git a/chartLib/src/main/kotlin/info/appdev/charting/formatter/ColorFormatter.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/ColorFormatter.kt similarity index 100% rename from chartLib/src/main/kotlin/info/appdev/charting/formatter/ColorFormatter.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/ColorFormatter.kt diff --git a/chartLib/src/main/kotlin/info/appdev/charting/formatter/DefaultValueFormatter.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/DefaultValueFormatter.kt similarity index 65% rename from chartLib/src/main/kotlin/info/appdev/charting/formatter/DefaultValueFormatter.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/DefaultValueFormatter.kt index 1164c220..58a58142 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/formatter/DefaultValueFormatter.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/DefaultValueFormatter.kt @@ -2,18 +2,15 @@ package info.appdev.charting.formatter import info.appdev.charting.data.EntryFloat import info.appdev.charting.utils.ViewPortHandler -import java.text.DecimalFormat +import info.appdev.charting.utils.formatGroupedDecimal /** - * Default formatter used for formatting values inside the chart. Uses a DecimalFormat with - * pre-calculated number of digits (depending on max and min value). + * Default formatter used for formatting values inside the chart. Formats values with a + * pre-calculated number of decimal digits and groups the integer part with thousands + * separators, platform-independent replacement for the former `java.text.DecimalFormat`-based + * implementation (e.g. pattern "###,###,###,##0.00"). */ open class DefaultValueFormatter(digits: Int) : IValueFormatter { - /** - * DecimalFormat for formatting - */ - protected var decimalFormat: DecimalFormat? = null - /** * Returns the number of decimal digits this formatter uses. */ @@ -34,18 +31,11 @@ open class DefaultValueFormatter(digits: Int) : IValueFormatter { */ fun setup(digits: Int) { decimalDigits = digits - val b = StringBuffer() - for (i in 0 until digits) { - if (i == 0) b.append(".") - b.append("0") - } - decimalFormat = DecimalFormat("###,###,###,##0$b") } - override fun getFormattedValue(value: Float, entryFloat: EntryFloat?, dataSetIndex: Int, viewPortHandler: ViewPortHandler?): String? { - + override fun getFormattedValue(value: Float, entryFloat: EntryFloat?, dataSetIndex: Int, viewPortHandler: ViewPortHandler?): String { // put more logic here ... // avoid memory allocations here (for performance reasons) - return decimalFormat!!.format(value.toDouble()) + return formatGroupedDecimal(value.toDouble(), decimalDigits) } -} \ No newline at end of file +} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/formatter/IValueFormatter.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/IValueFormatter.kt similarity index 100% rename from chartLib/src/main/kotlin/info/appdev/charting/formatter/IValueFormatter.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/IValueFormatter.kt diff --git a/chartLib/src/main/kotlin/info/appdev/charting/formatter/StackedValueFormatter.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/StackedValueFormatter.kt similarity index 72% rename from chartLib/src/main/kotlin/info/appdev/charting/formatter/StackedValueFormatter.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/StackedValueFormatter.kt index 606bfeb0..17e6e807 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/formatter/StackedValueFormatter.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/StackedValueFormatter.kt @@ -3,7 +3,7 @@ package info.appdev.charting.formatter import info.appdev.charting.data.BarEntryFloat import info.appdev.charting.data.EntryFloat import info.appdev.charting.utils.ViewPortHandler -import java.text.DecimalFormat +import info.appdev.charting.utils.formatGroupedDecimal /** * A formatter specifically for stacked BarChart that allows to specify whether the all stack values @@ -16,18 +16,7 @@ import java.text.DecimalFormat * @param appendix a string that should be appended behind the value * @param decimals the number of decimal digits to use */ -open class StackedValueFormatter(private val drawWholeStack: Boolean, private val appendix: String, decimals: Int) : IValueFormatter { - private val decimalFormat: DecimalFormat - - init { - val b = StringBuffer() - for (i in 0 until decimals) { - if (i == 0) b.append(".") - b.append("0") - } - - this.decimalFormat = DecimalFormat("###,###,###,##0$b") - } +open class StackedValueFormatter(private val drawWholeStack: Boolean, private val appendix: String, private val decimals: Int) : IValueFormatter { override fun getFormattedValue(value: Float, entryFloat: EntryFloat?, dataSetIndex: Int, viewPortHandler: ViewPortHandler?): String { if (!drawWholeStack && entryFloat is BarEntryFloat) { @@ -40,7 +29,7 @@ open class StackedValueFormatter(private val drawWholeStack: Boolean, private va return if (vals[vals.size - 1] == value) { // return the "sum" across all stack values - decimalFormat.format(barEntry.y.toDouble()) + appendix + formatGroupedDecimal(barEntry.y.toDouble(), decimals) + appendix } else { "" // return empty } @@ -48,6 +37,6 @@ open class StackedValueFormatter(private val drawWholeStack: Boolean, private va } // return the "proposed" value - return decimalFormat.format(value.toDouble()) + appendix + return formatGroupedDecimal(value.toDouble(), decimals) + appendix } } diff --git a/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/IDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/IDataSet.kt similarity index 97% rename from chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/IDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/IDataSet.kt index da94998b..7b907ab1 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/IDataSet.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/IDataSet.kt @@ -1,12 +1,12 @@ package info.appdev.charting.interfaces.datasets -import android.graphics.DashPathEffect -import info.appdev.charting.utils.ChartTypeface -import info.appdev.charting.components.Legend -import info.appdev.charting.components.YAxis -import info.appdev.charting.data.DataSet +import info.appdev.charting.components.AxisDependency +import info.appdev.charting.components.LegendForm import info.appdev.charting.data.BaseEntry +import info.appdev.charting.data.DataSet import info.appdev.charting.formatter.IValueFormatter +import info.appdev.charting.utils.ChartTypeface +import info.appdev.charting.utils.DashEffect import info.appdev.charting.utils.PointF interface IDataSet> { @@ -186,7 +186,7 @@ interface IDataSet> { /** * Set the y-axis this DataSet should be plotted against (either LEFT or RIGHT). Default: LEFT */ - var axisDependency: YAxis.AxisDependency + var axisDependency: AxisDependency /** * returns all the colors that are set for this DataSet @@ -250,7 +250,7 @@ interface IDataSet> { * * Return `DEFAULT` to use the default legend form. */ - val form: Legend.LegendForm + val form: LegendForm /** * The form size to draw for this dataset in the legend. @@ -271,7 +271,7 @@ interface IDataSet> { * * Return `null` to use the default legend form line dash effect. */ - val formLineDashEffect: DashPathEffect? + val formLineDashEffect: DashEffect? /** * Returns true if y-value drawing is enabled, false if not diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ColorTemplate.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ColorTemplate.kt index a22a9d31..70aa8557 100644 --- a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ColorTemplate.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ColorTemplate.kt @@ -115,4 +115,36 @@ object ColorTemplate { fun argb(red: Int, green: Int, blue: Int): Int { return (0xFF shl 24) or ((red and 0xFF) shl 16) or ((green and 0xFF) shl 8) or (blue and 0xFF) } + + /** + * Builds an ARGB color int from the given alpha/red/green/blue components (0-255), + * platform-independent replacement for android.graphics.Color.argb(a, r, g, b). + */ + fun argb(alpha: Int, red: Int, green: Int, blue: Int): Int { + return ((alpha and 0xFF) shl 24) or ((red and 0xFF) shl 16) or ((green and 0xFF) shl 8) or (blue and 0xFF) + } + + /** + * Extracts the alpha component (0-255) of the given ARGB color int, + * platform-independent replacement for android.graphics.Color.alpha(color). + */ + fun alpha(color: Int): Int = (color shr 24) and 0xFF + + /** + * Extracts the red component (0-255) of the given ARGB color int, + * platform-independent replacement for android.graphics.Color.red(color). + */ + fun red(color: Int): Int = (color shr 16) and 0xFF + + /** + * Extracts the green component (0-255) of the given ARGB color int, + * platform-independent replacement for android.graphics.Color.green(color). + */ + fun green(color: Int): Int = (color shr 8) and 0xFF + + /** + * Extracts the blue component (0-255) of the given ARGB color int, + * platform-independent replacement for android.graphics.Color.blue(color). + */ + fun blue(color: Int): Int = color and 0xFF } diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/DecimalFormatting.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/DecimalFormatting.kt new file mode 100644 index 00000000..a2424199 --- /dev/null +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/DecimalFormatting.kt @@ -0,0 +1,37 @@ +package info.appdev.charting.utils + +import kotlin.math.abs +import kotlin.math.pow +import kotlin.math.roundToLong + +/** + * Formats [value] rounded to the given number of decimal [digits], grouping the integer part + * with thousands separators. Platform-independent replacement for the + * `java.text.DecimalFormat`-based formatting previously used by value formatters + * (e.g. pattern "###,###,###,##0.00"). + */ +fun formatGroupedDecimal(value: Double, digits: Int): String { + val factor = 10.0.pow(digits) + val roundedTotal = (abs(value) * factor).roundToLong() + val intPart = if (digits == 0) roundedTotal else roundedTotal / factor.toLong() + val fracPart = if (digits == 0) 0L else roundedTotal % factor.toLong() + + val sb = StringBuilder() + if (value < 0 && roundedTotal != 0L) sb.append("-") + sb.append(groupThousands(intPart.toString())) + if (digits > 0) { + sb.append(".") + sb.append(fracPart.toString().padStart(digits, '0')) + } + return sb.toString() +} + +private fun groupThousands(digitsString: String): String { + val length = digitsString.length + val sb = StringBuilder() + for (i in digitsString.indices) { + if (i > 0 && (length - i) % 3 == 0) sb.append(",") + sb.append(digitsString[i]) + } + return sb.toString() +} diff --git a/chartLibCore/src/commonTest/kotlin/info/appdev/charting/formatter/DefaultValueFormatterTest.kt b/chartLibCore/src/commonTest/kotlin/info/appdev/charting/formatter/DefaultValueFormatterTest.kt new file mode 100644 index 00000000..51702423 --- /dev/null +++ b/chartLibCore/src/commonTest/kotlin/info/appdev/charting/formatter/DefaultValueFormatterTest.kt @@ -0,0 +1,44 @@ +package info.appdev.charting.formatter + +import kotlin.test.Test +import kotlin.test.assertEquals + +class DefaultValueFormatterTest { + + @Test + fun formatsWithZeroDigits() { + val formatter = DefaultValueFormatter(0) + assertEquals("42", formatter.getFormattedValue(42f, null, 0, null)) + assertEquals("0", formatter.getFormattedValue(0f, null, 0, null)) + assertEquals("-7", formatter.getFormattedValue(-7f, null, 0, null)) + } + + @Test + fun formatsWithDecimalDigits() { + val formatter = DefaultValueFormatter(2) + assertEquals("1.50", formatter.getFormattedValue(1.5f, null, 0, null)) + assertEquals("0.10", formatter.getFormattedValue(0.1f, null, 0, null)) + assertEquals("-3.25", formatter.getFormattedValue(-3.25f, null, 0, null)) + } + + @Test + fun groupsThousands() { + val formatter = DefaultValueFormatter(1) + assertEquals("1,234.5", formatter.getFormattedValue(1234.5f, null, 0, null)) + assertEquals("1,000,000.0", formatter.getFormattedValue(1_000_000f, null, 0, null)) + } + + @Test + fun negativeZeroDoesNotKeepMinusSign() { + val formatter = DefaultValueFormatter(0) + assertEquals("0", formatter.getFormattedValue(-0.1f, null, 0, null)) + } + + @Test + fun setupUpdatesDecimalDigits() { + val formatter = DefaultValueFormatter(1) + formatter.setup(3) + assertEquals(3, formatter.decimalDigits) + assertEquals("2.000", formatter.getFormattedValue(2f, null, 0, null)) + } +} From 13ba48323326a6944abf48385fbba34f70804e90 Mon Sep 17 00:00:00 2001 From: Hannes Achleitner Date: Sun, 27 Sep 2026 09:08:09 +0200 Subject: [PATCH 10/22] Step A.5 (axes + limit lines): migrate ComponentBase/AxisBase/XAxis/YAxis/LimitLine/LimitRange to chartLibCore Moves the axis-component family and its coupled axis-value formatter cluster into the platform-agnostic chartLibCore module, following the same extension-function extraction pattern established in Step A.3/A.6 for Paint/Context-dependent methods. - ComponentBase: Typeface? -> common ChartTypeface?, Color.BLACK -> ColorTemplate.BLACK, dropped @ColorInt. - AxisBase: Color.GRAY -> ColorTemplate.GRAY (new constant), axisLineDashPathEffect/gridDashPathEffect -> common DashEffect?, Timber.e -> println, dropped @ColorInt. The Paint-dependent getLongestLabel(p: Paint?) was extracted into a new Android-only extension function (chartLib/components/AxisBaseAndroid.kt); the no-arg longestLabel property (pure string comparison) stayed on the common class. - YAxis: Color.GRAY -> ColorTemplate.GRAY, dropped @ColorInt. The two Paint-dependent methods getRequiredWidthSpace/getRequiredHeightSpace were extracted into chartLib/components/YAxisAndroid.kt. XAxis needed no changes. - LimitLine/LimitRange: Color.rgb(...) -> ColorTemplate.argb(...), Paint.Style? -> common PaintStyle?, DashPathEffect? -> common DashEffect?, dropped @ColorInt. - Circular-dependency slice: AxisBase, IAxisValueFormatter and DefaultAxisValueFormatter had to move together atomically since AxisBase.valueFormatter references DefaultAxisValueFormatter directly, and both formatter types take an AxisBase? parameter. - DefaultAxisValueFormatter/PercentFormatter: replaced java.text.DecimalFormat with the shared chartLibCore/utils/DecimalFormatting.kt formatGroupedDecimal helper. PercentFormatter's unused DecimalFormat-based constructor overload (zero callers) was replaced with PercentFormatter(decimalDigits: Int) - a disclosed breaking change. - LargeValueFormatter: manual Kotlin rewrite of the DecimalFormat("###E00") engineering-notation algorithm (exponent forced to a multiple of 3, mantissa rounded to 3 significant digits, trailing zeros stripped, carry-over handled). Verified against the existing ~25-assertion LargeValueFormatterTest (moved to chartLibCore/commonTest and ported to kotlin.test) - caught and fixed a floor-division bug during verification. - Updated renderer call sites (XAxisRenderer, YAxisRenderer, XAxisRendererHorizontalBarChart, YAxisRendererHorizontalBarChart, YAxisRendererRadarChart, BarLineChartBase, HorizontalBarChart) for the DashEffect/PaintStyle conversions and the new extension-function imports. Legend/LegendEntry/Description/IMarker/MarkerImage/MarkerView remain in chartLib for a follow-up slice. Verified: chartLibCore:allTests, chartLib:compileDebugKotlin, full ./gradlew test, chartLibCompose:assembleDebug, app:assembleDebug all green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- KotlinMultiplatform.md | 102 +++++++++++- .../charting/charts/BarLineChartBase.kt | 2 + .../charting/charts/HorizontalBarChart.kt | 1 + .../charting/components/AxisBaseAndroid.kt | 31 ++++ .../charting/components/YAxisAndroid.kt | 43 +++++ .../formatter/DefaultAxisValueFormatter.kt | 32 ---- .../charting/formatter/LargeValueFormatter.kt | 81 ---------- .../appdev/charting/renderer/XAxisRenderer.kt | 10 +- .../XAxisRendererHorizontalBarChart.kt | 6 +- .../appdev/charting/renderer/YAxisRenderer.kt | 12 +- .../YAxisRendererHorizontalBarChart.kt | 6 +- .../renderer/YAxisRendererRadarChart.kt | 3 +- .../appdev/charting/components/AxisBase.kt | 54 ++----- .../charting/components/ComponentBase.kt | 10 +- .../appdev/charting/components/LimitLine.kt | 16 +- .../appdev/charting/components/LimitRange.kt | 19 +-- .../info/appdev/charting/components/XAxis.kt | 0 .../info/appdev/charting/components/YAxis.kt | 40 +---- .../formatter/DefaultAxisValueFormatter.kt | 18 +++ .../charting/formatter/IAxisValueFormatter.kt | 0 .../formatter/IndexAxisValueFormatter.kt | 0 .../charting/formatter/LargeValueFormatter.kt | 149 ++++++++++++++++++ .../charting/formatter/PercentFormatter.kt | 25 ++- .../appdev/charting/utils/ColorTemplate.kt | 5 + .../formatter}/LargeValueFormatterTest.kt | 59 ++++--- 25 files changed, 447 insertions(+), 277 deletions(-) create mode 100644 chartLib/src/main/kotlin/info/appdev/charting/components/AxisBaseAndroid.kt create mode 100644 chartLib/src/main/kotlin/info/appdev/charting/components/YAxisAndroid.kt delete mode 100644 chartLib/src/main/kotlin/info/appdev/charting/formatter/DefaultAxisValueFormatter.kt delete mode 100644 chartLib/src/main/kotlin/info/appdev/charting/formatter/LargeValueFormatter.kt rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/components/AxisBase.kt (91%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/components/ComponentBase.kt (90%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/components/LimitLine.kt (89%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/components/LimitRange.kt (90%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/components/XAxis.kt (100%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/components/YAxis.kt (81%) create mode 100644 chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/DefaultAxisValueFormatter.kt rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/formatter/IAxisValueFormatter.kt (100%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/formatter/IndexAxisValueFormatter.kt (100%) create mode 100644 chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/LargeValueFormatter.kt rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/formatter/PercentFormatter.kt (58%) rename {chartLib/src/test/kotlin/info/appdev/charting/test => chartLibCore/src/commonTest/kotlin/info/appdev/charting/formatter}/LargeValueFormatterTest.kt (59%) diff --git a/KotlinMultiplatform.md b/KotlinMultiplatform.md index 27ec8132..1402e9e7 100644 --- a/KotlinMultiplatform.md +++ b/KotlinMultiplatform.md @@ -486,7 +486,107 @@ lint Existing custom lint rules (unchanged) `IValueFormatter`/`IFillFormatter` no longer need an Android-only `ViewPortHandler`/`LineDataProvider`), most of Step A.4 (`formatter/` package, most of `highlight/`), and Step A.5 groundwork. -- [ ] **Step A.5 — Migrate `components/` (axes, legend, limit lines).** +- [x] **Step A.5 (axes + limit lines) — `components/` + (`ComponentBase`/`AxisBase`/`XAxis`/`YAxis`/`LimitLine`/`LimitRange`) + + the `AxisBase`-coupled formatter cluster + (`IAxisValueFormatter`/`DefaultAxisValueFormatter`/`IndexAxisValueFormatter`/ + `LargeValueFormatter`/`PercentFormatter`) fully migrated to `chartLibCore` + commonMain. `Legend`/`LegendEntry`/`Description`/`IMarker`/`MarkerImage`/ + `MarkerView` remain in `chartLib` for a follow-up slice.** + - **Circular-dependency slice:** `AxisBase.valueFormatter` references + `DefaultAxisValueFormatter` directly, and `IAxisValueFormatter`/ + `DefaultAxisValueFormatter` both take an `axis: AxisBase?` parameter — + so `AxisBase` + `IAxisValueFormatter` + `DefaultAxisValueFormatter` had to + move together as one atomic unit (can't be split across separate commits). + - `ComponentBase.kt`: `Typeface?` → common `ChartTypeface?` (same + zero-cost-typealias pattern as `IDataSet.valueTypeface` from Step A.3, + so every existing `paintX.typeface = someComponent.typeface` call site at + chartLib render boundaries stays source-compatible unchanged); + `Color.BLACK` → `ColorTemplate.BLACK`; dropped `@ColorInt`. + - `AxisBase.kt`: `Color.GRAY` → `ColorTemplate.GRAY` (new constant added to + `chartLibCore/utils/ColorTemplate.kt`, `argb(128, 128, 128)`); + `axisLineDashPathEffect`/`gridDashPathEffect` switched from + `android.graphics.DashPathEffect?` to common `DashEffect?` (same pattern as + Step A.3's `IDataSet.formLineDashEffect`); `Timber.e(...)` → `println(...)` + in `addLimitLine`/`addLimitRange`; dropped `@ColorInt`. The one + `Paint`-dependent method, `getLongestLabel(p: Paint?)`, was extracted out of + the class body into a new Android-only extension function in + `chartLib/components/AxisBaseAndroid.kt` (`fun AxisBase.getLongestLabel(p: + Paint?): String`) since `Paint.measureText` has no multiplatform + equivalent; the no-arg `longestLabel` property (pure string-length + comparison, no `Paint`) stayed as a member on the common class. + - `YAxis.kt`: `Color.GRAY` → `ColorTemplate.GRAY`; dropped `@ColorInt`. The + two `Paint`-dependent methods, `getRequiredWidthSpace(p: Paint)`/ + `getRequiredHeightSpace(p: Paint)`, were extracted the same way into a new + `chartLib/components/YAxisAndroid.kt` (calling the also-Android-only + `PaintUtils.kt` `calcTextWidth`/`calcTextHeight` extensions and the new + `AxisBase.getLongestLabel(p)` extension). `XAxis.kt` needed zero changes + (already fully portable — only used the already-common + `convertDpToPixel`). + - `LimitLine.kt`/`LimitRange.kt`: `Color.rgb(...)` → + `ColorTemplate.argb(...)`; `Paint.Style?` → the already-existing common + `PaintStyle?` enum (from the earlier `ICandleDataSet`/`CandleDataSet` + slice); `DashPathEffect?` → common `DashEffect?`; dropped `@ColorInt`. + - Renderer call-site updates for the `DashEffect`→`DashPathEffect` and + `PaintStyle`→`Paint.Style` conversions (via the existing + `toAndroidDashPathEffect()`/`toAndroidPaintStyle()` converters): + `XAxisRenderer.kt`, `YAxisRenderer.kt`, + `XAxisRendererHorizontalBarChart.kt`, `YAxisRendererHorizontalBarChart.kt`, + `YAxisRendererRadarChart.kt` — all at their + `paintGrid.pathEffect =`/`paintAxisLine.pathEffect =`/ + `limitLinePaint.pathEffect =`/`limitRangePaint.pathEffect =`/ + `limitLinePaint.style =`/`limitRangePaint.style =` call sites. Also added + the new `getRequiredWidthSpace`/`getRequiredHeightSpace` extension-function + imports at their two call sites, `BarLineChartBase.kt` and + `HorizontalBarChart.kt`. + - **Formatter cluster** (now unblocked once `AxisBase` is common): + - `IAxisValueFormatter.kt`/`IndexAxisValueFormatter.kt`: moved unchanged + (no Android coupling). + - `DefaultAxisValueFormatter.kt`: dropped the `java.text.DecimalFormat` + field, rewritten using the existing shared + `chartLibCore/utils/DecimalFormatting.kt` helper + `formatGroupedDecimal(value, digits)` (same helper already used by + `DefaultValueFormatter`/`StackedValueFormatter` in Step A.3). + - `PercentFormatter.kt`: same `formatGroupedDecimal(value, 1)` rewrite; + the unused `PercentFormatter(format: DecimalFormat)` constructor overload + (zero callers repo-wide, confirmed via grep) was replaced with + `PercentFormatter(decimalDigits: Int)` — a disclosed breaking change, + consistent with this migration's established pattern of trading + Java-only APIs (`Serializable`, `Parcelable`, now `DecimalFormat`) for + KMP-portable equivalents. + - `LargeValueFormatter.kt`: the most involved rewrite — replaced + `DecimalFormat("###E00")`-based engineering-notation formatting with a + manual Kotlin algorithm: compute `floor(log10(|value|))` (self-corrected + for floating-point imprecision by checking neighbouring powers of ten), + floor-divide by 3 and re-multiply to get the engineering exponent + (nearest multiple of 3 ≤ the true exponent), compute the mantissa, + determine how many fractional digits are still needed to reach exactly + 3 significant digits total, round with carry-over handling (mantissa + rounding up to ≥1000 bumps the exponent bracket and recomputes), then + format and strip trailing zeros/the decimal point. Validated against a + real JDK `DecimalFormat("###E00")` (`/tmp/DFTest.java`) to empirically + confirm the exact rounding/formatting behavior before writing the + Kotlin port. **Caught and fixed one bug during verification:** the + initial port forgot to re-multiply the floor-divided exponent by 3 + (`floorDiv3(exponent)` instead of `floorDiv3(exponent) * 3`), which + surfaced immediately as a test failure (`1100f` → `"110"` instead of + `"1.1k"`) and was fixed before landing. + - **Test migration:** `chartLib/src/test/.../LargeValueFormatterTest.kt` + (JUnit4, ~25 assertions) moved to + `chartLibCore/src/commonTest/.../LargeValueFormatterTest.kt` and ported to + `kotlin.test` (`Test`/`assertEquals`) — now runs on every `chartLibCore` + target (Android host, desktop JVM, iOS simulator) instead of only the + Android JVM unit-test target. All ~25 assertions pass unchanged after the + rewrite (this was the regression check for the manual engineering-notation + algorithm). + - Verified: `chartLibCore:allTests` green (Android host + desktop JVM + iOS + simulator, including the ported `LargeValueFormatterTest`), + `chartLib:compileDebugKotlin`, full `./gradlew test`, + `chartLibCompose:assembleDebug`, `app:assembleDebug` all pass. + - **Now unblocked:** Step A.4's dataprovider-independent formatter cluster + (`IAxisValueFormatter` family) is fully migrated as part of this slice; + the `Legend`/`LegendEntry` components (Step A.5 continuation, not yet + started) can now build on the common `ComponentBase`. - [ ] **Step A.7 — New `chartLibComposeMultiplatform` module**: Compose Multiplatform renderers built on `chartLibCore` using `DrawScope`. diff --git a/chartLib/src/main/kotlin/info/appdev/charting/charts/BarLineChartBase.kt b/chartLib/src/main/kotlin/info/appdev/charting/charts/BarLineChartBase.kt index cd41e74f..9f6969ed 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/charts/BarLineChartBase.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/charts/BarLineChartBase.kt @@ -14,6 +14,8 @@ import info.appdev.charting.components.Legend.LegendVerticalAlignment import info.appdev.charting.components.XAxis.XAxisPosition import info.appdev.charting.components.YAxis import info.appdev.charting.components.YAxis.AxisDependency +import info.appdev.charting.components.getRequiredHeightSpace +import info.appdev.charting.components.getRequiredWidthSpace import info.appdev.charting.data.BarLineScatterCandleBubbleData import info.appdev.charting.data.BaseEntry import info.appdev.charting.data.EntryFloat diff --git a/chartLib/src/main/kotlin/info/appdev/charting/charts/HorizontalBarChart.kt b/chartLib/src/main/kotlin/info/appdev/charting/charts/HorizontalBarChart.kt index 62b4bf50..44ce166a 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/charts/HorizontalBarChart.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/charts/HorizontalBarChart.kt @@ -8,6 +8,7 @@ import info.appdev.charting.components.Legend.LegendOrientation import info.appdev.charting.components.Legend.LegendVerticalAlignment import info.appdev.charting.components.XAxis.XAxisPosition import info.appdev.charting.components.YAxis.AxisDependency +import info.appdev.charting.components.getRequiredHeightSpace import info.appdev.charting.data.BarEntryFloat import info.appdev.charting.data.EntryFloat import info.appdev.charting.highlight.Highlight diff --git a/chartLib/src/main/kotlin/info/appdev/charting/components/AxisBaseAndroid.kt b/chartLib/src/main/kotlin/info/appdev/charting/components/AxisBaseAndroid.kt new file mode 100644 index 00000000..7883d278 --- /dev/null +++ b/chartLib/src/main/kotlin/info/appdev/charting/components/AxisBaseAndroid.kt @@ -0,0 +1,31 @@ +package info.appdev.charting.components + +import android.graphics.Paint + +/** + * Returns the longest formatted label (in terms of px), this axis + * contains. If paint is null, then returns the longest formatted label + * (in terms of characters), this axis contains. + * + * Android-only extension since text width measurement requires + * `android.graphics.Paint`. + */ +fun AxisBase.getLongestLabel(p: Paint?): String { + if (p == null) { + return this.longestLabel + } + var longest: String? = "" + val max = 0f + + for (i in entries.indices) { + val text = getFormattedLabel(i) + if (text != null) { + val width = p.measureText(text) + if (max < width) { + longest = text + } + } + } + + return longest!! +} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/components/YAxisAndroid.kt b/chartLib/src/main/kotlin/info/appdev/charting/components/YAxisAndroid.kt new file mode 100644 index 00000000..f9294603 --- /dev/null +++ b/chartLib/src/main/kotlin/info/appdev/charting/components/YAxisAndroid.kt @@ -0,0 +1,43 @@ +package info.appdev.charting.components + +import android.graphics.Paint +import info.appdev.charting.utils.calcTextHeight +import info.appdev.charting.utils.calcTextWidth +import info.appdev.charting.utils.convertDpToPixel +import kotlin.math.max +import kotlin.math.min + +/** + * This is for normal (not horizontal) charts horizontal spacing. + * + * Android-only extension since text measurement requires `android.graphics.Paint`. + */ +fun YAxis.getRequiredWidthSpace(p: Paint): Float { + p.textSize = textSize + + val label = getLongestLabel(p) + var width = p.calcTextWidth(label).toFloat() + xOffset * 2f + + var minWidth = this.minWidth + var maxWidth = this.maxWidth + + if (minWidth > 0f) minWidth = minWidth.convertDpToPixel() + + if (maxWidth > 0f && maxWidth != Float.POSITIVE_INFINITY) maxWidth = maxWidth.convertDpToPixel() + + width = max(minWidth, min(width, if (maxWidth > 0.0) maxWidth else width)) + + return width +} + +/** + * This is for HorizontalBarChart vertical spacing. + * + * Android-only extension since text measurement requires `android.graphics.Paint`. + */ +fun YAxis.getRequiredHeightSpace(p: Paint): Float { + p.textSize = textSize + + val label = getLongestLabel(p) + return p.calcTextHeight(label).toFloat() + yOffset * 2f +} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/formatter/DefaultAxisValueFormatter.kt b/chartLib/src/main/kotlin/info/appdev/charting/formatter/DefaultAxisValueFormatter.kt deleted file mode 100644 index 06b3837d..00000000 --- a/chartLib/src/main/kotlin/info/appdev/charting/formatter/DefaultAxisValueFormatter.kt +++ /dev/null @@ -1,32 +0,0 @@ -package info.appdev.charting.formatter - -import info.appdev.charting.components.AxisBase -import java.text.DecimalFormat - -open class DefaultAxisValueFormatter(digits: Int) : IAxisValueFormatter { - /** - * decimal format for formatting - */ - protected var decimalFormat: DecimalFormat - /** - * The number of decimal digits this formatter uses or -1, if unspecified. - */ - var decimalDigits = 0 - protected set - - init { - decimalDigits = digits - val stringBuffer = StringBuffer() - for (i in 0 until digits) { - if (i == 0) - stringBuffer.append(".") - stringBuffer.append("0") - } - decimalFormat = DecimalFormat("###,###,###,##0$stringBuffer") - } - - override fun getFormattedValue(value: Float, axis: AxisBase?): String { - // avoid memory allocations here (for performance) - return decimalFormat.format(value.toDouble()) - } -} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/formatter/LargeValueFormatter.kt b/chartLib/src/main/kotlin/info/appdev/charting/formatter/LargeValueFormatter.kt deleted file mode 100644 index 4bdc9690..00000000 --- a/chartLib/src/main/kotlin/info/appdev/charting/formatter/LargeValueFormatter.kt +++ /dev/null @@ -1,81 +0,0 @@ -package info.appdev.charting.formatter - -import info.appdev.charting.components.AxisBase -import info.appdev.charting.data.EntryFloat -import info.appdev.charting.utils.ViewPortHandler -import java.text.DecimalFormat - -/** - * Predefined value-formatter that formats large numbers in a pretty way. - * Outputs: 856 = 856; 1000 = 1k; 5821 = 5.8k; 10500 = 10k; 101800 = 102k; - * 2000000 = 2m; 7800000 = 7.8m; 92150000 = 92m; 123200000 = 123m; 9999999 = - * 10m; 1000000000 = 1b; - * Special thanks to Roman Gromov - * (https://github.com/romangromov) for this piece of code. - */ -open class LargeValueFormatter() : IValueFormatter, IAxisValueFormatter { - - private var suffix = arrayOf( - "", "k", "m", "b", "t" - ) - private var maxLength = 5 - private val decimalFormat: DecimalFormat = DecimalFormat("###E00") - private var text = "" - - /** - * Creates a formatter that appends a specified text to the result string - * - * @param appendix a text that will be appended - */ - constructor(appendix: String) : this() { - text = appendix - } - - // IValueFormatter - override fun getFormattedValue(value: Float, entryFloat: EntryFloat?, dataSetIndex: Int, viewPortHandler: ViewPortHandler?): String { - return makePretty(value.toDouble()) + text - } - - // IAxisValueFormatter - override fun getFormattedValue(value: Float, axis: AxisBase?): String { - return makePretty(value.toDouble()) + text - } - - /** - * Set an appendix text to be added at the end of the formatted value. - */ - fun setAppendix(appendix: String) { - text = appendix - } - - /** - * Set custom suffix to be appended after the values. - * Default suffix: ["", "k", "m", "b", "t"] - * - * @param suffixArray new suffix - */ - fun setSuffix(suffixArray: Array) { - suffix = suffixArray - } - - fun setMaxLength(max: Int) { - maxLength = max - } - - /** - * Formats each number properly. Special thanks to Roman Gromov - * (https://github.com/romangromov) for this piece of code. - */ - private fun makePretty(number: Double): String { - var decimalFormat = decimalFormat.format(number) - val numericValue1 = Character.getNumericValue(decimalFormat[decimalFormat.length - 1]) - val numericValue2 = Character.getNumericValue(decimalFormat[decimalFormat.length - 2]) - val combined = Integer.valueOf(numericValue2.toString() + "" + numericValue1) - decimalFormat = decimalFormat.replace("E[0-9][0-9]".toRegex(), suffix[combined / 3]) - while (decimalFormat.length > maxLength || decimalFormat.matches("[0-9]+\\.[a-z]".toRegex())) { - decimalFormat = decimalFormat.substring(0, decimalFormat.length - 2) + decimalFormat.substring(decimalFormat.length - 1) - } - return decimalFormat - } - -} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/renderer/XAxisRenderer.kt b/chartLib/src/main/kotlin/info/appdev/charting/renderer/XAxisRenderer.kt index a68e0ba1..860e6a20 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/renderer/XAxisRenderer.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/renderer/XAxisRenderer.kt @@ -23,6 +23,8 @@ import info.appdev.charting.utils.calcTextSize import info.appdev.charting.utils.calcTextWidth import info.appdev.charting.utils.convertDpToPixel import info.appdev.charting.utils.copyInto +import info.appdev.charting.utils.toAndroidDashPathEffect +import info.appdev.charting.utils.toAndroidPaintStyle import info.appdev.charting.utils.drawXAxisValue import kotlin.math.roundToInt @@ -34,7 +36,7 @@ open class XAxisRenderer( protected fun setupGridPaint() { paintGrid.color = xAxis.gridColor paintGrid.strokeWidth = xAxis.gridLineWidth - paintGrid.pathEffect = xAxis.gridDashPathEffect + paintGrid.pathEffect = xAxis.gridDashPathEffect?.toAndroidDashPathEffect() } override fun computeAxis(min: Float, max: Float, inverted: Boolean) { @@ -144,7 +146,7 @@ open class XAxisRenderer( paintAxisLine.color = xAxis.axisLineColor paintAxisLine.strokeWidth = xAxis.axisLineWidth - paintAxisLine.pathEffect = xAxis.axisLineDashPathEffect + paintAxisLine.pathEffect = xAxis.axisLineDashPathEffect?.toAndroidDashPathEffect() if (xAxis.position == XAxisPosition.TOP || xAxis.position == XAxisPosition.TOP_INSIDE || xAxis.position == XAxisPosition.BOTH_SIDED) { canvas.drawLine( @@ -335,7 +337,7 @@ open class XAxisRenderer( limitLinePaint.style = Paint.Style.STROKE limitLinePaint.color = limitLine.lineColor limitLinePaint.strokeWidth = limitLine.lineWidth - limitLinePaint.pathEffect = limitLine.dashPathEffect + limitLinePaint.pathEffect = limitLine.dashPathEffect?.toAndroidDashPathEffect() canvas.drawPath(mLimitLinePath, limitLinePaint) } @@ -378,7 +380,7 @@ open class XAxisRenderer( // if drawing the limit-value label is enabled limitLine.label?.let { label -> if (label.isNotEmpty() && limitLine.isEnabled) { - limitLinePaint.style = limitLine.textStyle + limitLinePaint.style = limitLine.textStyle.toAndroidPaintStyle() limitLinePaint.pathEffect = null limitLinePaint.color = limitLine.textColor limitLinePaint.strokeWidth = 0.5f diff --git a/chartLib/src/main/kotlin/info/appdev/charting/renderer/XAxisRendererHorizontalBarChart.kt b/chartLib/src/main/kotlin/info/appdev/charting/renderer/XAxisRendererHorizontalBarChart.kt index 911a67f9..cb90e539 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/renderer/XAxisRendererHorizontalBarChart.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/renderer/XAxisRendererHorizontalBarChart.kt @@ -19,6 +19,8 @@ import info.appdev.charting.utils.calcTextHeight import info.appdev.charting.utils.calcTextSize import info.appdev.charting.utils.convertDpToPixel import info.appdev.charting.utils.copyInto +import info.appdev.charting.utils.toAndroidDashPathEffect +import info.appdev.charting.utils.toAndroidPaintStyle import kotlin.math.roundToInt @Suppress("MemberVisibilityCanBePrivate") @@ -234,7 +236,7 @@ open class XAxisRendererHorizontalBarChart( limitLinePaint.style = Paint.Style.STROKE limitLinePaint.color = limitLine.lineColor limitLinePaint.strokeWidth = limitLine.lineWidth - limitLinePaint.pathEffect = limitLine.dashPathEffect + limitLinePaint.pathEffect = limitLine.dashPathEffect?.toAndroidDashPathEffect() pts[1] = limitLine.limit @@ -251,7 +253,7 @@ open class XAxisRendererHorizontalBarChart( // if drawing the limit-value label is enabled if (label != null && label != "") { - limitLinePaint.style = limitLine.textStyle + limitLinePaint.style = limitLine.textStyle.toAndroidPaintStyle() limitLinePaint.pathEffect = null limitLinePaint.color = limitLine.textColor limitLinePaint.strokeWidth = 0.5f diff --git a/chartLib/src/main/kotlin/info/appdev/charting/renderer/YAxisRenderer.kt b/chartLib/src/main/kotlin/info/appdev/charting/renderer/YAxisRenderer.kt index 84a3c0a2..6668277f 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/renderer/YAxisRenderer.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/renderer/YAxisRenderer.kt @@ -17,6 +17,8 @@ import info.appdev.charting.utils.ViewPortHandler import info.appdev.charting.utils.calcTextHeight import info.appdev.charting.utils.convertDpToPixel import info.appdev.charting.utils.copyInto +import info.appdev.charting.utils.toAndroidDashPathEffect +import info.appdev.charting.utils.toAndroidPaintStyle open class YAxisRenderer( viewPortHandler: ViewPortHandler, @@ -148,7 +150,7 @@ open class YAxisRenderer( paintGrid.color = yAxis.gridColor paintGrid.strokeWidth = yAxis.gridLineWidth - paintGrid.pathEffect = yAxis.gridDashPathEffect + paintGrid.pathEffect = yAxis.gridDashPathEffect?.toAndroidDashPathEffect() val gridLinePath = renderGridLinesPath gridLinePath.reset() @@ -292,7 +294,7 @@ open class YAxisRenderer( limitLinePaint.style = Paint.Style.STROKE limitLinePaint.color = limitLine.lineColor limitLinePaint.strokeWidth = limitLine.lineWidth - limitLinePaint.pathEffect = limitLine.dashPathEffect + limitLinePaint.pathEffect = limitLine.dashPathEffect?.toAndroidDashPathEffect() pts[1] = limitLine.limit @@ -309,7 +311,7 @@ open class YAxisRenderer( // if drawing the limit-value label is enabled if (label != null && label != "") { - limitLinePaint.style = limitLine.textStyle + limitLinePaint.style = limitLine.textStyle.toAndroidPaintStyle() limitLinePaint.pathEffect = null limitLinePaint.color = limitLine.textColor limitLinePaint.typeface = limitLine.typeface @@ -393,7 +395,7 @@ open class YAxisRenderer( limitRangePaint.style = Paint.Style.STROKE limitRangePaint.color = limitRange.lineColor limitRangePaint.strokeWidth = limitRange.lineWidth - limitRangePaint.pathEffect = limitRange.dashPathEffect + limitRangePaint.pathEffect = limitRange.dashPathEffect?.toAndroidDashPathEffect() limitRangePaintFill.style = Paint.Style.FILL limitRangePaintFill.color = limitRange.rangeColor @@ -431,7 +433,7 @@ open class YAxisRenderer( // if drawing the limit-value label is enabled if (label != null && label != "") { - limitRangePaint.style = limitRange.textStyle + limitRangePaint.style = limitRange.textStyle.toAndroidPaintStyle() limitRangePaint.pathEffect = null limitRangePaint.color = limitRange.textColor limitRangePaint.typeface = limitRange.typeface diff --git a/chartLib/src/main/kotlin/info/appdev/charting/renderer/YAxisRendererHorizontalBarChart.kt b/chartLib/src/main/kotlin/info/appdev/charting/renderer/YAxisRendererHorizontalBarChart.kt index 1766bd96..0f523730 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/renderer/YAxisRendererHorizontalBarChart.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/renderer/YAxisRendererHorizontalBarChart.kt @@ -16,6 +16,8 @@ import info.appdev.charting.utils.ViewPortHandler import info.appdev.charting.utils.calcTextHeight import info.appdev.charting.utils.convertDpToPixel import info.appdev.charting.utils.copyInto +import info.appdev.charting.utils.toAndroidDashPathEffect +import info.appdev.charting.utils.toAndroidPaintStyle @Suppress("MemberVisibilityCanBePrivate") open class YAxisRendererHorizontalBarChart( @@ -261,7 +263,7 @@ open class YAxisRendererHorizontalBarChart( limitLinePaint.style = Paint.Style.STROKE limitLinePaint.color = limitLine.lineColor - limitLinePaint.pathEffect = limitLine.dashPathEffect + limitLinePaint.pathEffect = limitLine.dashPathEffect?.toAndroidDashPathEffect() limitLinePaint.strokeWidth = limitLine.lineWidth canvas.drawPath(limitLinePath, limitLinePaint) @@ -271,7 +273,7 @@ open class YAxisRendererHorizontalBarChart( // if drawing the limit-value label is enabled if (label != null && label != "") { - limitLinePaint.style = limitLine.textStyle + limitLinePaint.style = limitLine.textStyle.toAndroidPaintStyle() limitLinePaint.pathEffect = null limitLinePaint.color = limitLine.textColor limitLinePaint.typeface = limitLine.typeface diff --git a/chartLib/src/main/kotlin/info/appdev/charting/renderer/YAxisRendererRadarChart.kt b/chartLib/src/main/kotlin/info/appdev/charting/renderer/YAxisRendererRadarChart.kt index 19be5988..fa6e8314 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/renderer/YAxisRendererRadarChart.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/renderer/YAxisRendererRadarChart.kt @@ -14,6 +14,7 @@ import kotlin.math.floor import kotlin.math.log10 import kotlin.math.nextUp import kotlin.math.pow +import info.appdev.charting.utils.toAndroidDashPathEffect class YAxisRendererRadarChart( viewPortHandler: ViewPortHandler, @@ -190,7 +191,7 @@ class YAxisRendererRadarChart( if (!limitLine.isEnabled) continue limitLinePaint.color = limitLine.lineColor - limitLinePaint.pathEffect = limitLine.dashPathEffect + limitLinePaint.pathEffect = limitLine.dashPathEffect?.toAndroidDashPathEffect() limitLinePaint.strokeWidth = limitLine.lineWidth val r = (limitLine.limit - chart.yChartMin) * factor diff --git a/chartLib/src/main/kotlin/info/appdev/charting/components/AxisBase.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/AxisBase.kt similarity index 91% rename from chartLib/src/main/kotlin/info/appdev/charting/components/AxisBase.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/AxisBase.kt index 19e7f2b1..ff2e78c0 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/components/AxisBase.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/AxisBase.kt @@ -1,13 +1,10 @@ package info.appdev.charting.components -import android.graphics.Color -import android.graphics.DashPathEffect -import android.graphics.Paint -import androidx.annotation.ColorInt import info.appdev.charting.formatter.DefaultAxisValueFormatter import info.appdev.charting.formatter.IAxisValueFormatter +import info.appdev.charting.utils.ColorTemplate +import info.appdev.charting.utils.DashEffect import info.appdev.charting.utils.convertDpToPixel -import timber.log.Timber import kotlin.math.abs /** @@ -23,16 +20,14 @@ abstract class AxisBase : ComponentBase() { * The color of the grid lines for this axis (the horizontal lines * coming from each label). */ - @ColorInt - var gridColor = Color.GRAY + var gridColor = ColorTemplate.GRAY private var mGridLineWidth = 1f /** * Returns the color of the axis line (line alongside the axis). */ - @ColorInt - var axisLineColor = Color.GRAY + var axisLineColor = ColorTemplate.GRAY private var mAxisLineWidth = 1f @@ -105,13 +100,13 @@ abstract class AxisBase : ComponentBase() { /** * the path effect of the axis line that makes dashed lines possible */ - var axisLineDashPathEffect: DashPathEffect? = null + var axisLineDashPathEffect: DashEffect? = null private set /** * the path effect of the grid lines that makes dashed lines possible */ - var gridDashPathEffect: DashPathEffect? = null + var gridDashPathEffect: DashEffect? = null private set /** @@ -297,7 +292,7 @@ abstract class AxisBase : ComponentBase() { limitLines.add(limitLine) if (limitLines.size > 6) { - Timber.e("Warning! You have more than 6 LimitLines on your axis, do you really want that?") + println("Warning! You have more than 6 LimitLines on your axis, do you really want that?") } } @@ -308,7 +303,7 @@ abstract class AxisBase : ComponentBase() { limitRanges.add(l) if (limitRanges.size > 6) { - Timber.e("Warning! You have more than 6 LimitLines on your axis, do you really want that?") + println("Warning! You have more than 6 LimitLines on your axis, do you really want that?") } } @@ -357,31 +352,6 @@ abstract class AxisBase : ComponentBase() { return longest!! } - /** - * Returns the longest formatted label (in terms of px), this axis - * contains. - * If paint is null, then returns the longest formatted label (in terms of characters), this axis contains. - */ - fun getLongestLabel(p: Paint?): String { - if (p == null) { - return this.longestLabel - } - var longest: String? = "" - val max = 0f - - for (i in entries.indices) { - val text = getFormattedLabel(i) - if (text != null) { - val width = p.measureText(text) - if (max < width) { - longest = text - } - } - } - - return longest!! - } - fun getFormattedLabel(index: Int): String? { return if (index < 0 || index >= entries.size) "" @@ -421,7 +391,7 @@ abstract class AxisBase : ComponentBase() { * @param phase offset, in degrees (normally, use 0) */ fun enableGridDashedLine(lineLength: Float, spaceLength: Float, phase: Float) { - this.gridDashPathEffect = DashPathEffect( + this.gridDashPathEffect = DashEffect( floatArrayOf( lineLength, spaceLength ), phase @@ -435,7 +405,7 @@ abstract class AxisBase : ComponentBase() { * * @param effect the DashPathEffect */ - fun setGridDashedLine(effect: DashPathEffect?) { + fun setGridDashedLine(effect: DashEffect?) { this.gridDashPathEffect = effect } @@ -462,7 +432,7 @@ abstract class AxisBase : ComponentBase() { * @param phase offset, in degrees (normally, use 0) */ fun enableAxisLineDashedLine(lineLength: Float, spaceLength: Float, phase: Float) { - this.axisLineDashPathEffect = DashPathEffect(floatArrayOf(lineLength, spaceLength), phase) + this.axisLineDashPathEffect = DashEffect(floatArrayOf(lineLength, spaceLength), phase) } /** @@ -472,7 +442,7 @@ abstract class AxisBase : ComponentBase() { * * @param effect the DashPathEffect */ - fun setAxisLineDashedLine(effect: DashPathEffect?) { + fun setAxisLineDashedLine(effect: DashEffect?) { this.axisLineDashPathEffect = effect } diff --git a/chartLib/src/main/kotlin/info/appdev/charting/components/ComponentBase.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/ComponentBase.kt similarity index 90% rename from chartLib/src/main/kotlin/info/appdev/charting/components/ComponentBase.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/ComponentBase.kt index c83b5e92..0b1894fa 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/components/ComponentBase.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/ComponentBase.kt @@ -1,8 +1,7 @@ package info.appdev.charting.components -import android.graphics.Color -import android.graphics.Typeface -import androidx.annotation.ColorInt +import info.appdev.charting.utils.ChartTypeface +import info.appdev.charting.utils.ColorTemplate import info.appdev.charting.utils.convertDpToPixel /** @@ -28,7 +27,7 @@ abstract class ComponentBase { /** * the typeface used for the labels */ - var typeface: Typeface? = null + var typeface: ChartTypeface? = null /** * the text size of the labels @@ -38,8 +37,7 @@ abstract class ComponentBase { /** * the text color to use for the labels */ - @ColorInt - open var textColor: Int = Color.BLACK + open var textColor: Int = ColorTemplate.BLACK /** * Returns the used offset on the x-axis for drawing the axis or legend diff --git a/chartLib/src/main/kotlin/info/appdev/charting/components/LimitLine.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/LimitLine.kt similarity index 89% rename from chartLib/src/main/kotlin/info/appdev/charting/components/LimitLine.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/LimitLine.kt index 1a24589b..21094874 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/components/LimitLine.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/LimitLine.kt @@ -1,9 +1,8 @@ package info.appdev.charting.components -import android.graphics.Color -import android.graphics.DashPathEffect -import android.graphics.Paint -import androidx.annotation.ColorInt +import info.appdev.charting.utils.ColorTemplate +import info.appdev.charting.utils.DashEffect +import info.appdev.charting.utils.PaintStyle import info.appdev.charting.utils.convertDpToPixel /** @@ -19,17 +18,16 @@ class LimitLine : ComponentBase { private var mLineWidth = 2f /** the color of the limit line */ - @ColorInt - var lineColor: Int = Color.rgb(237, 91, 91) + var lineColor: Int = ColorTemplate.argb(237, 91, 91) /** the style of the label text */ - var textStyle: Paint.Style? = Paint.Style.FILL_AND_STROKE + var textStyle: PaintStyle? = PaintStyle.FILL_AND_STROKE /** label string that is drawn next to the limit line */ var label: String? = "" /** the path effect of this LimitLine that makes dashed lines possible */ - var dashPathEffect: DashPathEffect? = null + var dashPathEffect: DashEffect? = null private set /** @@ -86,7 +84,7 @@ class LimitLine : ComponentBase { * @param phase offset, in degrees (normally, use 0) */ fun enableDashedLine(lineLength: Float, spaceLength: Float, phase: Float) { - this.dashPathEffect = DashPathEffect( + this.dashPathEffect = DashEffect( floatArrayOf( lineLength, spaceLength ), phase diff --git a/chartLib/src/main/kotlin/info/appdev/charting/components/LimitRange.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/LimitRange.kt similarity index 90% rename from chartLib/src/main/kotlin/info/appdev/charting/components/LimitRange.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/LimitRange.kt index e69f15c4..e38b652e 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/components/LimitRange.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/LimitRange.kt @@ -1,10 +1,9 @@ package info.appdev.charting.components -import android.graphics.Color -import android.graphics.DashPathEffect -import android.graphics.Paint -import androidx.annotation.ColorInt import info.appdev.charting.components.LimitLine.LimitLabelPosition +import info.appdev.charting.utils.ColorTemplate +import info.appdev.charting.utils.DashEffect +import info.appdev.charting.utils.PaintStyle import info.appdev.charting.utils.convertDpToPixel /** @@ -41,20 +40,18 @@ class LimitRange : ComponentBase { /** * the color of the limit line */ - @ColorInt - var lineColor: Int = Color.rgb(237, 91, 91) + var lineColor: Int = ColorTemplate.argb(237, 91, 91) /** * the color of the Range */ - @ColorInt - var rangeColor: Int = Color.rgb(128, 128, 128) + var rangeColor: Int = ColorTemplate.argb(128, 128, 128) /** * Sets the color of the value-text that is drawn next to the LimitLine. * Default: Paint.Style.FILL_AND_STROKE */ - var textStyle: Paint.Style? = Paint.Style.FILL + var textStyle: PaintStyle? = PaintStyle.FILL /** * Sets the label that is drawn next to the limit line. Provide "" if no @@ -65,7 +62,7 @@ class LimitRange : ComponentBase { /** * the path effect of this LimitLine that makes dashed lines possible */ - var dashPathEffect: DashPathEffect? = null + var dashPathEffect: DashEffect? = null private set /** @@ -122,7 +119,7 @@ class LimitRange : ComponentBase { * @param phase offset, in degrees (normally, use 0) */ fun enableDashedLine(lineLength: Float, spaceLength: Float, phase: Float) { - this.dashPathEffect = DashPathEffect( + this.dashPathEffect = DashEffect( floatArrayOf( lineLength, spaceLength ), phase diff --git a/chartLib/src/main/kotlin/info/appdev/charting/components/XAxis.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/XAxis.kt similarity index 100% rename from chartLib/src/main/kotlin/info/appdev/charting/components/XAxis.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/XAxis.kt diff --git a/chartLib/src/main/kotlin/info/appdev/charting/components/YAxis.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/YAxis.kt similarity index 81% rename from chartLib/src/main/kotlin/info/appdev/charting/components/YAxis.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/YAxis.kt index 4d869ea9..e08c68c4 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/components/YAxis.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/YAxis.kt @@ -1,10 +1,6 @@ package info.appdev.charting.components -import android.graphics.Color -import android.graphics.Paint -import androidx.annotation.ColorInt -import info.appdev.charting.utils.calcTextHeight -import info.appdev.charting.utils.calcTextWidth +import info.appdev.charting.utils.ColorTemplate import info.appdev.charting.utils.convertDpToPixel import kotlin.math.abs import kotlin.math.max @@ -50,8 +46,7 @@ open class YAxis : AxisBase { /** * Color of the zero line */ - @ColorInt - var zeroLineColor: Int = Color.GRAY + var zeroLineColor: Int = ColorTemplate.GRAY /** * Width of the zero line in pixels @@ -132,37 +127,6 @@ open class YAxis : AxisBase { this.mZeroLineWidth = width.convertDpToPixel() } - /** - * This is for normal (not horizontal) charts horizontal spacing. - */ - fun getRequiredWidthSpace(p: Paint): Float { - p.textSize = mTextSize - - val label = getLongestLabel(p) - var width = p.calcTextWidth(label).toFloat() + xOffset * 2f - - var minWidth = this.minWidth - var maxWidth = this.maxWidth - - if (minWidth > 0f) minWidth = minWidth.convertDpToPixel() - - if (maxWidth > 0f && maxWidth != Float.POSITIVE_INFINITY) maxWidth = maxWidth.convertDpToPixel() - - width = max(minWidth, min(width, if (maxWidth > 0.0) maxWidth else width)) - - return width - } - - /** - * This is for HorizontalBarChart vertical spacing. - */ - fun getRequiredHeightSpace(p: Paint): Float { - p.textSize = mTextSize - - val label = getLongestLabel(p) - return p.calcTextHeight(label).toFloat() + yOffset * 2f - } - /** * Returns true if this axis needs horizontal offset, false if no offset is needed. */ diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/DefaultAxisValueFormatter.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/DefaultAxisValueFormatter.kt new file mode 100644 index 00000000..b14a367a --- /dev/null +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/DefaultAxisValueFormatter.kt @@ -0,0 +1,18 @@ +package info.appdev.charting.formatter + +import info.appdev.charting.components.AxisBase +import info.appdev.charting.utils.formatGroupedDecimal + +open class DefaultAxisValueFormatter(digits: Int) : IAxisValueFormatter { + /** + * The number of decimal digits this formatter uses or -1, if unspecified. + */ + var decimalDigits = digits + protected set + + override fun getFormattedValue(value: Float, axis: AxisBase?): String { + // avoid memory allocations here (for performance) + return formatGroupedDecimal(value.toDouble(), decimalDigits) + } +} + diff --git a/chartLib/src/main/kotlin/info/appdev/charting/formatter/IAxisValueFormatter.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/IAxisValueFormatter.kt similarity index 100% rename from chartLib/src/main/kotlin/info/appdev/charting/formatter/IAxisValueFormatter.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/IAxisValueFormatter.kt diff --git a/chartLib/src/main/kotlin/info/appdev/charting/formatter/IndexAxisValueFormatter.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/IndexAxisValueFormatter.kt similarity index 100% rename from chartLib/src/main/kotlin/info/appdev/charting/formatter/IndexAxisValueFormatter.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/IndexAxisValueFormatter.kt diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/LargeValueFormatter.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/LargeValueFormatter.kt new file mode 100644 index 00000000..4d49a092 --- /dev/null +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/LargeValueFormatter.kt @@ -0,0 +1,149 @@ +package info.appdev.charting.formatter + +import info.appdev.charting.components.AxisBase +import info.appdev.charting.data.EntryFloat +import info.appdev.charting.utils.ViewPortHandler +import kotlin.math.abs +import kotlin.math.floor +import kotlin.math.log10 +import kotlin.math.pow +import kotlin.math.roundToLong + +/** + * Predefined value-formatter that formats large numbers in a pretty way. + * Outputs: 856 = 856; 1000 = 1k; 5821 = 5.8k; 10500 = 10k; 101800 = 102k; + * 2000000 = 2m; 7800000 = 7.8m; 92150000 = 92m; 123200000 = 123m; 9999999 = + * 10m; 1000000000 = 1b; + * Special thanks to Roman Gromov + * (https://github.com/romangromov) for this piece of code. + */ +open class LargeValueFormatter() : IValueFormatter, IAxisValueFormatter { + + private var suffix = arrayOf( + "", "k", "m", "b", "t" + ) + private var maxLength = 5 + private var text = "" + + /** + * Creates a formatter that appends a specified text to the result string + * + * @param appendix a text that will be appended + */ + constructor(appendix: String) : this() { + text = appendix + } + + // IValueFormatter + override fun getFormattedValue(value: Float, entryFloat: EntryFloat?, dataSetIndex: Int, viewPortHandler: ViewPortHandler?): String { + return makePretty(value.toDouble()) + text + } + + // IAxisValueFormatter + override fun getFormattedValue(value: Float, axis: AxisBase?): String { + return makePretty(value.toDouble()) + text + } + + /** + * Set an appendix text to be added at the end of the formatted value. + */ + fun setAppendix(appendix: String) { + text = appendix + } + + /** + * Set custom suffix to be appended after the values. + * Default suffix: ["", "k", "m", "b", "t"] + * + * @param suffixArray new suffix + */ + fun setSuffix(suffixArray: Array) { + suffix = suffixArray + } + + fun setMaxLength(max: Int) { + maxLength = max + } + + /** + * Formats the number using engineering notation (exponent forced to a multiple of 3) with + * the mantissa rounded to 3 significant digits, then replaces the exponent with the + * corresponding suffix (k, m, b, t, ...). Platform-independent replacement of the previous + * `java.text.DecimalFormat("###E00")`-based implementation. + */ + private fun makePretty(number: Double): String { + if (number == 0.0) return "0" + + val negative = number < 0 + val absNumber = abs(number) + + // floor(log10(absNumber)), self-corrected for floating point imprecision + var exponent = floor(log10(absNumber)).toInt() + if (10.0.pow(exponent) > absNumber) exponent-- + if (10.0.pow(exponent + 1) <= absNumber) exponent++ + + // round down to the nearest (lower or equal) multiple of 3, engineering-notation style + var engineeringExponent = floorDiv3(exponent) * 3 + var mantissa = absNumber / 10.0.pow(engineeringExponent) + + // number of integer digits in the mantissa (1..3), used to know how many fractional + // digits are still needed to reach 3 significant digits in total + var integerDigits = digitCount(mantissa) + var fractionDigits = (3 - integerDigits).coerceAtLeast(0) + mantissa = roundTo(mantissa, fractionDigits) + + // rounding may have pushed the mantissa to 1000 or beyond (e.g. 999.999 -> 1000.0), + // in which case the exponent bracket needs to shift up by one step + if (mantissa >= 1000.0) { + engineeringExponent += 3 + mantissa /= 1000.0 + integerDigits = digitCount(mantissa) + fractionDigits = (3 - integerDigits).coerceAtLeast(0) + mantissa = roundTo(mantissa, fractionDigits) + } + + val suffixIndex = engineeringExponent / 3 + val suffixText = if (suffixIndex in suffix.indices) suffix[suffixIndex] else "" + + val mantissaString = stripTrailingZeros(formatFixed(mantissa, fractionDigits)) + + return (if (negative) "-" else "") + mantissaString + suffixText + } + + private fun floorDiv3(value: Int): Int { + val quotient = value / 3 + return if (value % 3 != 0 && value < 0) quotient - 1 else quotient + } + + private fun digitCount(mantissa: Double): Int { + var digits = 1 + var threshold = 10.0 + while (mantissa >= threshold) { + digits++ + threshold *= 10.0 + } + return digits + } + + private fun roundTo(value: Double, digits: Int): Double { + val factor = 10.0.pow(digits) + return (value * factor).roundToLong() / factor + } + + private fun formatFixed(value: Double, digits: Int): String { + if (digits <= 0) return value.roundToLong().toString() + + val factor = 10.0.pow(digits) + val scaled = (value * factor).roundToLong() + val intPart = scaled / factor.toLong() + val fracPart = scaled % factor.toLong() + return "$intPart." + fracPart.toString().padStart(digits, '0') + } + + private fun stripTrailingZeros(value: String): String { + if (!value.contains('.')) return value + var result = value.trimEnd('0') + if (result.endsWith('.')) result = result.dropLast(1) + return result + } +} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/formatter/PercentFormatter.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/PercentFormatter.kt similarity index 58% rename from chartLib/src/main/kotlin/info/appdev/charting/formatter/PercentFormatter.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/PercentFormatter.kt index e785c983..bd5a668e 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/formatter/PercentFormatter.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/formatter/PercentFormatter.kt @@ -3,35 +3,34 @@ package info.appdev.charting.formatter import info.appdev.charting.components.AxisBase import info.appdev.charting.data.EntryFloat import info.appdev.charting.utils.ViewPortHandler -import java.text.DecimalFormat +import info.appdev.charting.utils.formatGroupedDecimal /** * This IValueFormatter is just for convenience and simply puts a "%" sign after each value. (Recommended for PieChart) */ open class PercentFormatter : IValueFormatter, IAxisValueFormatter { - protected var decimalFormat: DecimalFormat + /** + * The number of decimal digits used when formatting the percentage value. + */ + protected var decimalDigits: Int = 1 - constructor() { - decimalFormat = DecimalFormat("###,###,##0.0") - } + constructor() /** - * Allow a custom decimal format + * Allow a custom number of decimal digits. */ - constructor(format: DecimalFormat) { - decimalFormat = format + constructor(decimalDigits: Int) { + this.decimalDigits = decimalDigits } // IValueFormatter override fun getFormattedValue(value: Float, entryFloat: EntryFloat?, dataSetIndex: Int, viewPortHandler: ViewPortHandler?): String? { - return decimalFormat.format(value.toDouble()) + " %" + return formatGroupedDecimal(value.toDouble(), decimalDigits) + " %" } // IAxisValueFormatter override fun getFormattedValue(value: Float, axis: AxisBase?): String { - return decimalFormat.format(value.toDouble()) + " %" + return formatGroupedDecimal(value.toDouble(), decimalDigits) + " %" } - - val decimalDigits: Int - get() = 1 } + diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ColorTemplate.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ColorTemplate.kt index 70aa8557..0c24896a 100644 --- a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ColorTemplate.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/ColorTemplate.kt @@ -108,6 +108,11 @@ object ColorTemplate { */ val BLACK: Int = argb(0, 0, 0) + /** + * Fully opaque gray, platform-independent replacement for android.graphics.Color.GRAY. + */ + val GRAY: Int = argb(128, 128, 128) + /** * Builds a fully opaque ARGB color int from the given red/green/blue components (0-255), * platform-independent replacement for android.graphics.Color.rgb(r, g, b). diff --git a/chartLib/src/test/kotlin/info/appdev/charting/test/LargeValueFormatterTest.kt b/chartLibCore/src/commonTest/kotlin/info/appdev/charting/formatter/LargeValueFormatterTest.kt similarity index 59% rename from chartLib/src/test/kotlin/info/appdev/charting/test/LargeValueFormatterTest.kt rename to chartLibCore/src/commonTest/kotlin/info/appdev/charting/formatter/LargeValueFormatterTest.kt index 48fd5dc4..d0b7f9be 100644 --- a/chartLib/src/test/kotlin/info/appdev/charting/test/LargeValueFormatterTest.kt +++ b/chartLibCore/src/commonTest/kotlin/info/appdev/charting/formatter/LargeValueFormatterTest.kt @@ -1,8 +1,7 @@ -package info.appdev.charting.test +package info.appdev.charting.formatter -import info.appdev.charting.formatter.LargeValueFormatter -import org.junit.Assert -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals class LargeValueFormatterTest { @Test @@ -10,82 +9,82 @@ class LargeValueFormatterTest { val formatter = LargeValueFormatter() val resultDouble = formatter.getFormattedValue(5.0.toFloat(), null) - Assert.assertEquals("5", resultDouble) + assertEquals("5", resultDouble) var result = formatter.getFormattedValue(5f, null) - Assert.assertEquals("5", result) + assertEquals("5", result) result = formatter.getFormattedValue(5.5f, null) - Assert.assertEquals("5.5", result) + assertEquals("5.5", result) result = formatter.getFormattedValue(50f, null) - Assert.assertEquals("50", result) + assertEquals("50", result) result = formatter.getFormattedValue(50.5f, null) - Assert.assertEquals("50.5", result) + assertEquals("50.5", result) result = formatter.getFormattedValue(500f, null) - Assert.assertEquals("500", result) + assertEquals("500", result) result = formatter.getFormattedValue(1100f, null) - Assert.assertEquals("1.1k", result) + assertEquals("1.1k", result) result = formatter.getFormattedValue(10000f, null) - Assert.assertEquals("10k", result) + assertEquals("10k", result) result = formatter.getFormattedValue(10500f, null) - Assert.assertEquals("10.5k", result) + assertEquals("10.5k", result) result = formatter.getFormattedValue(100000f, null) - Assert.assertEquals("100k", result) + assertEquals("100k", result) result = formatter.getFormattedValue(1000000f, null) - Assert.assertEquals("1m", result) + assertEquals("1m", result) result = formatter.getFormattedValue(1500000f, null) - Assert.assertEquals("1.5m", result) + assertEquals("1.5m", result) result = formatter.getFormattedValue(9500000f, null) - Assert.assertEquals("9.5m", result) + assertEquals("9.5m", result) result = formatter.getFormattedValue(22200000f, null) - Assert.assertEquals("22.2m", result) + assertEquals("22.2m", result) result = formatter.getFormattedValue(222000000f, null) - Assert.assertEquals("222m", result) + assertEquals("222m", result) result = formatter.getFormattedValue(1000000000f, null) - Assert.assertEquals("1b", result) + assertEquals("1b", result) result = formatter.getFormattedValue(9900000000f, null) - Assert.assertEquals("9.9b", result) + assertEquals("9.9b", result) result = formatter.getFormattedValue(99000000000f, null) - Assert.assertEquals("99b", result) + assertEquals("99b", result) result = formatter.getFormattedValue(99500000000f, null) - Assert.assertEquals("99.5b", result) + assertEquals("99.5b", result) result = formatter.getFormattedValue(999000000000f, null) - Assert.assertEquals("999b", result) + assertEquals("999b", result) result = formatter.getFormattedValue(1000000000000f, null) - Assert.assertEquals("1t", result) + assertEquals("1t", result) formatter.setSuffix(arrayOf("", "k", "m", "b", "t", "q")) // quadrillion support result = formatter.getFormattedValue(1000000000000000f, null) - Assert.assertEquals("1q", result) + assertEquals("1q", result) result = formatter.getFormattedValue(1100000000000000f, null) - Assert.assertEquals("1.1q", result) + assertEquals("1.1q", result) result = formatter.getFormattedValue(10000000000000000f, null) - Assert.assertEquals("10q", result) + assertEquals("10q", result) result = formatter.getFormattedValue(13300000000000000f, null) - Assert.assertEquals("13.3q", result) + assertEquals("13.3q", result) result = formatter.getFormattedValue(100000000000000000f, null) - Assert.assertEquals("100q", result) + assertEquals("100q", result) } } From fd9748ed42afabd394382a285d0015419b2ad7f5 Mon Sep 17 00:00:00 2001 From: Hannes Achleitner Date: Sun, 27 Sep 2026 09:21:34 +0200 Subject: [PATCH 11/22] Step A.5 (continuation): migrate Legend/LegendEntry/Description to chartLibCore Completes Step A.5 by moving the remaining portable components/ classes into chartLibCore commonMain. - LegendEntry: trivial move, dropped @ColorInt. - Legend: the bulk of the class (alignment/orientation enums, spacing properties, entries/extraEntries, setCustom/setExtra/resetCustom) needed no changes. The three Paint-dependent methods (getMaximumEntryWidth, getMaximumEntryHeight, and the ~110-line calculateDimensions layout algorithm) were extracted into a new Android-only extension file, chartLib/components/LegendAndroid.kt - the largest extension-function extraction in this migration so far, demonstrating the pattern scales to large multi-branch logic as long as every touched member is public (required switching two internal offset reads from the protected mXOffset/mYOffset fields to the existing public xOffset/yOffset accessors on ComponentBase). - Description: textAlign (android.graphics.Paint.Align?) replaced with a new common chartLibCore/utils/TextAlign.kt enum plus a new chartLib/utils/TextAlignAndroid.kt converter, following the same common-enum-plus-Android-converter pattern already used for PaintStyle/DashEffect. Updated the one render call site in Chart.kt. - LegendRenderer.kt: added the import for the new calculateDimensions extension function at its call site. - IMarker/MarkerImage/MarkerView confirmed permanently Android-only (Canvas/Context/Drawable/RelativeLayout coupling with no separable portable core) and left in chartLib. Step A.5 is now fully complete: all of components/ that has a genuinely portable core lives in chartLibCore. Verified: chartLibCore:allTests, chartLib:compileDebugKotlin, full ./gradlew test, chartLibCompose:assembleDebug, app:assembleDebug all green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- KotlinMultiplatform.md | 53 +- .../info/appdev/charting/charts/Chart.kt | 3 +- .../info/appdev/charting/components/Legend.kt | 493 ------------------ .../charting/components/LegendAndroid.kt | 246 +++++++++ .../charting/renderer/LegendRenderer.kt | 1 + .../appdev/charting/utils/TextAlignAndroid.kt | 15 + .../appdev/charting/components/Description.kt | 4 +- .../info/appdev/charting/components/Legend.kt | 260 +++++++++ .../appdev/charting/components/LegendEntry.kt | 4 +- .../info/appdev/charting/utils/TextAlign.kt | 12 + 10 files changed, 590 insertions(+), 501 deletions(-) delete mode 100644 chartLib/src/main/kotlin/info/appdev/charting/components/Legend.kt create mode 100644 chartLib/src/main/kotlin/info/appdev/charting/components/LegendAndroid.kt create mode 100644 chartLib/src/main/kotlin/info/appdev/charting/utils/TextAlignAndroid.kt rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/components/Description.kt (93%) create mode 100644 chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/Legend.kt rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/components/LegendEntry.kt (96%) create mode 100644 chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/TextAlign.kt diff --git a/KotlinMultiplatform.md b/KotlinMultiplatform.md index 1402e9e7..56ee7e7f 100644 --- a/KotlinMultiplatform.md +++ b/KotlinMultiplatform.md @@ -585,8 +585,57 @@ lint Existing custom lint rules (unchanged) `chartLibCompose:assembleDebug`, `app:assembleDebug` all pass. - **Now unblocked:** Step A.4's dataprovider-independent formatter cluster (`IAxisValueFormatter` family) is fully migrated as part of this slice; - the `Legend`/`LegendEntry` components (Step A.5 continuation, not yet - started) can now build on the common `ComponentBase`. + the `Legend`/`LegendEntry` components (Step A.5 continuation) are now + migrated too, see below. + +- [x] **Step A.5 (continuation) — `Legend`/`LegendEntry`/`Description` fully + migrated to `chartLibCore` commonMain; `IMarker`/`MarkerImage`/`MarkerView` + confirmed permanently Android-only (Canvas/Context/Drawable/RelativeLayout + coupling with no separable portable core) and left in `chartLib`.** + - `LegendEntry.kt`: trivial move, only dropped `@ColorInt`. + - `Legend.kt`: dropped `@ColorInt` (n/a — none present besides the + already-common enums); the bulk of the class (alignment/orientation + enums, spacing properties, `entries`/`extraEntries`, `setCustom`/ + `setExtra`/`resetCustom`) needed no changes at all. Three methods were + `Paint`-dependent and were extracted into a new Android-only extension + file, `chartLib/components/LegendAndroid.kt`: + `Legend.getMaximumEntryWidth(p: Paint)`, `Legend.getMaximumEntryHeight(p: + Paint)`, and — the largest and most involved extraction in this + migration so far — `Legend.calculateDimensions(labelPaint: Paint, + viewPortHandler: ViewPortHandler)` (the ~110-line horizontal/vertical + legend-layout algorithm). Unlike the smaller single/double-line + extractions in Step A.3/A.5 (`getLongestLabel`, `getRequiredWidthSpace`), + this shows the extension-function pattern scales to large, + multi-branch business logic as long as every field/property it touches + is `public` on the common class — required switching the two internal + offset reads at the end of `calculateDimensions` from the `protected` + `mXOffset`/`mYOffset` fields to the existing public `xOffset`/`yOffset` + accessors on `ComponentBase` (extension functions cannot see `protected` + members from outside the class hierarchy, same constraint documented in + Step A.3's `BaseDataSetAndroid.kt`). + - `Description.kt`: `textAlign: android.graphics.Paint.Align?` replaced + with a new common `chartLibCore/utils/TextAlign.kt` enum + (`LEFT`/`CENTER`/`RIGHT`) plus a new `chartLib/utils/TextAlignAndroid.kt` + converter (`TextAlign?.toAndroidAlign(default)`), following the same + common-enum-plus-Android-converter pattern already established for + `PaintStyle`/`DashEffect`. Only one render call site needed updating, + `Chart.kt`'s `drawDescription()` (`mDescPaint.textAlign = + description.textAlign.toAndroidAlign()`); grepped for other + `description.textAlign`/`Description(...)` usages repo-wide and found + none that set a custom alignment (only the no-op default). + - `LegendRenderer.kt` updated to import the new + `info.appdev.charting.components.calculateDimensions` extension + function at its one call site (member-call syntax unchanged, resolves + identically as an extension call). + - Verified: `chartLibCore:allTests` green, `chartLib:compileDebugKotlin`, + full `./gradlew test`, `chartLibCompose:assembleDebug`, + `app:assembleDebug` all pass. + - **Step A.5 is now considered fully complete**: all of `components/` + that has a genuinely portable core (axes, limit lines, legend, + description) lives in `chartLibCore`; the remainder + (`IMarker`/`MarkerImage`/`MarkerView`) is permanently Android-only by + design, matching the same category of decision already made for + `highlight/`'s dataprovider-coupled classes in Step A.4. - [ ] **Step A.7 — New `chartLibComposeMultiplatform` module**: Compose Multiplatform renderers built on `chartLibCore` using `DrawScope`. diff --git a/chartLib/src/main/kotlin/info/appdev/charting/charts/Chart.kt b/chartLib/src/main/kotlin/info/appdev/charting/charts/Chart.kt index e0717fc3..361284e3 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/charts/Chart.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/charts/Chart.kt @@ -39,6 +39,7 @@ import info.appdev.charting.utils.PointF import info.appdev.charting.utils.PointF.Companion.getInstance import info.appdev.charting.utils.SaveUtils.saveToGallery import info.appdev.charting.utils.SaveUtils.saveToPath +import info.appdev.charting.utils.toAndroidAlign import info.appdev.charting.utils.Utils import info.appdev.charting.utils.ViewPortHandler import info.appdev.charting.utils.convertDpToPixel @@ -357,7 +358,7 @@ abstract class Chart>> : ViewGroup, I mDescPaint.typeface = description.typeface mDescPaint.textSize = description.textSize mDescPaint.color = description.textColor - mDescPaint.textAlign = description.textAlign + mDescPaint.textAlign = description.textAlign.toAndroidAlign() val x: Float val y: Float diff --git a/chartLib/src/main/kotlin/info/appdev/charting/components/Legend.kt b/chartLib/src/main/kotlin/info/appdev/charting/components/Legend.kt deleted file mode 100644 index a42336df..00000000 --- a/chartLib/src/main/kotlin/info/appdev/charting/components/Legend.kt +++ /dev/null @@ -1,493 +0,0 @@ -package info.appdev.charting.components - -import android.graphics.Paint -import info.appdev.charting.utils.ColorTemplate -import info.appdev.charting.utils.DashEffect -import info.appdev.charting.utils.FSize -import info.appdev.charting.utils.ViewPortHandler -import info.appdev.charting.utils.calcTextHeight -import info.appdev.charting.utils.calcTextSize -import info.appdev.charting.utils.calcTextWidth -import info.appdev.charting.utils.convertDpToPixel -import info.appdev.charting.utils.getLineHeight -import info.appdev.charting.utils.getLineSpacing -import kotlin.math.max -import kotlin.math.min - -/** - * Class representing the legend of the chart. The legend will contain one entry - * per color and DataSet. Multiple colors in one DataSet are grouped together. - * The legend object is NOT available before setting data to the chart. - */ -class Legend() : ComponentBase() { - /** - * Kept as a nested type alias (rather than a nested enum) so that the actual enum can live - * in the platform-independent `chartLibCore` module while `Legend.LegendForm` keeps - * working unchanged for existing callers. - */ - typealias LegendForm = info.appdev.charting.components.LegendForm - - enum class LegendHorizontalAlignment { - LEFT, CENTER, RIGHT - } - - enum class LegendVerticalAlignment { - TOP, CENTER, BOTTOM - } - - enum class LegendOrientation { - HORIZONTAL, VERTICAL - } - - enum class LegendDirection { - LEFT_TO_RIGHT, RIGHT_TO_LEFT - } - - /** - * The legend entries array - */ - var entries: Array = arrayOf() - private set - - /** - * Entries that will be appended to the end of the auto calculated entries after calculating the legend. - * (if the legend has already been calculated, you will need to call notifyDataSetChanged() to let the changes take effect) - */ - var extraEntries: Array = arrayOf() - private set - - /** - * Are the legend labels/colors a custom value or auto calculated? If false, - * then it's auto, if true, then custom. default false (automatic legend) - */ - var isLegendCustom: Boolean = false - private set - - /** - * sets the horizontal alignment of the legend - */ - var horizontalAlignment: LegendHorizontalAlignment = LegendHorizontalAlignment.LEFT - - /** - * sets the vertical alignment of the legend - */ - var verticalAlignment: LegendVerticalAlignment = LegendVerticalAlignment.BOTTOM - - /** - * sets the orientation of the legend - */ - var orientation: LegendOrientation = LegendOrientation.HORIZONTAL - - /** - * returns whether the legend will draw inside the chart or outside - */ - var isDrawInsideEnabled: Boolean = false - private set - - /** - * the text direction for the legend - */ - var direction: LegendDirection = LegendDirection.LEFT_TO_RIGHT - - /** - * the shape/form the legend colors are drawn in - */ - var form: LegendForm = LegendForm.SQUARE - - /** - * the size of the legend forms/shapes - */ - var formSize: Float = 8f - - /** - * the size of the legend forms/shapes - */ - var formLineWidth: Float = 3f - - /** - * Line dash path effect used for shapes that consist of lines. - */ - var formLineDashEffect: DashEffect? = null - - /** - * the space between the legend entries on a horizontal axis, default 6f - */ - var xEntrySpace: Float = 6f - - /** - * the space between the legend entries on a vertical axis, default 5f - */ - var yEntrySpace: Float = 0f - - /** - * the space between the legend entries on a vertical axis, default 2f - * private float mYEntrySpace = 2f; / ** the space between the form and the - * actual label/text - */ - var formToTextSpace: Float = 5f - - /** - * the space that should be left between stacked forms - */ - var stackSpace: Float = 3f - - /** - * The maximum relative size out of the whole chart view. / If the legend is - * to the right/left of the chart, then this affects the width of the - * legend. / If the legend is to the top/bottom of the chart, then this - * affects the height of the legend. / If the legend is the center of the - * piechart, then this defines the size of the rectangular bounds out of the - * size of the "hole". / default: 0.95f (95%) - */ - var maxSizePercent: Float = 0.95f - - /** - * Constructor. Provide entries for the legend. - */ - constructor(entries: Array) : this() { - this.entries = entries - } - - /** - * This method sets the automatically computed colors for the legend. Use setCustom(...) to set custom colors. - */ - fun setEntries(entries: MutableList) { - this.entries = entries.toTypedArray() - } - - /** - * returns the maximum length in pixels across all legend labels + formsize - * + formtotextspace - * - * @param p the paint object used for rendering the text - */ - fun getMaximumEntryWidth(p: Paint): Float { - var max = 0f - var maxFormSize = 0f - val formToTextSpace = formToTextSpace.convertDpToPixel() - - for (entry in this.entries) { - if (entry == null) continue - val formSize = (if (entry.formSize.isNaN()) - this.formSize - else - entry.formSize).convertDpToPixel() - if (formSize > maxFormSize) maxFormSize = formSize - - val label = entry.label ?: continue - - val length = p.calcTextWidth(label).toFloat() - - if (length > max) max = length - } - - return max + maxFormSize + formToTextSpace - } - - /** - * returns the maximum height in pixels across all legend labels - * - * @param p the paint object used for rendering the text - */ - fun getMaximumEntryHeight(p: Paint): Float { - var max = 0f - - for (entry in this.entries) { - if (entry == null) continue - val label = entry.label ?: continue - - val length = p.calcTextHeight(label).toFloat() - - if (length > max) max = length - } - - return max - } - - fun setExtra(entries: MutableList) { - this.extraEntries = entries.toTypedArray() - } - - fun setExtra(entries: Array) { - this.extraEntries = entries - } - - /** - * Entries that will be appended to the end of the auto calculated - * entries after calculating the legend. - * (if the legend has already been calculated, you will need to call notifyDataSetChanged() - * to let the changes take effect) - */ - fun setExtra(colors: IntArray, labels: Array) { - val entries: MutableList = ArrayList() - - for (i in 0..() - } - - /** - * Sets a custom legend's entries array. - * * A null label will start a group. - * This will disable the feature that automatically calculates the legend - * entries from the datasets. - * Call resetCustom() to re-enable automatic calculation (and then - * notifyDataSetChanged() is needed to auto-calculate the legend again) - */ - fun setCustom(entries: Array) { - this.entries = entries - this.isLegendCustom = true - } - - /** - * Sets a custom legend's entries array. - * * A null label will start a group. - * This will disable the feature that automatically calculates the legend - * entries from the datasets. - * Call resetCustom() to re-enable automatic calculation (and then - * notifyDataSetChanged() is needed to auto-calculate the legend again) - */ - fun setCustom(entries: MutableList) { - this.entries = entries.toTypedArray() - this.isLegendCustom = true - } - - /** - * Calling this will disable the custom legend entries (set by - * setCustom(...)). Instead, the entries will again be calculated - * automatically (after notifyDataSetChanged() is called). - */ - fun resetCustom() { - this.isLegendCustom = false - } - - /** - * sets whether the legend will draw inside the chart or outside - */ - fun setDrawInside(value: Boolean) { - this.isDrawInsideEnabled = value - } - - /** - * the total width of the legend (needed width space) - */ - var neededWidth: Float = 0f - - /** - * the total height of the legend (needed height space) - */ - var neededHeight: Float = 0f - - var mTextHeightMax: Float = 0f - - var mTextWidthMax: Float = 0f - - /** - * Should the legend word wrap? / this is currently supported only for: - * BelowChartLeft, BelowChartRight, BelowChartCenter. / note that word - * wrapping a legend takes a toll on performance. / you may want to set - * maxSizePercent when word wrapping, to set the point where the text wraps. - * / default: false - */ - var isWordWrapEnabled: Boolean = false - - val calculatedLabelSizes: MutableList = mutableListOf() - val calculatedLabelBreakPoints: MutableList = mutableListOf() - val calculatedLineSizes: MutableList = mutableListOf() - - init { - this.mTextSize = 10f.convertDpToPixel() - this.mXOffset = 5f.convertDpToPixel() - this.mYOffset = 3f.convertDpToPixel() // 2 - } - - /** - * Calculates the dimensions of the Legend. This includes the maximum width - * and height of a single entry, as well as the total width and height of - * the Legend. - */ - fun calculateDimensions(labelPaint: Paint, viewPortHandler: ViewPortHandler) { - val defaultFormSize = formSize.convertDpToPixel() - val stackSpace = stackSpace.convertDpToPixel() - val formToTextSpace = formToTextSpace.convertDpToPixel() - val xEntrySpace = xEntrySpace.convertDpToPixel() - val yEntrySpace = yEntrySpace.convertDpToPixel() - val wordWrapEnabled = this.isWordWrapEnabled - val entries = this.entries - val entryCount = entries.size - - mTextWidthMax = getMaximumEntryWidth(labelPaint) - mTextHeightMax = getMaximumEntryHeight(labelPaint) - - when (this.orientation) { - LegendOrientation.VERTICAL -> { - var maxWidth = 0f - var maxHeight = 0f - var width = 0f - val labelLineHeight = labelPaint.getLineHeight() - var wasStacked = false - - var i = 0 - while (i < entryCount) { - val e = entries[i] - if (e == null) { - i++; continue - } - val drawingForm = e.form != LegendForm.NONE - val formSize = if (e.formSize.isNaN()) - defaultFormSize - else - e.formSize.convertDpToPixel() - val label = e.label - - if (!wasStacked) width = 0f - - if (drawingForm) { - if (wasStacked) width += stackSpace - width += formSize - } - - // grouped forms have null labels - if (label != null) { - // make a step to the left - if (drawingForm && !wasStacked) width += formToTextSpace - else if (wasStacked) { - maxWidth = max(maxWidth, width) - maxHeight += labelLineHeight + yEntrySpace - width = 0f - wasStacked = false - } - - width += labelPaint.calcTextWidth(label).toFloat() - - maxHeight += labelLineHeight + yEntrySpace - } else { - wasStacked = true - width += formSize - if (i < entryCount - 1) width += stackSpace - } - - maxWidth = max(maxWidth, width) - i++ - } - - neededWidth = maxWidth - neededHeight = maxHeight - } - - LegendOrientation.HORIZONTAL -> { - val labelLineHeight = labelPaint.getLineHeight() - val labelLineSpacing = labelPaint.getLineSpacing() + yEntrySpace - val contentWidth = viewPortHandler.contentWidth() * this.maxSizePercent - - // Start calculating layout - var maxLineWidth = 0f - var currentLineWidth = 0f - var requiredWidth = 0f - var stackedStartIndex = -1 - - calculatedLabelBreakPoints.clear() - calculatedLabelSizes.clear() - calculatedLineSizes.clear() - - var i = 0 - while (i < entryCount) { - val legendEntry = entries[i] - if (legendEntry == null) { - i++; continue - } - val drawingForm = legendEntry.form != LegendForm.NONE - val formSize = if (legendEntry.formSize.isNaN()) - defaultFormSize - else - legendEntry.formSize.convertDpToPixel() - val label = legendEntry.label - - calculatedLabelBreakPoints.add(false) - - if (stackedStartIndex == -1) { - // we are not stacking, so required width is for this label only - requiredWidth = 0f - } else { - // add the spacing appropriate for stacked labels/forms - requiredWidth += stackSpace - } - - // grouped forms have null labels - if (label != null) { - val fSize = labelPaint.calcTextSize(label) - calculatedLabelSizes.add(fSize) - requiredWidth += if (drawingForm) formToTextSpace + formSize else 0f - requiredWidth += fSize.width - } else { - calculatedLabelSizes.add(FSize.getInstance(0f, 0f)) - requiredWidth += if (drawingForm) formSize else 0f - - if (stackedStartIndex == -1) { - // mark this index as we might want to break here later - stackedStartIndex = i - } - } - - if (label != null || i == entryCount - 1) { - val requiredSpacing = if (currentLineWidth == 0f) 0f else xEntrySpace - - if (!wordWrapEnabled // No word wrapping, it must fit. - // The line is empty, it must fit - || currentLineWidth == 0f // It simply fits - || (contentWidth - currentLineWidth >= - requiredSpacing + requiredWidth) - ) { - // Expand current line - currentLineWidth += requiredSpacing + requiredWidth - } else { // It doesn't fit, we need to wrap a line - - // Add current line size to array - - calculatedLineSizes.add(FSize.getInstance(currentLineWidth, labelLineHeight)) - maxLineWidth = max(maxLineWidth, currentLineWidth) - - // Start a new line - calculatedLabelBreakPoints[if (stackedStartIndex > -1) - stackedStartIndex - else - i] = true - currentLineWidth = requiredWidth - } - - if (i == entryCount - 1) { - // Add last line size to array - calculatedLineSizes.add(FSize.getInstance(currentLineWidth, labelLineHeight)) - maxLineWidth = max(maxLineWidth, currentLineWidth) - } - } - - stackedStartIndex = if (label != null) -1 else stackedStartIndex - i++ - } - - neededWidth = maxLineWidth - neededHeight = (labelLineHeight - * (calculatedLineSizes.size).toFloat() - + labelLineSpacing * (if (calculatedLineSizes.isEmpty()) - 0 - else - (calculatedLineSizes.size - 1)).toFloat()) - } - } - - neededHeight += mYOffset - neededWidth += mXOffset - } -} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/components/LegendAndroid.kt b/chartLib/src/main/kotlin/info/appdev/charting/components/LegendAndroid.kt new file mode 100644 index 00000000..21385498 --- /dev/null +++ b/chartLib/src/main/kotlin/info/appdev/charting/components/LegendAndroid.kt @@ -0,0 +1,246 @@ +package info.appdev.charting.components + +import android.graphics.Paint +import info.appdev.charting.utils.FSize +import info.appdev.charting.utils.ViewPortHandler +import info.appdev.charting.utils.calcTextHeight +import info.appdev.charting.utils.calcTextSize +import info.appdev.charting.utils.calcTextWidth +import info.appdev.charting.utils.convertDpToPixel +import info.appdev.charting.utils.getLineHeight +import info.appdev.charting.utils.getLineSpacing +import kotlin.math.max + +/** + * Returns the maximum length in pixels across all legend labels + formsize + * + formtotextspace. + * + * Android-only extension since text measurement requires `android.graphics.Paint`. + * + * @param p the paint object used for rendering the text + */ +fun Legend.getMaximumEntryWidth(p: Paint): Float { + var max = 0f + var maxFormSize = 0f + val formToTextSpace = formToTextSpace.convertDpToPixel() + + for (entry in this.entries) { + if (entry == null) continue + val formSize = (if (entry.formSize.isNaN()) + this.formSize + else + entry.formSize).convertDpToPixel() + if (formSize > maxFormSize) maxFormSize = formSize + + val label = entry.label ?: continue + + val length = p.calcTextWidth(label).toFloat() + + if (length > max) max = length + } + + return max + maxFormSize + formToTextSpace +} + +/** + * Returns the maximum height in pixels across all legend labels. + * + * Android-only extension since text measurement requires `android.graphics.Paint`. + * + * @param p the paint object used for rendering the text + */ +fun Legend.getMaximumEntryHeight(p: Paint): Float { + var max = 0f + + for (entry in this.entries) { + if (entry == null) continue + val label = entry.label ?: continue + + val length = p.calcTextHeight(label).toFloat() + + if (length > max) max = length + } + + return max +} + +/** + * Calculates the dimensions of the Legend. This includes the maximum width + * and height of a single entry, as well as the total width and height of + * the Legend. + * + * Android-only extension since text measurement requires `android.graphics.Paint`. + */ +fun Legend.calculateDimensions(labelPaint: Paint, viewPortHandler: ViewPortHandler) { + val defaultFormSize = formSize.convertDpToPixel() + val stackSpace = stackSpace.convertDpToPixel() + val formToTextSpace = formToTextSpace.convertDpToPixel() + val xEntrySpace = xEntrySpace.convertDpToPixel() + val yEntrySpace = yEntrySpace.convertDpToPixel() + val wordWrapEnabled = this.isWordWrapEnabled + val entries = this.entries + val entryCount = entries.size + + mTextWidthMax = getMaximumEntryWidth(labelPaint) + mTextHeightMax = getMaximumEntryHeight(labelPaint) + + when (this.orientation) { + Legend.LegendOrientation.VERTICAL -> { + var maxWidth = 0f + var maxHeight = 0f + var width = 0f + val labelLineHeight = labelPaint.getLineHeight() + var wasStacked = false + + var i = 0 + while (i < entryCount) { + val e = entries[i] + if (e == null) { + i++; continue + } + val drawingForm = e.form != Legend.LegendForm.NONE + val formSize = if (e.formSize.isNaN()) + defaultFormSize + else + e.formSize.convertDpToPixel() + val label = e.label + + if (!wasStacked) width = 0f + + if (drawingForm) { + if (wasStacked) width += stackSpace + width += formSize + } + + // grouped forms have null labels + if (label != null) { + // make a step to the left + if (drawingForm && !wasStacked) width += formToTextSpace + else if (wasStacked) { + maxWidth = max(maxWidth, width) + maxHeight += labelLineHeight + yEntrySpace + width = 0f + wasStacked = false + } + + width += labelPaint.calcTextWidth(label).toFloat() + + maxHeight += labelLineHeight + yEntrySpace + } else { + wasStacked = true + width += formSize + if (i < entryCount - 1) width += stackSpace + } + + maxWidth = max(maxWidth, width) + i++ + } + + neededWidth = maxWidth + neededHeight = maxHeight + } + + Legend.LegendOrientation.HORIZONTAL -> { + val labelLineHeight = labelPaint.getLineHeight() + val labelLineSpacing = labelPaint.getLineSpacing() + yEntrySpace + val contentWidth = viewPortHandler.contentWidth() * this.maxSizePercent + + // Start calculating layout + var maxLineWidth = 0f + var currentLineWidth = 0f + var requiredWidth = 0f + var stackedStartIndex = -1 + + calculatedLabelBreakPoints.clear() + calculatedLabelSizes.clear() + calculatedLineSizes.clear() + + var i = 0 + while (i < entryCount) { + val legendEntry = entries[i] + if (legendEntry == null) { + i++; continue + } + val drawingForm = legendEntry.form != Legend.LegendForm.NONE + val formSize = if (legendEntry.formSize.isNaN()) + defaultFormSize + else + legendEntry.formSize.convertDpToPixel() + val label = legendEntry.label + + calculatedLabelBreakPoints.add(false) + + if (stackedStartIndex == -1) { + // we are not stacking, so required width is for this label only + requiredWidth = 0f + } else { + // add the spacing appropriate for stacked labels/forms + requiredWidth += stackSpace + } + + // grouped forms have null labels + if (label != null) { + val fSize = labelPaint.calcTextSize(label) + calculatedLabelSizes.add(fSize) + requiredWidth += if (drawingForm) formToTextSpace + formSize else 0f + requiredWidth += fSize.width + } else { + calculatedLabelSizes.add(FSize.getInstance(0f, 0f)) + requiredWidth += if (drawingForm) formSize else 0f + + if (stackedStartIndex == -1) { + // mark this index as we might want to break here later + stackedStartIndex = i + } + } + + if (label != null || i == entryCount - 1) { + val requiredSpacing = if (currentLineWidth == 0f) 0f else xEntrySpace + + if (!wordWrapEnabled // No word wrapping, it must fit. + // The line is empty, it must fit + || currentLineWidth == 0f // It simply fits + || (contentWidth - currentLineWidth >= + requiredSpacing + requiredWidth) + ) { + // Expand current line + currentLineWidth += requiredSpacing + requiredWidth + } else { // It doesn't fit, we need to wrap a line + + // Add current line size to array + + calculatedLineSizes.add(FSize.getInstance(currentLineWidth, labelLineHeight)) + maxLineWidth = max(maxLineWidth, currentLineWidth) + + // Start a new line + calculatedLabelBreakPoints[if (stackedStartIndex > -1) + stackedStartIndex + else + i] = true + currentLineWidth = requiredWidth + } + + if (i == entryCount - 1) { + // Add last line size to array + calculatedLineSizes.add(FSize.getInstance(currentLineWidth, labelLineHeight)) + maxLineWidth = max(maxLineWidth, currentLineWidth) + } + } + + stackedStartIndex = if (label != null) -1 else stackedStartIndex + i++ + } + + neededWidth = maxLineWidth + neededHeight = (labelLineHeight + * (calculatedLineSizes.size).toFloat() + + labelLineSpacing * (if (calculatedLineSizes.isEmpty()) + 0 + else + (calculatedLineSizes.size - 1)).toFloat()) + } + } + + neededHeight += yOffset + neededWidth += xOffset +} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/renderer/LegendRenderer.kt b/chartLib/src/main/kotlin/info/appdev/charting/renderer/LegendRenderer.kt index 531f2203..053f0792 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/renderer/LegendRenderer.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/renderer/LegendRenderer.kt @@ -12,6 +12,7 @@ import info.appdev.charting.components.Legend.LegendHorizontalAlignment import info.appdev.charting.components.Legend.LegendOrientation import info.appdev.charting.components.Legend.LegendVerticalAlignment import info.appdev.charting.components.LegendEntry +import info.appdev.charting.components.calculateDimensions import info.appdev.charting.data.ChartData import info.appdev.charting.interfaces.datasets.IBarDataSet import info.appdev.charting.interfaces.datasets.ICandleDataSet diff --git a/chartLib/src/main/kotlin/info/appdev/charting/utils/TextAlignAndroid.kt b/chartLib/src/main/kotlin/info/appdev/charting/utils/TextAlignAndroid.kt new file mode 100644 index 00000000..ab1975a1 --- /dev/null +++ b/chartLib/src/main/kotlin/info/appdev/charting/utils/TextAlignAndroid.kt @@ -0,0 +1,15 @@ +package info.appdev.charting.utils + +import android.graphics.Paint + +/** + * Converts the platform-independent [TextAlign] used by the data model (e.g. + * [info.appdev.charting.components.Description]) to the `android.graphics.Paint.Align` + * expected by the Android rendering pipeline. + */ +fun TextAlign?.toAndroidAlign(default: Paint.Align = Paint.Align.LEFT): Paint.Align = when (this) { + TextAlign.LEFT -> Paint.Align.LEFT + TextAlign.CENTER -> Paint.Align.CENTER + TextAlign.RIGHT -> Paint.Align.RIGHT + null -> default +} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/components/Description.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/Description.kt similarity index 93% rename from chartLib/src/main/kotlin/info/appdev/charting/components/Description.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/Description.kt index 91a16cf8..e928c904 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/components/Description.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/Description.kt @@ -1,7 +1,7 @@ package info.appdev.charting.components -import android.graphics.Paint.Align import info.appdev.charting.utils.PointF +import info.appdev.charting.utils.TextAlign import info.appdev.charting.utils.convertDpToPixel class Description : ComponentBase() { @@ -26,7 +26,7 @@ class Description : ComponentBase() { /** * the alignment of the description text */ - var textAlign: Align? = Align.RIGHT + var textAlign: TextAlign? = TextAlign.RIGHT init { // default size diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/Legend.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/Legend.kt new file mode 100644 index 00000000..58fefb37 --- /dev/null +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/Legend.kt @@ -0,0 +1,260 @@ +package info.appdev.charting.components + +import info.appdev.charting.utils.ColorTemplate +import info.appdev.charting.utils.DashEffect +import info.appdev.charting.utils.FSize +import info.appdev.charting.utils.ViewPortHandler +import info.appdev.charting.utils.convertDpToPixel +import kotlin.math.max +import kotlin.math.min + +/** + * Class representing the legend of the chart. The legend will contain one entry + * per color and DataSet. Multiple colors in one DataSet are grouped together. + * The legend object is NOT available before setting data to the chart. + */ +class Legend() : ComponentBase() { + /** + * Kept as a nested type alias (rather than a nested enum) so that the actual enum can live + * in the platform-independent `chartLibCore` module while `Legend.LegendForm` keeps + * working unchanged for existing callers. + */ + typealias LegendForm = info.appdev.charting.components.LegendForm + + enum class LegendHorizontalAlignment { + LEFT, CENTER, RIGHT + } + + enum class LegendVerticalAlignment { + TOP, CENTER, BOTTOM + } + + enum class LegendOrientation { + HORIZONTAL, VERTICAL + } + + enum class LegendDirection { + LEFT_TO_RIGHT, RIGHT_TO_LEFT + } + + /** + * The legend entries array + */ + var entries: Array = arrayOf() + private set + + /** + * Entries that will be appended to the end of the auto calculated entries after calculating the legend. + * (if the legend has already been calculated, you will need to call notifyDataSetChanged() to let the changes take effect) + */ + var extraEntries: Array = arrayOf() + private set + + /** + * Are the legend labels/colors a custom value or auto calculated? If false, + * then it's auto, if true, then custom. default false (automatic legend) + */ + var isLegendCustom: Boolean = false + private set + + /** + * sets the horizontal alignment of the legend + */ + var horizontalAlignment: LegendHorizontalAlignment = LegendHorizontalAlignment.LEFT + + /** + * sets the vertical alignment of the legend + */ + var verticalAlignment: LegendVerticalAlignment = LegendVerticalAlignment.BOTTOM + + /** + * sets the orientation of the legend + */ + var orientation: LegendOrientation = LegendOrientation.HORIZONTAL + + /** + * returns whether the legend will draw inside the chart or outside + */ + var isDrawInsideEnabled: Boolean = false + private set + + /** + * the text direction for the legend + */ + var direction: LegendDirection = LegendDirection.LEFT_TO_RIGHT + + /** + * the shape/form the legend colors are drawn in + */ + var form: LegendForm = LegendForm.SQUARE + + /** + * the size of the legend forms/shapes + */ + var formSize: Float = 8f + + /** + * the size of the legend forms/shapes + */ + var formLineWidth: Float = 3f + + /** + * Line dash path effect used for shapes that consist of lines. + */ + var formLineDashEffect: DashEffect? = null + + /** + * the space between the legend entries on a horizontal axis, default 6f + */ + var xEntrySpace: Float = 6f + + /** + * the space between the legend entries on a vertical axis, default 5f + */ + var yEntrySpace: Float = 0f + + /** + * the space between the legend entries on a vertical axis, default 2f + * private float mYEntrySpace = 2f; / ** the space between the form and the + * actual label/text + */ + var formToTextSpace: Float = 5f + + /** + * the space that should be left between stacked forms + */ + var stackSpace: Float = 3f + + /** + * The maximum relative size out of the whole chart view. / If the legend is + * to the right/left of the chart, then this affects the width of the + * legend. / If the legend is to the top/bottom of the chart, then this + * affects the height of the legend. / If the legend is the center of the + * piechart, then this defines the size of the rectangular bounds out of the + * size of the "hole". / default: 0.95f (95%) + */ + var maxSizePercent: Float = 0.95f + + /** + * Constructor. Provide entries for the legend. + */ + constructor(entries: Array) : this() { + this.entries = entries + } + + /** + * This method sets the automatically computed colors for the legend. Use setCustom(...) to set custom colors. + */ + fun setEntries(entries: MutableList) { + this.entries = entries.toTypedArray() + } + + fun setExtra(entries: MutableList) { + this.extraEntries = entries.toTypedArray() + } + + fun setExtra(entries: Array) { + this.extraEntries = entries + } + + /** + * Entries that will be appended to the end of the auto calculated + * entries after calculating the legend. + * (if the legend has already been calculated, you will need to call notifyDataSetChanged() + * to let the changes take effect) + */ + fun setExtra(colors: IntArray, labels: Array) { + val entries: MutableList = ArrayList() + + for (i in 0..() + } + + /** + * Sets a custom legend's entries array. + * * A null label will start a group. + * This will disable the feature that automatically calculates the legend + * entries from the datasets. + * Call resetCustom() to re-enable automatic calculation (and then + * notifyDataSetChanged() is needed to auto-calculate the legend again) + */ + fun setCustom(entries: Array) { + this.entries = entries + this.isLegendCustom = true + } + + /** + * Sets a custom legend's entries array. + * * A null label will start a group. + * This will disable the feature that automatically calculates the legend + * entries from the datasets. + * Call resetCustom() to re-enable automatic calculation (and then + * notifyDataSetChanged() is needed to auto-calculate the legend again) + */ + fun setCustom(entries: MutableList) { + this.entries = entries.toTypedArray() + this.isLegendCustom = true + } + + /** + * Calling this will disable the custom legend entries (set by + * setCustom(...)). Instead, the entries will again be calculated + * automatically (after notifyDataSetChanged() is called). + */ + fun resetCustom() { + this.isLegendCustom = false + } + + /** + * sets whether the legend will draw inside the chart or outside + */ + fun setDrawInside(value: Boolean) { + this.isDrawInsideEnabled = value + } + + /** + * the total width of the legend (needed width space) + */ + var neededWidth: Float = 0f + + /** + * the total height of the legend (needed height space) + */ + var neededHeight: Float = 0f + + var mTextHeightMax: Float = 0f + + var mTextWidthMax: Float = 0f + + /** + * Should the legend word wrap? / this is currently supported only for: + * BelowChartLeft, BelowChartRight, BelowChartCenter. / note that word + * wrapping a legend takes a toll on performance. / you may want to set + * maxSizePercent when word wrapping, to set the point where the text wraps. + * / default: false + */ + var isWordWrapEnabled: Boolean = false + + val calculatedLabelSizes: MutableList = mutableListOf() + val calculatedLabelBreakPoints: MutableList = mutableListOf() + val calculatedLineSizes: MutableList = mutableListOf() + + init { + this.mTextSize = 10f.convertDpToPixel() + this.mXOffset = 5f.convertDpToPixel() + this.mYOffset = 3f.convertDpToPixel() // 2 + } + +} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/components/LegendEntry.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/LegendEntry.kt similarity index 96% rename from chartLib/src/main/kotlin/info/appdev/charting/components/LegendEntry.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/LegendEntry.kt index 09256b7b..7ef028d2 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/components/LegendEntry.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/components/LegendEntry.kt @@ -1,7 +1,6 @@ package info.appdev.charting.components import info.appdev.charting.utils.DashEffect -import androidx.annotation.ColorInt import info.appdev.charting.components.Legend.LegendForm import info.appdev.charting.utils.ColorTemplate @@ -24,7 +23,7 @@ class LegendEntry { formSize: Float, formLineWidth: Float, formLineDashEffect: DashEffect?, - @ColorInt formColor: Int + formColor: Int ) { this.label = label this.form = form @@ -73,6 +72,5 @@ class LegendEntry { /** * The color for drawing the form */ - @ColorInt var formColor: Int = ColorTemplate.COLOR_NONE } \ No newline at end of file diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/TextAlign.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/TextAlign.kt new file mode 100644 index 00000000..636764ad --- /dev/null +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/TextAlign.kt @@ -0,0 +1,12 @@ +package info.appdev.charting.utils + +/** + * Platform-independent replacement for `android.graphics.Paint.Align`. + * Used by components (e.g. [info.appdev.charting.components.Description]) to describe + * how text should be aligned, without depending on `android.graphics.Paint`. + */ +enum class TextAlign { + LEFT, + CENTER, + RIGHT +} From 4495a3c6539932ef63ea1bdea340b464817a57b0 Mon Sep 17 00:00:00 2001 From: Hannes Achleitner Date: Sun, 27 Sep 2026 09:44:02 +0200 Subject: [PATCH 12/22] Step A.7 (module scaffold): create chartLibComposeMultiplatform KMP module Scaffolds a new chartLibComposeMultiplatform module targeting Android, desktop (jvm), iosArm64 and iosSimulatorArm64, applying the Compose Multiplatform (org.jetbrains.compose 1.12.1) and Compose compiler (org.jetbrains.kotlin.plugin.compose 2.4.10) plugins on top of the same com.android.kotlin.multiplatform.library + org.jetbrains.kotlin.multiplatform setup used by chartLibCore. commonMain depends on chartLibCore (api) plus compose.runtime/compose.foundation/compose.ui. Two toolchain constraints discovered and documented in KotlinMultiplatform.md: - Compose Multiplatform 1.12.1's Android artifacts require compileSdk 37, so this module (only) is bumped to compileSdk 37 while the rest of the repo intentionally stays on 36. - Compose Multiplatform 1.12.x no longer publishes artifacts for the iosX64 (Intel simulator) target, so unlike chartLibCore this module only configures iosArm64()/iosSimulatorArm64(). Only a documented placeholder constant exists so far; no renderer logic has been ported yet. Verified chartLibComposeMultiplatform:build succeeds across all targets, and the full existing verification chain (chartLibCore:allTests, chartLib:compileDebugKotlin, ./gradlew test, chartLibCompose:assembleDebug, app:assembleDebug) remains green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- KotlinMultiplatform.md | 54 +++++++++++++++++++ chartLibComposeMultiplatform/build.gradle.kts | 51 ++++++++++++++++++ .../compose/multiplatform/Placeholder.kt | 11 ++++ settings.gradle.kts | 1 + 4 files changed, 117 insertions(+) create mode 100644 chartLibComposeMultiplatform/build.gradle.kts create mode 100644 chartLibComposeMultiplatform/src/commonMain/kotlin/info/appdev/charting/compose/multiplatform/Placeholder.kt diff --git a/KotlinMultiplatform.md b/KotlinMultiplatform.md index 56ee7e7f..9ea9c95f 100644 --- a/KotlinMultiplatform.md +++ b/KotlinMultiplatform.md @@ -639,6 +639,60 @@ lint Existing custom lint rules (unchanged) - [ ] **Step A.7 — New `chartLibComposeMultiplatform` module**: Compose Multiplatform renderers built on `chartLibCore` using `DrawScope`. + Given the plan doc already flags this as "the single largest remaining + chunk of work (~32 renderer files)", it is being sub-sliced: + - [x] **Step A.7 (module scaffold)**: created the new + `chartLibComposeMultiplatform` module and verified the whole Gradle/ + Kotlin/Compose Multiplatform toolchain resolves and builds across + every target *before* investing in any renderer port. + - `chartLibComposeMultiplatform/build.gradle.kts` closely mirrors + `chartLibCore/build.gradle.kts` (`com.android.kotlin.multiplatform.library` + + `org.jetbrains.kotlin.multiplatform`), adding two more plugins: + `org.jetbrains.compose` (the Compose Multiplatform Gradle plugin, which + supplies the `compose.runtime`/`compose.foundation`/`compose.ui` + dependency aliases) and `org.jetbrains.kotlin.plugin.compose` (the + Compose compiler, version-pinned to the project's Kotlin `2.4.10`, same + as `chartLibCompose`). `commonMain` depends on `chartLibCore` (`api`) + plus `compose.runtime`/`compose.foundation`/`compose.ui`. + - No Gradle version catalog (`libs.versions.toml`) exists in this repo; + plugin/dependency versions are declared inline per-module, so the new + Compose Multiplatform plugin version is pinned directly in this + module's `build.gradle.kts`. + - **Compose Multiplatform plugin version required raising this module's + `compileSdk` to 37** (kept at `36` everywhere else in the repo): + Compose Multiplatform `1.12.1`'s Android artifacts + (`androidx.compose.ui:ui-android`, `foundation-android`, + `runtime-saveable-android`, etc.) declare an AAR metadata minimum of + API 37, which fails `checkAndroidMainAarMetadata` at `compileSdk=36`. + An older plugin version (`1.8.2`) avoids the compileSdk bump but is + incompatible with AGP 9.2.1's newer + `KotlinMultiplatformAndroidComponentsExtension` API + (`NoSuchMethodError` on `onVariant`) — confirmed by testing both. + `compileSdk = 37` is therefore scoped to only this one module for now; + the rest of the repo (`chartLib`, `chartLibCore`, `chartLibCompose`, + `app`) intentionally stays on `36` until there's a reason to bump + everything at once. + - **Dropped `iosX64` for this module only** (unlike `chartLibCore`, + which still targets it): Compose Multiplatform `1.12.x` no longer + publishes `compose.ui`/`compose.foundation`/`compose.runtime` + artifacts for the Intel iOS simulator target, so `iosX64` dependency + resolution fails outright. Only `iosArm64()`/`iosSimulatorArm64()` are + configured, matching upstream Compose Multiplatform's own supported + iOS target set. + - Added a placeholder `chartLibComposeMultiplatform/.../Placeholder.kt` + (a single documented constant) purely to validate the module compiles + end-to-end on every target — no real renderer code yet. + - `settings.gradle.kts` updated with + `include(":chartLibComposeMultiplatform")`. + - Verified: `:chartLibComposeMultiplatform:build` succeeds (Android AAR, + desktop jar, and both iOS arm64 klibs/frameworks all compile/link), + plus the full existing chain (`chartLibCore:allTests`, + `chartLib:compileDebugKotlin`, full `./gradlew test`, + `chartLibCompose:assembleDebug`, `app:assembleDebug`) still green. + - **Not yet done** (remaining sub-slices of Step A.7): the actual + `DrawScope` port of the ~32 renderer files, and deciding/implementing + a slice order for them (proof-of-concept with one simple renderer + first, per the plan, before the rest). - [ ] **Step A.8 — Gesture handling** with `pointerInput`/`detectTransformGestures` replacing `ChartTouchListener`/`MotionEvent`. - [ ] **Step A.9 — `demoKmp` Compose Multiplatform demo app** diff --git a/chartLibComposeMultiplatform/build.gradle.kts b/chartLibComposeMultiplatform/build.gradle.kts new file mode 100644 index 00000000..9f55874f --- /dev/null +++ b/chartLibComposeMultiplatform/build.gradle.kts @@ -0,0 +1,51 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("com.android.kotlin.multiplatform.library") + id("org.jetbrains.kotlin.multiplatform") + id("org.jetbrains.compose") version "1.12.1" + id("org.jetbrains.kotlin.plugin.compose") version "2.4.10" +} + +kotlin { + compilerOptions { + freeCompilerArgs.add("-Xexpect-actual-classes") + } + + android { + namespace = "info.appdev.charting.compose.multiplatform" + compileSdk = 37 + minSdk = 23 + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + withHostTest {} + } + + jvm("desktop") + + // Compose Multiplatform (as of 1.12.x) no longer publishes iosX64 (Intel simulator) + // artifacts for compose.ui/compose.foundation/compose.runtime, so unlike chartLibCore + // this module only targets the two arm64 iOS targets. + val xcfName = "chartLibComposeMultiplatformKit" + listOf( + iosArm64(), + iosSimulatorArm64() + ).forEach { iosTarget -> + iosTarget.binaries.framework { + baseName = xcfName + } + } + + sourceSets { + commonMain.dependencies { + api(project(":chartLibCore")) + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.ui) + } + commonTest.dependencies { + implementation(kotlin("test")) + } + } +} diff --git a/chartLibComposeMultiplatform/src/commonMain/kotlin/info/appdev/charting/compose/multiplatform/Placeholder.kt b/chartLibComposeMultiplatform/src/commonMain/kotlin/info/appdev/charting/compose/multiplatform/Placeholder.kt new file mode 100644 index 00000000..1e00a637 --- /dev/null +++ b/chartLibComposeMultiplatform/src/commonMain/kotlin/info/appdev/charting/compose/multiplatform/Placeholder.kt @@ -0,0 +1,11 @@ +package info.appdev.charting.compose.multiplatform + +/** + * Placeholder entry point for the Compose Multiplatform chart renderers (Step A.7). + * + * This module will host `DrawScope`-based re-implementations of the `chartLib` + * renderer classes so that charts can be rendered from common code on Android, + * iOS, desktop (and potentially wasmJs), consuming the portable model classes + * already migrated to `chartLibCore`. + */ +public const val CHART_LIB_COMPOSE_MULTIPLATFORM_MODULE_NAME: String = "chartLibComposeMultiplatform" diff --git a/settings.gradle.kts b/settings.gradle.kts index 5fcb4371..00a9333e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,5 +1,6 @@ include("chartLib") include("chartLibCore") include("chartLibCompose") +include("chartLibComposeMultiplatform") include("app") include("lint") From e3abdebafa3680b49431072036a882c63c5879e7 Mon Sep 17 00:00:00 2001 From: Hannes Achleitner Date: Sun, 27 Sep 2026 10:01:03 +0200 Subject: [PATCH 13/22] Step A.7 (grid line proof-of-concept): first Compose Multiplatform renderer slice Ports the axis grid line drawing (XAxisRenderer.renderGridLines/ drawGridLine and YAxisRenderer.renderGridLines/linePath/ transformedPositions) to Compose's DrawScope, as the first real renderer slice out of the ~32-file Step A.7 chunk. This slice was chosen because it needs no concrete chart-type DataSet (LineDataSet/BarDataSet/etc. haven't moved to chartLibCore yet) - only the axis/viewport/transform types already migrated in Step A.5/A.6 (XAxis/YAxis, ViewPortHandler, TransformerCore), making it fully self-contained. Adds chartLibComposeMultiplatform's renderer/AxisGridRenderer.kt with DrawScope.drawXAxisGridLines(...) and DrawScope.drawYAxisGridLines(...), operating purely on chartLibCore types rather than chartLib's Renderer/DataRenderer/AxisRenderer Android base-class hierarchy - confirming Compose renderers will be fresh idiomatic functions on the portable model, not a line-for-line port of the View-based OOP renderer classes. The axis-value-to-pixel math is factored out into plain xAxisGridPixelPositions/yAxisGridPixelPositions functions, separate from the DrawScope drawLine/clipRect calls, so it can be unit-tested without a Compose UI test harness. Added AxisGridRendererTest covering both axes, which now passes on testAndroidHostTest, desktopTest, and iosSimulatorArm64Test. Removes the earlier scaffold placeholder now that real renderer code exists. Full existing verification chain (chartLibCore:allTests, chartLib:compileDebugKotlin, ./gradlew test, chartLibCompose:assembleDebug, app:assembleDebug) remains green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- KotlinMultiplatform.md | 43 +++++++ .../compose/multiplatform/Placeholder.kt | 11 -- .../renderer/AxisGridRenderer.kt | 111 ++++++++++++++++++ .../renderer/AxisGridRendererTest.kt | 67 +++++++++++ 4 files changed, 221 insertions(+), 11 deletions(-) delete mode 100644 chartLibComposeMultiplatform/src/commonMain/kotlin/info/appdev/charting/compose/multiplatform/Placeholder.kt create mode 100644 chartLibComposeMultiplatform/src/commonMain/kotlin/info/appdev/charting/compose/multiplatform/renderer/AxisGridRenderer.kt create mode 100644 chartLibComposeMultiplatform/src/commonTest/kotlin/info/appdev/charting/compose/multiplatform/renderer/AxisGridRendererTest.kt diff --git a/KotlinMultiplatform.md b/KotlinMultiplatform.md index 9ea9c95f..adec43dc 100644 --- a/KotlinMultiplatform.md +++ b/KotlinMultiplatform.md @@ -693,6 +693,49 @@ lint Existing custom lint rules (unchanged) `DrawScope` port of the ~32 renderer files, and deciding/implementing a slice order for them (proof-of-concept with one simple renderer first, per the plan, before the rest). + - [x] **Step A.7 (grid line proof-of-concept)**: ported the vertical/ + horizontal axis grid line drawing (`XAxisRenderer.renderGridLines`/ + `drawGridLine` and `YAxisRenderer.renderGridLines`/`linePath`/ + `transformedPositions`) to Compose's `DrawScope`, as the first real + renderer slice, chosen because it needs no concrete chart-type + `DataSet` (`LineDataSet`/`BarDataSet`/etc. are still Android-coupled + and haven't moved to `chartLibCore` yet — only the axis/viewport/ + transform types from Step A.5/A.6 are needed), making it fully + self-contained. + - New file `chartLibComposeMultiplatform/.../renderer/AxisGridRenderer.kt` + with `DrawScope.drawXAxisGridLines(xAxis, viewPortHandler, transformer)` + and `DrawScope.drawYAxisGridLines(yAxis, viewPortHandler, transformer)`, + operating purely on `chartLibCore` types (`XAxis`/`YAxis`, + `ViewPortHandler`, `TransformerCore`) — no dependency on chartLib's + `Renderer`/`DataRenderer`/`AxisRenderer` Android base-class hierarchy, + confirming the intended Step A.8 direction: Compose renderers are + fresh idiomatic functions operating on the portable model, not a + line-for-line port of the View-based OOP renderer classes. + - The axis-value-to-pixel math (`xAxisGridPixelPositions`/ + `yAxisGridPixelPositions`) is deliberately factored out as plain + functions returning a `FloatArray`, separate from the actual + `drawLine`/`clipRect` calls, specifically so it can be unit-tested + without a Compose UI test harness. + - New test `chartLibComposeMultiplatform/commonTest/.../ + AxisGridRendererTest.kt`: sets up a `ViewPortHandler`/`TransformerCore` + for a 100x100 content rect mapping axis values `[0, 10]`, and asserts + axis value `0`/`10` map to the expected content-rect edges (left/right + for X, bottom/top for Y, confirming the Y-pixel-axis inversion). + - Removed the earlier scaffold placeholder file now that real renderer + code exists in the module. + - Verified: this is the **first slice where `chartLibComposeMultiplatform` + tests actually ran and passed on three targets** — + `testAndroidHostTest`, `desktopTest`, and (this session's sandbox + happened to have a usable iOS toolchain) `iosSimulatorArm64Test` — all + 2/2 tests green on each. Full existing chain + (`chartLibCore:allTests`, `chartLib:compileDebugKotlin`, + `./gradlew test`, `chartLibCompose:assembleDebug`, + `app:assembleDebug`) also still green. + - **Not yet done**: the remaining ~30 renderer files (data renderers for + each chart type, legend renderer, limit line renderer, marker + rendering, etc.), most of which *do* need the concrete `DataSet` + classes ported to `chartLibCore` first (a prerequisite not yet + started) before they can be tackled the same way. - [ ] **Step A.8 — Gesture handling** with `pointerInput`/`detectTransformGestures` replacing `ChartTouchListener`/`MotionEvent`. - [ ] **Step A.9 — `demoKmp` Compose Multiplatform demo app** diff --git a/chartLibComposeMultiplatform/src/commonMain/kotlin/info/appdev/charting/compose/multiplatform/Placeholder.kt b/chartLibComposeMultiplatform/src/commonMain/kotlin/info/appdev/charting/compose/multiplatform/Placeholder.kt deleted file mode 100644 index 1e00a637..00000000 --- a/chartLibComposeMultiplatform/src/commonMain/kotlin/info/appdev/charting/compose/multiplatform/Placeholder.kt +++ /dev/null @@ -1,11 +0,0 @@ -package info.appdev.charting.compose.multiplatform - -/** - * Placeholder entry point for the Compose Multiplatform chart renderers (Step A.7). - * - * This module will host `DrawScope`-based re-implementations of the `chartLib` - * renderer classes so that charts can be rendered from common code on Android, - * iOS, desktop (and potentially wasmJs), consuming the portable model classes - * already migrated to `chartLibCore`. - */ -public const val CHART_LIB_COMPOSE_MULTIPLATFORM_MODULE_NAME: String = "chartLibComposeMultiplatform" diff --git a/chartLibComposeMultiplatform/src/commonMain/kotlin/info/appdev/charting/compose/multiplatform/renderer/AxisGridRenderer.kt b/chartLibComposeMultiplatform/src/commonMain/kotlin/info/appdev/charting/compose/multiplatform/renderer/AxisGridRenderer.kt new file mode 100644 index 00000000..048a02fc --- /dev/null +++ b/chartLibComposeMultiplatform/src/commonMain/kotlin/info/appdev/charting/compose/multiplatform/renderer/AxisGridRenderer.kt @@ -0,0 +1,111 @@ +package info.appdev.charting.compose.multiplatform.renderer + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.clipRect +import info.appdev.charting.components.XAxis +import info.appdev.charting.components.YAxis +import info.appdev.charting.utils.TransformerCore +import info.appdev.charting.utils.ViewPortHandler + +/** + * Proof-of-concept Compose Multiplatform renderer (Step A.7): draws the X-axis vertical + * grid lines using only portable `chartLibCore` types ([XAxis], [ViewPortHandler], + * [TransformerCore]), rendering through Compose's [DrawScope] instead of + * `android.graphics.Canvas`. + * + * Mirrors the pixel math of `chartLib`'s `XAxisRenderer.renderGridLines`/`drawGridLine`: + * one vertical line per axis entry (or specific position), transformed from axis-value + * space to pixel space and clipped to the viewport's content rect. + */ +/** + * Computes the pixel-space vertical grid line x-positions for [xAxis] via [transformer] + * (axis-value space -> pixel space). Extracted as a pure function, separate from the + * [DrawScope] drawing itself, so the coordinate math can be unit-tested without needing + * a Compose UI test harness. + */ +fun xAxisGridPixelPositions(xAxis: XAxis, transformer: TransformerCore): FloatArray { + val values = if (xAxis.isShowSpecificPositions) xAxis.specificPositions else xAxis.entries + val positions = FloatArray(values.size * 2) + for (i in values.indices) { + positions[i * 2] = values[i] + positions[i * 2 + 1] = values[i] + } + transformer.pointValuesToPixel(positions) + return positions +} + +fun DrawScope.drawXAxisGridLines( + xAxis: XAxis, + viewPortHandler: ViewPortHandler, + transformer: TransformerCore +) { + if (!xAxis.isDrawGridLines || !xAxis.isEnabled) return + + val positions = xAxisGridPixelPositions(xAxis, transformer) + + val clip = viewPortHandler.contentRect + val color = Color(xAxis.gridColor) + clipRect(clip.left, clip.top, clip.right, clip.bottom) { + var i = 0 + while (i < positions.size) { + val x = positions[i] + drawLine( + color = color, + start = Offset(x, viewPortHandler.contentBottom()), + end = Offset(x, viewPortHandler.contentTop()), + strokeWidth = xAxis.gridLineWidth + ) + i += 2 + } + } +} + +/** + * Computes the pixel-space horizontal grid line y-positions for [yAxis] via [transformer] + * (axis-value space -> pixel space). Extracted as a pure function for the same testability + * reason as [xAxisGridPixelPositions]. + */ +fun yAxisGridPixelPositions(yAxis: YAxis, transformer: TransformerCore): FloatArray { + val values = if (yAxis.isShowSpecificPositions) yAxis.specificPositions else yAxis.entries + val positions = FloatArray(values.size * 2) + for (i in values.indices) { + // only fill y values, x values are not needed for y grid lines + positions[i * 2 + 1] = values[i] + } + transformer.pointValuesToPixel(positions) + return positions +} + +/** + * Draws the Y-axis horizontal grid lines, mirroring `chartLib`'s + * `YAxisRenderer.renderGridLines`/`linePath`/`transformedPositions`: one horizontal line + * per axis entry (or specific position), transformed from axis-value space to pixel space + * and clipped to the viewport's content rect. + */ +fun DrawScope.drawYAxisGridLines( + yAxis: YAxis, + viewPortHandler: ViewPortHandler, + transformer: TransformerCore +) { + if (!yAxis.isEnabled || !yAxis.isDrawGridLines) return + + val positions = yAxisGridPixelPositions(yAxis, transformer) + + val clip = viewPortHandler.contentRect + val color = Color(yAxis.gridColor) + clipRect(clip.left, clip.top, clip.right, clip.bottom) { + var i = 0 + while (i < positions.size) { + val y = positions[i + 1] + drawLine( + color = color, + start = Offset(viewPortHandler.offsetLeft(), y), + end = Offset(viewPortHandler.contentRight(), y), + strokeWidth = yAxis.gridLineWidth + ) + i += 2 + } + } +} diff --git a/chartLibComposeMultiplatform/src/commonTest/kotlin/info/appdev/charting/compose/multiplatform/renderer/AxisGridRendererTest.kt b/chartLibComposeMultiplatform/src/commonTest/kotlin/info/appdev/charting/compose/multiplatform/renderer/AxisGridRendererTest.kt new file mode 100644 index 00000000..c30c9c49 --- /dev/null +++ b/chartLibComposeMultiplatform/src/commonTest/kotlin/info/appdev/charting/compose/multiplatform/renderer/AxisGridRendererTest.kt @@ -0,0 +1,67 @@ +package info.appdev.charting.compose.multiplatform.renderer + +import info.appdev.charting.components.XAxis +import info.appdev.charting.components.YAxis +import info.appdev.charting.utils.TransformerCore +import info.appdev.charting.utils.ViewPortHandler +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Verifies the pure pixel-math helpers behind the Step A.7 proof-of-concept grid line + * renderer, without needing a Compose UI test harness: a 100x100 content rect mapping the + * axis-value range [0, 10] should place value 0 at the left/bottom edge and value 10 at the + * right/top edge, matching `chartLib`'s `XAxisRenderer`/`YAxisRenderer` pixel math. + */ +class AxisGridRendererTest { + + private fun setUpViewPortAndTransformer(): Pair { + val viewPortHandler = ViewPortHandler() + viewPortHandler.setChartDimens(100f, 100f) + viewPortHandler.restrainViewPort(0f, 0f, 0f, 0f) + + val transformer = TransformerCore(viewPortHandler) + transformer.prepareMatrixValuePx(xChartMin = 0f, deltaX = 10f, deltaY = 10f, yChartMin = 0f) + transformer.prepareMatrixOffset(inverted = false) + return viewPortHandler to transformer + } + + @Test + fun xAxisGridPixelPositions_mapsAxisValuesToContentRectPixels() { + val (viewPortHandler, transformer) = setUpViewPortAndTransformer() + + val xAxis = XAxis().apply { + entries = floatArrayOf(0f, 10f) + } + + val positions = xAxisGridPixelPositions(xAxis, transformer) + + assertEquals(4, positions.size) + assertEquals(viewPortHandler.contentLeft(), positions[0], absoluteTolerance = 0.001f) + assertEquals(viewPortHandler.contentRight(), positions[2], absoluteTolerance = 0.001f) + } + + @Test + fun yAxisGridPixelPositions_mapsAxisValuesToContentRectPixels() { + val (viewPortHandler, transformer) = setUpViewPortAndTransformer() + + val yAxis = YAxis().apply { + entries = floatArrayOf(0f, 10f) + } + + val positions = yAxisGridPixelPositions(yAxis, transformer) + + assertEquals(4, positions.size) + // axis value 0 maps to the bottom of the content rect, value 10 to the top + // (y-pixel axis is inverted relative to chart-value axis). + assertEquals(viewPortHandler.contentBottom(), positions[1], absoluteTolerance = 0.001f) + assertEquals(viewPortHandler.contentTop(), positions[3], absoluteTolerance = 0.001f) + } + + private fun assertEquals(expected: Float, actual: Float, absoluteTolerance: Float) { + kotlin.test.assertTrue( + kotlin.math.abs(expected - actual) <= absoluteTolerance, + "expected=$expected actual=$actual" + ) + } +} From 38fb5904739f8d6aab93a83447af8ae72bcd0dab Mon Sep 17 00:00:00 2001 From: Hannes Achleitner Date: Sun, 27 Sep 2026 10:21:37 +0200 Subject: [PATCH 14/22] Step A.3 (continuation, part 2): migrate concrete DataSet family to chartLibCore Prerequisite work surfaced by the Step A.7 grid-line proof-of-concept: most of the ~30 remaining renderer files need concrete chart-type DataSet classes (LineDataSet/BarDataSet/etc.), which hadn't moved to chartLibCore yet even though the abstract DataSet/BaseDataSet/IDataSet base already had (Step A.3 completion). Moves every concrete DataSet class with no remaining Android-only blocker to chartLibCore commonMain: BarLineScatterCandleBubbleDataSet, LineScatterCandleRadarDataSet, LineRadarDataSet (abstract parents), plus the now fully-portable leaf classes BubbleDataSet, CandleDataSet, PieDataSet, RadarDataSet, and their interfaces. Trivial Android cleanup: @ColorInt annotations dropped, Color.rgb(...)/ Color.WHITE replaced with the existing common ColorTemplate.argb(r,g,b) helper. ILineScatterCandleRadarDataSet.dashPathEffectHighlight switches DashPathEffect -> the common DashEffect type (same pattern as Step A.5), updating LineScatterCandleRadarRenderer's one call site. fillDrawable (Drawable has no portable equivalent) is removed from common code entirely and replaced with an Android-only extension property in the new chartLib/data/LineRadarDataSetAndroid.kt, backed by a WeakHashMap keyed by dataset identity - keeps `dataSet.fillDrawable = ...` call-site syntax unchanged for both LineDataSet (stays Android-only) and RadarDataSet (now common), at the cost of an explicit import at each call site (LineChartRenderer, RadarChartRenderer, three app example activities). Two small disclosed behavior changes from removing the fillDrawable stored property: setting fillColor no longer implicitly clears a previously set fillDrawable, and .copy() no longer propagates fillDrawable to the copy. BarDataSet/LineDataSet (need the heavily Canvas/Paint/Drawable-coupled Fill class, Context-based drawable loading, and IFillFormatter's dependency on the permanently-Android-only LineDataProvider) and ScatterDataSet (needs the Canvas/Paint-coupled IShapeRenderer family) remain Android-only leaves extending the now-common abstract parents, same pattern as IMarker/highlighters in Steps A.4/A.5. Verified: chartLibCore:allTests, chartLib:compileDebugKotlin, full ./gradlew test, chartLibCompose:assembleDebug, app:assembleDebug, and chartLibComposeMultiplatform:build (all targets) all pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- KotlinMultiplatform.md | 86 +++++++++++++++++++ .../info/appdev/chartexample/DataTools.kt | 1 + .../appdev/chartexample/GradientActivity.kt | 1 + .../SpecificPositionsLineChartActivity.kt | 1 + .../charting/data/LineRadarDataSetAndroid.kt | 30 +++++++ .../charting/renderer/LineChartRenderer.kt | 1 + .../LineScatterCandleRadarRenderer.kt | 3 +- .../charting/renderer/RadarChartRenderer.kt | 1 + .../data/BarLineScatterCandleBubbleDataSet.kt | 6 +- .../appdev/charting/data/BubbleDataSet.kt | 0 .../appdev/charting/data/CandleDataSet.kt | 5 -- .../appdev/charting/data/LineRadarDataSet.kt | 25 ++---- .../data/LineScatterCandleRadarDataSet.kt | 8 +- .../info/appdev/charting/data/PieDataSet.kt | 2 - .../info/appdev/charting/data/RadarDataSet.kt | 6 +- .../IBarLineScatterCandleBubbleDataSet.kt | 0 .../interfaces/datasets/IBubbleDataSet.kt | 0 .../interfaces/datasets/ICandleDataSet.kt | 0 .../interfaces/datasets/ILineRadarDataSet.kt | 6 -- .../ILineScatterCandleRadarDataSet.kt | 6 +- .../interfaces/datasets/IPieDataSet.kt | 0 .../interfaces/datasets/IRadarDataSet.kt | 0 22 files changed, 142 insertions(+), 46 deletions(-) create mode 100644 chartLib/src/main/kotlin/info/appdev/charting/data/LineRadarDataSetAndroid.kt rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/data/BarLineScatterCandleBubbleDataSet.kt (83%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/data/BubbleDataSet.kt (100%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/data/CandleDataSet.kt (98%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/data/LineRadarDataSet.kt (74%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/data/LineScatterCandleRadarDataSet.kt (93%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/data/PieDataSet.kt (98%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/data/RadarDataSet.kt (94%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/interfaces/datasets/IBarLineScatterCandleBubbleDataSet.kt (100%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/interfaces/datasets/IBubbleDataSet.kt (100%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/interfaces/datasets/ICandleDataSet.kt (100%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/interfaces/datasets/ILineRadarDataSet.kt (79%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/interfaces/datasets/ILineScatterCandleRadarDataSet.kt (80%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/interfaces/datasets/IPieDataSet.kt (100%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/interfaces/datasets/IRadarDataSet.kt (100%) diff --git a/KotlinMultiplatform.md b/KotlinMultiplatform.md index adec43dc..9b81314e 100644 --- a/KotlinMultiplatform.md +++ b/KotlinMultiplatform.md @@ -736,6 +736,92 @@ lint Existing custom lint rules (unchanged) rendering, etc.), most of which *do* need the concrete `DataSet` classes ported to `chartLibCore` first (a prerequisite not yet started) before they can be tackled the same way. +- [x] **Step A.3 (continuation, part 2) — concrete `DataSet` family (Bar/Line/ + Scatter/Candle/Bubble/Radar/Pie) prerequisite for Step A.7's renderer + port.** The Step A.7 grid-line slice above surfaced that most of the + remaining ~30 renderer files need concrete chart-type `DataSet` classes + (`LineDataSet`/`BarDataSet`/etc.), which hadn't moved to `chartLibCore` + yet even though the abstract `DataSet`/`BaseDataSet`/`IDataSet` base + already had (Step A.3 completion). This slice migrates every concrete + `DataSet` class that has *no* remaining Android-only blocker, and + leaves the ones that genuinely do (documented below) as Android-only + leaves extending the now-common parent classes — same pattern as + `IMarker`/highlighters in Steps A.4/A.5. + - **Moved to `chartLibCore` commonMain** (same package/class names): + `data/BarLineScatterCandleBubbleDataSet.kt`, + `data/LineScatterCandleRadarDataSet.kt`, `data/LineRadarDataSet.kt`, + `data/BubbleDataSet.kt`, `data/CandleDataSet.kt`, `data/PieDataSet.kt`, + `data/RadarDataSet.kt`, and their interfaces + `interfaces/datasets/IBarLineScatterCandleBubbleDataSet.kt`, + `ILineScatterCandleRadarDataSet.kt`, `ILineRadarDataSet.kt`, + `IBubbleDataSet.kt`, `ICandleDataSet.kt`, `IPieDataSet.kt`, + `IRadarDataSet.kt`. `BubbleDataSet`/`PieDataSet`/`CandleDataSet`/ + `RadarDataSet` are now fully portable leaf classes; the abstract + parents unblock any future common data renderer for those chart + types. + - Trivial Android cleanup applied throughout: `@ColorInt` annotations + dropped (non-functional at runtime); `android.graphics.Color.rgb(...)`/ + `Color.WHITE` literals replaced with the existing common + `ColorTemplate.argb(r, g, b)` helper (already used elsewhere in + `chartLibCore`); the `convertDpToPixel()` calls in these files already + resolved to the common `Float.convertDpToPixel()` extension + established in Step A.5 (backed by the global `chartDensity` + set via `Context.initUtils()`), so no changes were needed there. + - `ILineScatterCandleRadarDataSet.dashPathEffectHighlight`/ + `LineScatterCandleRadarDataSet.enableDashedHighlightLine(...)`: + `android.graphics.DashPathEffect` replaced with the common `DashEffect` + type (Step A.5's `DashEffect`/`toAndroidDashPathEffect()` pattern, + reused as-is). One render call site updated, + `LineScatterCandleRadarRenderer.drawHighlightLines(...)` + (`paintHighlight.pathEffect = set.dashPathEffectHighlight` → + `...?.toAndroidDashPathEffect()`). + - **`fillDrawable` (on `ILineRadarDataSet`/`LineRadarDataSet`) removed + from common code entirely** and replaced with a new Android-only + side-channel: `chartLib/data/LineRadarDataSetAndroid.kt` defines + `var ILineRadarDataSet<*>.fillDrawable: Drawable?` as an extension + property backed by a `WeakHashMap, Drawable?>` + keyed by dataset identity. `android.graphics.drawable.Drawable` has no + portable equivalent (unlike `DashPathEffect`/`Paint.Style`, which + already had common replacements), so — matching the established + extension-property pattern from Step A.3's `BaseDataSet.setColors` — + this keeps `dataSet.fillDrawable = ...`/`dataSet.fillDrawable` + call-site syntax completely unchanged for both `LineDataSet` (still + Android-only, stays in `chartLib`) and `RadarDataSet` (now common), + at the cost of adding an explicit + `import info.appdev.charting.data.fillDrawable` at each call site + (`LineChartRenderer.kt`, `RadarChartRenderer.kt`, and three `app` + example activities that set a custom fill drawable). + - **Two small disclosed behavior changes**, documented in + `LineRadarDataSet`'s class doc, both around the removed `fillDrawable` + stored property: setting `fillColor` no longer implicitly clears a + previously set `fillDrawable` (previously an automatic side effect of + the old stored-property setter), and `.copy()` no longer propagates + `fillDrawable` to the copy. Callers relying on either behavior should + set `fillDrawable` explicitly afterward — same category of + already-disclosed minor breaking change as `PercentFormatter`'s + constructor change in Step A.5. + - **Left as Android-only leaves** (extending the now-common abstract + parents, same as `IMarker`/highlighters in Steps A.4/A.5): `BarDataSet` + (needs the heavily Canvas/Paint/Drawable/LinearGradient-coupled `Fill` + class, which itself would need the same common-type-plus-Android- + renderer split as `DashEffect`/`PaintStyle` — out of scope for this + slice), `LineDataSet` (needs `Fill`, `Context`/`ContextCompat`-based + drawable-resource fill loading, and `IFillFormatter`, which in turn + depends on the permanently-Android-only `LineDataProvider` from Step + A.4), and `ScatterDataSet`/`IScatterDataSet` (needs the + Canvas/Paint-coupled `IShapeRenderer` family in + `renderer/scatter/`). + - Verified: `chartLibCore:compileKotlinDesktop` (chartLibCore's own + compile), full `chartLibCore:allTests`, `chartLib:compileDebugKotlin`, + full `./gradlew test`, `chartLibCompose:assembleDebug`, + `app:assembleDebug`, and `chartLibComposeMultiplatform:build` (all + targets, since it depends on `chartLibCore`) all pass. + - **Not yet done**: `Fill` splitting (common data + Android drawing + extension) to unblock `BarDataSet`/`LineDataSet`; `IFillFormatter`/ + `LineDataProvider` decoupling; `IShapeRenderer`/`ScatterDataSet`. Any + of these would be reasonable next slices before resuming the Step A.7 + renderer port for Bar/Line/Scatter charts (Candle/Radar/Bubble/Pie + renderers are unblocked already on the data-model side). - [ ] **Step A.8 — Gesture handling** with `pointerInput`/`detectTransformGestures` replacing `ChartTouchListener`/`MotionEvent`. - [ ] **Step A.9 — `demoKmp` Compose Multiplatform demo app** diff --git a/app/src/main/kotlin/info/appdev/chartexample/DataTools.kt b/app/src/main/kotlin/info/appdev/chartexample/DataTools.kt index 87d3742d..6b4d983a 100644 --- a/app/src/main/kotlin/info/appdev/chartexample/DataTools.kt +++ b/app/src/main/kotlin/info/appdev/chartexample/DataTools.kt @@ -8,6 +8,7 @@ import info.appdev.charting.charts.LineChart import info.appdev.charting.data.EntryFloat import info.appdev.charting.data.LineData import info.appdev.charting.data.LineDataSet +import info.appdev.charting.data.fillDrawable import info.appdev.charting.formatter.IFillFormatter import info.appdev.charting.interfaces.dataprovider.LineDataProvider import info.appdev.charting.interfaces.datasets.ILineDataSet diff --git a/app/src/main/kotlin/info/appdev/chartexample/GradientActivity.kt b/app/src/main/kotlin/info/appdev/chartexample/GradientActivity.kt index c3fda989..251c0bbd 100644 --- a/app/src/main/kotlin/info/appdev/chartexample/GradientActivity.kt +++ b/app/src/main/kotlin/info/appdev/chartexample/GradientActivity.kt @@ -9,6 +9,7 @@ import info.appdev.chartexample.notimportant.DemoBase import info.appdev.charting.data.EntryFloat import info.appdev.charting.data.LineData import info.appdev.charting.data.LineDataSet +import info.appdev.charting.data.fillDrawable import info.appdev.charting.formatter.IFillFormatter import info.appdev.charting.interfaces.dataprovider.LineDataProvider import info.appdev.charting.interfaces.datasets.ILineDataSet diff --git a/app/src/main/kotlin/info/appdev/chartexample/SpecificPositionsLineChartActivity.kt b/app/src/main/kotlin/info/appdev/chartexample/SpecificPositionsLineChartActivity.kt index 8abb5d61..ee138021 100644 --- a/app/src/main/kotlin/info/appdev/chartexample/SpecificPositionsLineChartActivity.kt +++ b/app/src/main/kotlin/info/appdev/chartexample/SpecificPositionsLineChartActivity.kt @@ -23,6 +23,7 @@ import info.appdev.charting.components.LimitLine.LimitLabelPosition import info.appdev.charting.data.EntryFloat import info.appdev.charting.data.LineData import info.appdev.charting.data.LineDataSet +import info.appdev.charting.data.fillDrawable import info.appdev.charting.highlight.Highlight import info.appdev.charting.interfaces.datasets.ILineDataSet import info.appdev.charting.listener.ChartTouchListener.ChartGesture diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/LineRadarDataSetAndroid.kt b/chartLib/src/main/kotlin/info/appdev/charting/data/LineRadarDataSetAndroid.kt new file mode 100644 index 00000000..9117132d --- /dev/null +++ b/chartLib/src/main/kotlin/info/appdev/charting/data/LineRadarDataSetAndroid.kt @@ -0,0 +1,30 @@ +package info.appdev.charting.data + +import android.graphics.drawable.Drawable +import info.appdev.charting.interfaces.datasets.ILineRadarDataSet +import java.util.WeakHashMap + +/** + * Android-only side-channel storage for the `fillDrawable` property that used to live directly + * on `ILineRadarDataSet`/`LineRadarDataSet`. `android.graphics.drawable.Drawable` has no + * portable equivalent, so it can no longer be a stored property on the now-common + * `LineRadarDataSet` (used by both the Android-only `LineDataSet` and the fully portable + * `RadarDataSet`). Backed by a [WeakHashMap] keyed by dataset identity so it doesn't leak + * datasets that are no longer referenced elsewhere. + * + * Note: unlike the old stored property, setting [ILineRadarDataSet.fillColor] no longer + * implicitly clears a previously set `fillDrawable`, and `.copy()` no longer propagates + * `fillDrawable` to the copy (see `LineRadarDataSet`'s class doc in `chartLibCore`). + */ +private val fillDrawables = WeakHashMap, Drawable?>() + +/** + * The drawable used for filling the area below the line (line/radar charts), as an + * alternative to a solid [ILineRadarDataSet.fillColor]. Android-only, since it operates on + * `android.graphics.drawable.Drawable`. + */ +var ILineRadarDataSet<*>.fillDrawable: Drawable? + get() = fillDrawables[this] + set(value) { + fillDrawables[this] = value + } diff --git a/chartLib/src/main/kotlin/info/appdev/charting/renderer/LineChartRenderer.kt b/chartLib/src/main/kotlin/info/appdev/charting/renderer/LineChartRenderer.kt index 38bb2fce..dc6481c3 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/renderer/LineChartRenderer.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/renderer/LineChartRenderer.kt @@ -10,6 +10,7 @@ import android.graphics.Path import info.appdev.charting.animation.ChartAnimator import info.appdev.charting.data.BaseEntry import info.appdev.charting.data.LineDataSet +import info.appdev.charting.data.fillDrawable import info.appdev.charting.highlight.Highlight import info.appdev.charting.interfaces.dataprovider.LineDataProvider import info.appdev.charting.interfaces.datasets.IBarLineScatterCandleBubbleDataSet diff --git a/chartLib/src/main/kotlin/info/appdev/charting/renderer/LineScatterCandleRadarRenderer.kt b/chartLib/src/main/kotlin/info/appdev/charting/renderer/LineScatterCandleRadarRenderer.kt index 13f8ad48..27cbb752 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/renderer/LineScatterCandleRadarRenderer.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/renderer/LineScatterCandleRadarRenderer.kt @@ -6,6 +6,7 @@ import android.graphics.Path import info.appdev.charting.animation.ChartAnimator import info.appdev.charting.interfaces.datasets.ILineScatterCandleRadarDataSet import info.appdev.charting.utils.ViewPortHandler +import info.appdev.charting.utils.toAndroidDashPathEffect abstract class LineScatterCandleRadarRenderer( animator: ChartAnimator, @@ -31,7 +32,7 @@ abstract class LineScatterCandleRadarRenderer( paintHighlight.strokeWidth = set.highlightLineWidth // draw highlighted lines (if enabled) - paintHighlight.pathEffect = set.dashPathEffectHighlight + paintHighlight.pathEffect = set.dashPathEffectHighlight?.toAndroidDashPathEffect() // draw vertical highlight lines if (set.isVerticalHighlightIndicator) { diff --git a/chartLib/src/main/kotlin/info/appdev/charting/renderer/RadarChartRenderer.kt b/chartLib/src/main/kotlin/info/appdev/charting/renderer/RadarChartRenderer.kt index 5c0c8ba0..90c543cb 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/renderer/RadarChartRenderer.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/renderer/RadarChartRenderer.kt @@ -8,6 +8,7 @@ import androidx.annotation.ColorInt import androidx.core.graphics.withSave import info.appdev.charting.animation.ChartAnimator import info.appdev.charting.charts.RadarChart +import info.appdev.charting.data.fillDrawable import info.appdev.charting.highlight.Highlight import info.appdev.charting.interfaces.datasets.IRadarDataSet import info.appdev.charting.utils.ColorTemplate diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/BarLineScatterCandleBubbleDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BarLineScatterCandleBubbleDataSet.kt similarity index 83% rename from chartLib/src/main/kotlin/info/appdev/charting/data/BarLineScatterCandleBubbleDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BarLineScatterCandleBubbleDataSet.kt index 2751e470..f0bf6bfb 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/BarLineScatterCandleBubbleDataSet.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BarLineScatterCandleBubbleDataSet.kt @@ -1,8 +1,7 @@ package info.appdev.charting.data -import android.graphics.Color -import androidx.annotation.ColorInt import info.appdev.charting.interfaces.datasets.IBarLineScatterCandleBubbleDataSet +import info.appdev.charting.utils.ColorTemplate /** * Baseclass of all DataSets for Bar-, Line-, Scatter- and CandleStickChart. @@ -12,8 +11,7 @@ abstract class BarLineScatterCandleBubbleDataSet>(yVals: Mu /** * Sets the color that is used for drawing the highlight indicators. */ - @ColorInt - override var highLightColor: Int = Color.rgb(255, 187, 115) + override var highLightColor: Int = ColorTemplate.argb(255, 187, 115) protected fun copy(barLineScatterCandleBubbleDataSet: BarLineScatterCandleBubbleDataSet<*>) { super.copy((barLineScatterCandleBubbleDataSet as BaseDataSet<*>?)!!) diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/BubbleDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BubbleDataSet.kt similarity index 100% rename from chartLib/src/main/kotlin/info/appdev/charting/data/BubbleDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BubbleDataSet.kt diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/CandleDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/CandleDataSet.kt similarity index 98% rename from chartLib/src/main/kotlin/info/appdev/charting/data/CandleDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/CandleDataSet.kt index dd461ce2..48545466 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/CandleDataSet.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/CandleDataSet.kt @@ -1,6 +1,5 @@ package info.appdev.charting.data -import androidx.annotation.ColorInt import info.appdev.charting.interfaces.datasets.ICandleDataSet import info.appdev.charting.utils.ColorTemplate import info.appdev.charting.utils.PaintStyle @@ -48,25 +47,21 @@ open class CandleDataSet(yVals: MutableList, label: String = " /** * color for open == close */ - @ColorInt protected var mNeutralColor: Int = ColorTemplate.COLOR_SKIP /** * color for open < close */ - @ColorInt protected var mIncreasingColor: Int = ColorTemplate.COLOR_SKIP /** * color for open > close */ - @ColorInt protected var mDecreasingColor: Int = ColorTemplate.COLOR_SKIP /** * shadow line color, set -1 for backward compatibility and uses default color */ - @ColorInt protected var mShadowColor: Int = ColorTemplate.COLOR_SKIP override fun copy(): DataSet { diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/LineRadarDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/LineRadarDataSet.kt similarity index 74% rename from chartLib/src/main/kotlin/info/appdev/charting/data/LineRadarDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/LineRadarDataSet.kt index c1b27095..fb28bd26 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/LineRadarDataSet.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/LineRadarDataSet.kt @@ -1,29 +1,25 @@ package info.appdev.charting.data -import android.graphics.Color -import android.graphics.drawable.Drawable -import androidx.annotation.ColorInt import info.appdev.charting.interfaces.datasets.ILineRadarDataSet +import info.appdev.charting.utils.ColorTemplate import info.appdev.charting.utils.convertDpToPixel /** * Base dataset for line and radar DataSets. + * + * Note: the Android-only `fillDrawable` property (drawable-based line/area fill, as opposed + * to a solid [fillColor]) lives outside this common class, as an extension property in + * `chartLib`'s `LineRadarDataSetAndroid.kt` (`android.graphics.drawable.Drawable` has no + * portable equivalent). As a result, setting [fillColor] here no longer implicitly clears a + * previously set `fillDrawable`, and [copy] no longer propagates `fillDrawable` to the copy; + * callers relying on either behavior should set `fillDrawable` explicitly. */ abstract class LineRadarDataSet>(yVals: MutableList, label: String) : LineScatterCandleRadarDataSet(yVals, label), ILineRadarDataSet { // TODO: Move to using `Fill` class /** * the color that is used for filling the line surface */ - @ColorInt - private var mFillColor = Color.rgb(140, 234, 255) - - /** - * Sets the drawable to be used to fill the area below the line. - */ - /** - * the drawable to be used for filling the line surface - */ - override var fillDrawable: Drawable? = null + private var mFillColor = ColorTemplate.argb(140, 234, 255) /** * sets the alpha value (transparency) that is used for filling the line @@ -48,11 +44,9 @@ abstract class LineRadarDataSet>(yVals: MutableList, lab get() = mFillColor /** * Sets the color that is used for filling the area below the line. - * Resets an eventually set "fillDrawable". */ set(color) { mFillColor = color - this.fillDrawable = null } override var lineWidth: Float @@ -73,7 +67,6 @@ abstract class LineRadarDataSet>(yVals: MutableList, lab lineRadarDataSet.isDrawFilled = this.isDrawFilled lineRadarDataSet.fillAlpha = this.fillAlpha lineRadarDataSet.mFillColor = mFillColor - lineRadarDataSet.fillDrawable = this.fillDrawable lineRadarDataSet.mLineWidth = mLineWidth } } diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/LineScatterCandleRadarDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/LineScatterCandleRadarDataSet.kt similarity index 93% rename from chartLib/src/main/kotlin/info/appdev/charting/data/LineScatterCandleRadarDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/LineScatterCandleRadarDataSet.kt index 5a2a9a81..81d46bb6 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/LineScatterCandleRadarDataSet.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/LineScatterCandleRadarDataSet.kt @@ -1,7 +1,7 @@ package info.appdev.charting.data -import android.graphics.DashPathEffect import info.appdev.charting.interfaces.datasets.ILineScatterCandleRadarDataSet +import info.appdev.charting.utils.DashEffect import info.appdev.charting.utils.convertDpToPixel abstract class LineScatterCandleRadarDataSet>(yVals: MutableList, label: String) : BarLineScatterCandleBubbleDataSet(yVals, label), @@ -12,8 +12,8 @@ abstract class LineScatterCandleRadarDataSet>(yVals: Mutabl /** the width of the highlight indicator lines */ protected var mHighlightLineWidth: Float - /** the path effect for dashed highlight-lines */ - override var dashPathEffectHighlight: DashPathEffect? = null + /** the dash effect for dashed highlight-lines */ + override var dashPathEffectHighlight: DashEffect? = null protected set init { @@ -58,7 +58,7 @@ abstract class LineScatterCandleRadarDataSet>(yVals: Mutabl * @param phase offset, in degrees (normally, use 0) */ fun enableDashedHighlightLine(lineLength: Float, spaceLength: Float, phase: Float) { - this.dashPathEffectHighlight = DashPathEffect( + this.dashPathEffectHighlight = DashEffect( floatArrayOf(lineLength, spaceLength), phase ) diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/PieDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/PieDataSet.kt similarity index 98% rename from chartLib/src/main/kotlin/info/appdev/charting/data/PieDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/PieDataSet.kt index aa0161ee..83fe37c7 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/PieDataSet.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/PieDataSet.kt @@ -1,6 +1,5 @@ package info.appdev.charting.data -import androidx.annotation.ColorInt import info.appdev.charting.interfaces.datasets.IPieDataSet import info.appdev.charting.utils.convertDpToPixel @@ -26,7 +25,6 @@ open class PieDataSet(yVals: MutableList, label: String) : DataSe private var mValueLinePart2Length = 0.4f private var mIsValueLineVariableLength = true - @ColorInt private var mHighlightColor: Int? = null override fun copy(): DataSet { diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/RadarDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/RadarDataSet.kt similarity index 94% rename from chartLib/src/main/kotlin/info/appdev/charting/data/RadarDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/RadarDataSet.kt index d4d3ce26..8108a22c 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/RadarDataSet.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/RadarDataSet.kt @@ -1,7 +1,5 @@ package info.appdev.charting.data -import android.graphics.Color -import androidx.annotation.ColorInt import info.appdev.charting.interfaces.datasets.IRadarDataSet import info.appdev.charting.utils.ColorTemplate @@ -9,12 +7,10 @@ open class RadarDataSet(yVals: MutableList, label: String = "") /** flag indicating whether highlight circle should be drawn or not */ protected var mIsDrawHighlightCircle: Boolean = false - @ColorInt - protected var mHighlightCircleFillColor: Int = Color.WHITE + protected var mHighlightCircleFillColor: Int = ColorTemplate.argb(255, 255, 255) /** The stroke color for highlight circle. * If Utils.COLOR_NONE, the color of the dataset is taken. */ - @ColorInt protected var mHighlightCircleStrokeColor: Int = ColorTemplate.COLOR_NONE protected var mHighlightCircleStrokeAlpha: Int = (0.3 * 255).toInt() diff --git a/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/IBarLineScatterCandleBubbleDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/IBarLineScatterCandleBubbleDataSet.kt similarity index 100% rename from chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/IBarLineScatterCandleBubbleDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/IBarLineScatterCandleBubbleDataSet.kt diff --git a/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/IBubbleDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/IBubbleDataSet.kt similarity index 100% rename from chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/IBubbleDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/IBubbleDataSet.kt diff --git a/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/ICandleDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/ICandleDataSet.kt similarity index 100% rename from chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/ICandleDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/ICandleDataSet.kt diff --git a/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/ILineRadarDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/ILineRadarDataSet.kt similarity index 79% rename from chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/ILineRadarDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/ILineRadarDataSet.kt index 31ebc998..d6287e1d 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/ILineRadarDataSet.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/ILineRadarDataSet.kt @@ -1,6 +1,5 @@ package info.appdev.charting.interfaces.datasets -import android.graphics.drawable.Drawable import info.appdev.charting.data.BaseEntry interface ILineRadarDataSet> : ILineScatterCandleRadarDataSet { @@ -9,11 +8,6 @@ interface ILineRadarDataSet> : ILineScatterCandleRadarDataS */ val fillColor: Int - /** - * Returns the drawable used for filling the area below the line. - */ - val fillDrawable: Drawable? - /** * Returns the alpha value that is used for filling the line surface, * default: 85 diff --git a/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/ILineScatterCandleRadarDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/ILineScatterCandleRadarDataSet.kt similarity index 80% rename from chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/ILineScatterCandleRadarDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/ILineScatterCandleRadarDataSet.kt index 8e1f2868..975465df 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/ILineScatterCandleRadarDataSet.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/ILineScatterCandleRadarDataSet.kt @@ -1,7 +1,7 @@ package info.appdev.charting.interfaces.datasets -import android.graphics.DashPathEffect import info.appdev.charting.data.BaseEntry +import info.appdev.charting.utils.DashEffect interface ILineScatterCandleRadarDataSet> : IBarLineScatterCandleBubbleDataSet { /** @@ -20,7 +20,7 @@ interface ILineScatterCandleRadarDataSet> : IBarLineScatter val highlightLineWidth: Float /** - * Returns the DashPathEffect that is used for highlighting. + * Returns the dash effect that is used for highlighting. */ - val dashPathEffectHighlight: DashPathEffect? + val dashPathEffectHighlight: DashEffect? } diff --git a/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/IPieDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/IPieDataSet.kt similarity index 100% rename from chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/IPieDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/IPieDataSet.kt diff --git a/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/IRadarDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/IRadarDataSet.kt similarity index 100% rename from chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/IRadarDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/IRadarDataSet.kt From fcc1b0b11a5e7635d97a0960d5931aff570fdd78 Mon Sep 17 00:00:00 2001 From: Hannes Achleitner Date: Sun, 27 Sep 2026 11:32:14 +0200 Subject: [PATCH 15/22] Step A.3 (continuation, part 3): split Fill class, migrate BarDataSet - chartLibCore/utils/Fill.kt: portable data-only state (type, color, alpha, gradientColors/gradientPositions, finalColor). Dropped @ColorInt. - chartLib/utils/FillAndroid.kt (new): Android-only Fill.fillRect()/ Fill.fillPath() extension functions (ported verbatim) plus a WeakHashMap-backed Fill.drawable extension property for the Type.DRAWABLE payload, same side-channel pattern as ILineRadarDataSet.fillDrawable. - BarChartRenderer.kt / HorizontalBarChartRenderer.kt: added `import info.appdev.charting.utils.fillRect` (no call-site changes needed). - Moved data/BarDataSet.kt and interfaces/datasets/IBarDataSet.kt to chartLibCore; replaced Color.rgb(...)/Color.BLACK with ColorTemplate.argb(...)/ColorTemplate.BLACK, dropped @ColorInt. BarDataSet is now a fully portable leaf class. - LineDataSet/ScatterDataSet remain Android-only (still blocked on IFillFormatter/LineDataProvider and IShapeRenderer respectively). - No disclosed behavior changes. - Updated KotlinMultiplatform.md with the changelog entry. Verified: chartLibCore:allTests, chartLib:compileDebugKotlin, chartLibCompose:assembleDebug, app:assembleDebug, full ./gradlew test, and chartLibComposeMultiplatform:build (Android/desktop/iosArm64/ iosSimulatorArm64) all pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- KotlinMultiplatform.md | 60 +++++ .../charting/renderer/BarChartRenderer.kt | 1 + .../renderer/HorizontalBarChartRenderer.kt | 1 + .../kotlin/info/appdev/charting/utils/Fill.kt | 248 ------------------ .../info/appdev/charting/utils/FillAndroid.kt | 185 +++++++++++++ .../info/appdev/charting/data/BarDataSet.kt | 16 +- .../interfaces/datasets/IBarDataSet.kt | 0 .../kotlin/info/appdev/charting/utils/Fill.kt | 81 ++++++ 8 files changed, 334 insertions(+), 258 deletions(-) delete mode 100644 chartLib/src/main/kotlin/info/appdev/charting/utils/Fill.kt create mode 100644 chartLib/src/main/kotlin/info/appdev/charting/utils/FillAndroid.kt rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/data/BarDataSet.kt (91%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/interfaces/datasets/IBarDataSet.kt (100%) create mode 100644 chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/Fill.kt diff --git a/KotlinMultiplatform.md b/KotlinMultiplatform.md index 9b81314e..692a71ac 100644 --- a/KotlinMultiplatform.md +++ b/KotlinMultiplatform.md @@ -822,6 +822,66 @@ lint Existing custom lint rules (unchanged) of these would be reasonable next slices before resuming the Step A.7 renderer port for Bar/Line/Scatter charts (Candle/Radar/Bubble/Pie renderers are unblocked already on the data-model side). +- [x] **Step A.3 (continuation, part 3) — split the `Fill` class, migrate + `BarDataSet`.** Tackles the first of the three blockers flagged above. + `Fill` (`utils/Fill.kt`) mixed a small amount of portable state + (`type`/`color`/`alpha`/`gradientColors`/`gradientPositions`/the derived + `finalColor`) with Canvas/Paint/Drawable/LinearGradient-based drawing + logic (`fillRect(...)`/`fillPath(...)`) and a `Drawable` payload for + `Type.DRAWABLE` — none of which have a portable KMP equivalent. + - **`chartLibCore/utils/Fill.kt`** now holds only the portable state: + `Type`/`Direction` enums, `type`, `color`, `alpha`, + `gradientColors`/`gradientPositions`, the `startColor,endColor` + gradient constructor, and `setGradientColors(...)`. The previously + `private var mFinalColor` is exposed as a new public read-only + `val finalColor: Int?` property (was only accessible internally to + `fillRect`/`fillPath` before) so the Android-only drawing extensions + can read it. `@ColorInt` dropped (non-functional at runtime, same as + prior slices). + - **New `chartLib/utils/FillAndroid.kt`** holds everything + Android-only: `fun Fill.fillRect(...)`/`fun Fill.fillPath(...)` as + extension functions (ported verbatim from the old member functions, + just reading `finalColor` instead of the private `mFinalColor`), plus + `var Fill.drawable: Drawable?` as a `WeakHashMap`-backed + extension property for the `Type.DRAWABLE` payload — same side-channel + pattern as `ILineRadarDataSet.fillDrawable` from continuation part 2. + (Note: `Type.DRAWABLE`/`drawable` were already dead code with no + public setter anywhere in the codebase before this slice; the + side-channel exists for API completeness/future use, not because + anything currently exercises it.) + - Two call sites (`BarChartRenderer.kt`, `HorizontalBarChartRenderer.kt`, + both calling `dataSet.getFill(pos)?.fillRect(...)`) needed only an + added `import info.appdev.charting.utils.fillRect` — no call-site + syntax changes, since Kotlin resolves member-call syntax + (`fill.fillRect(...)`) identically whether `fillRect` is a member or + an extension function. + - **Moved to `chartLibCore` commonMain**: `data/BarDataSet.kt`, + `interfaces/datasets/IBarDataSet.kt` — `IBarDataSet` needed no changes + (it was already portable, referencing only `Fill`/`BarEntryFloat`); + `BarDataSet` had `android.graphics.Color.rgb(...)`/`Color.BLACK` + replaced with `ColorTemplate.argb(...)`/`ColorTemplate.BLACK` (the + latter a pre-existing common constant) and `@ColorInt` dropped. + `BarDataSet` is now a fully portable leaf class — first of the three + remaining Android-only `DataSet` leaves to move. + - **`LineDataSet`/`ScatterDataSet` remain Android-only** — `LineDataSet` + still needs `IFillFormatter`/`LineDataProvider` decoupling and + `Context`/`ContextCompat`-based drawable-resource fill loading; + `ScatterDataSet` still needs the `IShapeRenderer` family split. + - No behavior changes disclosed for this slice — `finalColor` was + already computed identically before (just via a private property), + and the dead `Type.DRAWABLE` path behaves the same (returns early + when no drawable is set, same as before when `drawable` was `null`). + - Verified: `chartLibCore:compileKotlinDesktop`, + `chartLib:compileDebugKotlin`, full `chartLibCore:allTests`, + `chartLibCompose:assembleDebug`, `app:assembleDebug`, full + `./gradlew test`, and `chartLibComposeMultiplatform:build` (all + targets) all pass. + - **Not yet done**: `IFillFormatter`/`LineDataProvider` decoupling and + `IShapeRenderer`/`ScatterDataSet` splitting remain as candidate next + slices to fully unblock `LineDataSet`/`ScatterDataSet`; resuming the + Step A.7 renderer port itself (e.g. a Bar/Candle/Radar/Bubble/Pie data + renderer proof-of-concept) is also now viable given the expanded set + of portable `DataSet` leaves. - [ ] **Step A.8 — Gesture handling** with `pointerInput`/`detectTransformGestures` replacing `ChartTouchListener`/`MotionEvent`. - [ ] **Step A.9 — `demoKmp` Compose Multiplatform demo app** diff --git a/chartLib/src/main/kotlin/info/appdev/charting/renderer/BarChartRenderer.kt b/chartLib/src/main/kotlin/info/appdev/charting/renderer/BarChartRenderer.kt index 0d26f25a..d1ab347b 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/renderer/BarChartRenderer.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/renderer/BarChartRenderer.kt @@ -10,6 +10,7 @@ import info.appdev.charting.highlight.Highlight import info.appdev.charting.interfaces.dataprovider.BarDataProvider import info.appdev.charting.interfaces.datasets.IBarDataSet import info.appdev.charting.utils.Fill +import info.appdev.charting.utils.fillRect import info.appdev.charting.utils.PointF import info.appdev.charting.utils.Transformer import info.appdev.charting.utils.ViewPortHandler diff --git a/chartLib/src/main/kotlin/info/appdev/charting/renderer/HorizontalBarChartRenderer.kt b/chartLib/src/main/kotlin/info/appdev/charting/renderer/HorizontalBarChartRenderer.kt index 7b5bc7de..04be4724 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/renderer/HorizontalBarChartRenderer.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/renderer/HorizontalBarChartRenderer.kt @@ -10,6 +10,7 @@ import info.appdev.charting.interfaces.dataprovider.BarDataProvider import info.appdev.charting.interfaces.dataprovider.base.IBaseProvider import info.appdev.charting.interfaces.datasets.IBarDataSet import info.appdev.charting.utils.Fill +import info.appdev.charting.utils.fillRect import info.appdev.charting.utils.PointF import info.appdev.charting.utils.Transformer import info.appdev.charting.utils.ViewPortHandler diff --git a/chartLib/src/main/kotlin/info/appdev/charting/utils/Fill.kt b/chartLib/src/main/kotlin/info/appdev/charting/utils/Fill.kt deleted file mode 100644 index bd3c5a92..00000000 --- a/chartLib/src/main/kotlin/info/appdev/charting/utils/Fill.kt +++ /dev/null @@ -1,248 +0,0 @@ -package info.appdev.charting.utils - -import android.R.attr.endColor -import android.R.attr.startColor -import android.graphics.Canvas -import android.graphics.LinearGradient -import android.graphics.Paint -import android.graphics.Path -import android.graphics.RectF -import android.graphics.Shader -import android.graphics.drawable.Drawable -import androidx.annotation.ColorInt -import kotlin.math.floor -import androidx.core.graphics.withClip - -open class Fill { - enum class Type { - EMPTY, COLOR, LINEAR_GRADIENT, DRAWABLE - } - - enum class Direction { - DOWN, UP, RIGHT, LEFT - } - - /** - * the type of fill - */ - var type: Type = Type.EMPTY - - /** - * the color that is used for filling - */ - @ColorInt - private var mColor: Int? = null - - @ColorInt - private var mFinalColor: Int? = null - - /** - * the drawable to be used for filling - */ - protected var drawable: Drawable? = null - - var gradientColors: IntArray? = null - - var gradientPositions: FloatArray? = null - - /** - * transparency used for filling - */ - private var mAlpha = 255 - - constructor(@ColorInt startColor: Int, @ColorInt endColor: Int) { - this.type = Type.LINEAR_GRADIENT - this.gradientColors = intArrayOf(startColor, endColor) - } - - var color: Int? - get() = mColor - set(color) { - this.mColor = color - calculateFinalColor() - } - - fun setGradientColors(@ColorInt startColor: Int, @ColorInt endColor: Int) { - this.gradientColors = intArrayOf(startColor, endColor) - } - - var alpha: Int - get() = mAlpha - set(alpha) { - this.mAlpha = alpha - calculateFinalColor() - } - - private fun calculateFinalColor() { - if (mColor == null) { - mFinalColor = null - } else { - val alpha = floor(((mColor!! shr 24) / 255.0) * (mAlpha / 255.0) * 255.0).toInt() - mFinalColor = (alpha shl 24) or (mColor!! and 0xffffff) - } - } - - fun fillRect( - canvas: Canvas, paint: Paint, - left: Float, top: Float, right: Float, bottom: Float, - gradientDirection: Direction?, mRoundedBarRadius: Float - ) { - when (this.type) { - Type.EMPTY -> return - - Type.COLOR -> { - if (mFinalColor == null) { - return - } - - if (this.isClipPathSupported) { - canvas.withClip(left, top, right, bottom) { - - canvas.drawColor(mFinalColor!!) - - } - } else { - // save - val previous = paint.style - val previousColor = paint.color - - // set - paint.style = Paint.Style.FILL - paint.color = mFinalColor!! - - canvas.drawRoundRect(RectF(left, top, right, bottom), mRoundedBarRadius, mRoundedBarRadius, paint) - - // restore - paint.color = previousColor - paint.style = previous - } - } - - Type.LINEAR_GRADIENT -> { - val gradient = LinearGradient( - (if (gradientDirection == Direction.RIGHT) - right - else - left) - .toInt().toFloat(), - (if (gradientDirection == Direction.UP) - bottom - else - top) - .toInt().toFloat(), - (when (gradientDirection) { - Direction.RIGHT -> left - Direction.LEFT -> right - else -> left - }).toInt().toFloat(), - (when (gradientDirection) { - Direction.UP -> top - Direction.DOWN -> bottom - else -> top - }).toInt().toFloat(), - this.gradientColors!!, - this.gradientPositions, - Shader.TileMode.MIRROR - ) - - paint.shader = gradient - - canvas.drawRoundRect(RectF(left, top, right, bottom), mRoundedBarRadius, mRoundedBarRadius, paint) - } - - Type.DRAWABLE -> { - if (drawable == null) { - return - } - - drawable!!.setBounds(left.toInt(), top.toInt(), right.toInt(), bottom.toInt()) - drawable!!.draw(canvas) - } - } - } - - fun fillPath( - canvas: Canvas, - path: Path, - paint: Paint, - clipRect: RectF? - ) { - when (this.type) { - Type.EMPTY -> return - - Type.COLOR -> { - if (mFinalColor == null) { - return - } - - if (clipRect != null && this.isClipPathSupported) { - canvas.withClip(path) { - - canvas.drawColor(mFinalColor!!) - - } - } else { - // save - val previous = paint.style - val previousColor = paint.color - - // set - paint.style = Paint.Style.FILL - paint.color = mFinalColor!! - - canvas.drawPath(path, paint) - - // restore - paint.color = previousColor - paint.style = previous - } - } - - Type.LINEAR_GRADIENT -> { - val gradient = LinearGradient( - 0f, - 0f, - canvas.width.toFloat(), - canvas.height.toFloat(), - this.gradientColors!!, - this.gradientPositions, - Shader.TileMode.MIRROR - ) - - paint.shader = gradient - - canvas.drawPath(path, paint) - } - - Type.DRAWABLE -> { - if (drawable == null) { - return - } - - ensureClipPathSupported() - - val save = canvas.save() - canvas.clipPath(path) - - drawable!!.setBounds( - if (clipRect == null) 0 else clipRect.left.toInt(), - if (clipRect == null) 0 else clipRect.top.toInt(), - if (clipRect == null) canvas.width else clipRect.right.toInt(), - if (clipRect == null) canvas.height else clipRect.bottom.toInt() - ) - drawable!!.draw(canvas) - - canvas.restoreToCount(save) - } - } - } - - private val isClipPathSupported: Boolean - get() = getSDKInt() >= 18 - - private fun ensureClipPathSupported() { - if (getSDKInt() < 18) { - throw RuntimeException("Fill-drawables not (yet) supported below API level 18, this code was run on API level \${getSDKInt()}") - } - } -} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/utils/FillAndroid.kt b/chartLib/src/main/kotlin/info/appdev/charting/utils/FillAndroid.kt new file mode 100644 index 00000000..5dd89f21 --- /dev/null +++ b/chartLib/src/main/kotlin/info/appdev/charting/utils/FillAndroid.kt @@ -0,0 +1,185 @@ +package info.appdev.charting.utils + +import android.graphics.Canvas +import android.graphics.LinearGradient +import android.graphics.Paint +import android.graphics.Path +import android.graphics.RectF +import android.graphics.Shader +import android.graphics.drawable.Drawable +import androidx.core.graphics.withClip +import java.util.WeakHashMap + +/** + * Android-only drawing logic and [Drawable] payload for the portable [Fill] class. + * + * `Canvas`/`Paint`/`Drawable`/`LinearGradient` have no portable KMP equivalent, so this stays + * Android-only, mirroring the [Fill] side-channel pattern already used for + * `ILineRadarDataSet.fillDrawable` (see `LineRadarDataSetAndroid.kt`). + */ +private val fillDrawables = WeakHashMap() + +/** + * the drawable to be used for filling when [Fill.type] is [Fill.Type.DRAWABLE] + */ +var Fill.drawable: Drawable? + get() = fillDrawables[this] + set(value) { + fillDrawables[this] = value + } + +private val Fill.isClipPathSupported: Boolean + get() = getSDKInt() >= 18 + +private fun Fill.ensureClipPathSupported() { + if (getSDKInt() < 18) { + throw RuntimeException("Fill-drawables not (yet) supported below API level 18, this code was run on API level \${getSDKInt()}") + } +} + +fun Fill.fillRect( + canvas: Canvas, paint: Paint, + left: Float, top: Float, right: Float, bottom: Float, + gradientDirection: Fill.Direction?, mRoundedBarRadius: Float +) { + when (this.type) { + Fill.Type.EMPTY -> return + + Fill.Type.COLOR -> { + val finalColor = this.finalColor ?: return + + if (this.isClipPathSupported) { + canvas.withClip(left, top, right, bottom) { + + canvas.drawColor(finalColor) + + } + } else { + // save + val previous = paint.style + val previousColor = paint.color + + // set + paint.style = Paint.Style.FILL + paint.color = finalColor + + canvas.drawRoundRect(RectF(left, top, right, bottom), mRoundedBarRadius, mRoundedBarRadius, paint) + + // restore + paint.color = previousColor + paint.style = previous + } + } + + Fill.Type.LINEAR_GRADIENT -> { + val gradient = LinearGradient( + (if (gradientDirection == Fill.Direction.RIGHT) + right + else + left) + .toInt().toFloat(), + (if (gradientDirection == Fill.Direction.UP) + bottom + else + top) + .toInt().toFloat(), + (when (gradientDirection) { + Fill.Direction.RIGHT -> left + Fill.Direction.LEFT -> right + else -> left + }).toInt().toFloat(), + (when (gradientDirection) { + Fill.Direction.UP -> top + Fill.Direction.DOWN -> bottom + else -> top + }).toInt().toFloat(), + this.gradientColors!!, + this.gradientPositions, + Shader.TileMode.MIRROR + ) + + paint.shader = gradient + + canvas.drawRoundRect(RectF(left, top, right, bottom), mRoundedBarRadius, mRoundedBarRadius, paint) + } + + Fill.Type.DRAWABLE -> { + val drawable = this.drawable ?: return + + drawable.setBounds(left.toInt(), top.toInt(), right.toInt(), bottom.toInt()) + drawable.draw(canvas) + } + } +} + +fun Fill.fillPath( + canvas: Canvas, + path: Path, + paint: Paint, + clipRect: RectF? +) { + when (this.type) { + Fill.Type.EMPTY -> return + + Fill.Type.COLOR -> { + val finalColor = this.finalColor ?: return + + if (clipRect != null && this.isClipPathSupported) { + canvas.withClip(path) { + + canvas.drawColor(finalColor) + + } + } else { + // save + val previous = paint.style + val previousColor = paint.color + + // set + paint.style = Paint.Style.FILL + paint.color = finalColor + + canvas.drawPath(path, paint) + + // restore + paint.color = previousColor + paint.style = previous + } + } + + Fill.Type.LINEAR_GRADIENT -> { + val gradient = LinearGradient( + 0f, + 0f, + canvas.width.toFloat(), + canvas.height.toFloat(), + this.gradientColors!!, + this.gradientPositions, + Shader.TileMode.MIRROR + ) + + paint.shader = gradient + + canvas.drawPath(path, paint) + } + + Fill.Type.DRAWABLE -> { + val drawable = this.drawable ?: return + + ensureClipPathSupported() + + val save = canvas.save() + canvas.clipPath(path) + + drawable.setBounds( + if (clipRect == null) 0 else clipRect.left.toInt(), + if (clipRect == null) 0 else clipRect.top.toInt(), + if (clipRect == null) canvas.width else clipRect.right.toInt(), + if (clipRect == null) canvas.height else clipRect.bottom.toInt() + ) + drawable.draw(canvas) + + canvas.restoreToCount(save) + } + } +} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/BarDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BarDataSet.kt similarity index 91% rename from chartLib/src/main/kotlin/info/appdev/charting/data/BarDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BarDataSet.kt index 1e94ef0c..5be7ee00 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/BarDataSet.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/BarDataSet.kt @@ -1,8 +1,7 @@ package info.appdev.charting.data -import android.graphics.Color -import androidx.annotation.ColorInt import info.appdev.charting.interfaces.datasets.IBarDataSet +import info.appdev.charting.utils.ColorTemplate import info.appdev.charting.utils.Fill open class BarDataSet(yVals: MutableList, label: String) : BarLineScatterCandleBubbleDataSet(yVals, label), IBarDataSet { @@ -15,13 +14,11 @@ open class BarDataSet(yVals: MutableList, label: String) : BarLin /** * the color used for drawing the bar shadows */ - @ColorInt - private var mBarShadowColor = Color.rgb(215, 215, 215) + private var mBarShadowColor = ColorTemplate.argb(215, 215, 215) private var mBarBorderWidth = 0.0f - @ColorInt - private var mBarBorderColor = Color.BLACK + private var mBarBorderColor = ColorTemplate.BLACK /** * the alpha value used to draw the highlight indicator bar @@ -44,7 +41,7 @@ open class BarDataSet(yVals: MutableList, label: String) : BarLin protected set init { - highLightColor = Color.rgb(0, 0, 0) + highLightColor = ColorTemplate.BLACK calcStackSize(yVals) calcEntryCountIncludingStacks(yVals) @@ -87,7 +84,7 @@ open class BarDataSet(yVals: MutableList, label: String) : BarLin /** * Sets the start and end color for gradient color, ONLY color that should be used for this DataSet. */ - fun setGradientColor(@ColorInt startColor: Int, @ColorInt endColor: Int) { + fun setGradientColor(startColor: Int, endColor: Int) { gradients.clear() gradients.add(Fill(startColor, endColor)) } @@ -150,8 +147,7 @@ open class BarDataSet(yVals: MutableList, label: String) : BarLin /** * Sets the color used for drawing the bar-shadows. The bar shadows is a - * surface behind the bar that indicates the maximum value. Don't for get to - * use getResources().getColor(...) to set this. Or Color.rgb(...). + * surface behind the bar that indicates the maximum value. */ override var barShadowColor: Int get() = mBarShadowColor diff --git a/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/IBarDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/IBarDataSet.kt similarity index 100% rename from chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/IBarDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/IBarDataSet.kt diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/Fill.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/Fill.kt new file mode 100644 index 00000000..4d62c73a --- /dev/null +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/utils/Fill.kt @@ -0,0 +1,81 @@ +package info.appdev.charting.utils + +import kotlin.math.floor + +/** + * Portable, data-only description of a fill (solid color, linear gradient, or drawable-based). + * + * The actual Canvas/Paint/Drawable/LinearGradient-based drawing logic (`fillRect`/`fillPath`) + * has no portable KMP equivalent, so it lives as Android-only extension functions in + * `chartLib`'s `FillAndroid.kt`. The same applies to the `drawable` payload used by + * [Type.DRAWABLE], which is exposed there as an extension property. + */ +open class Fill { + enum class Type { + EMPTY, COLOR, LINEAR_GRADIENT, DRAWABLE + } + + enum class Direction { + DOWN, UP, RIGHT, LEFT + } + + /** + * the type of fill + */ + var type: Type = Type.EMPTY + + /** + * the color that is used for filling + */ + private var mColor: Int? = null + + private var mFinalColor: Int? = null + + var gradientColors: IntArray? = null + + var gradientPositions: FloatArray? = null + + /** + * transparency used for filling + */ + private var mAlpha = 255 + + constructor(startColor: Int, endColor: Int) { + this.type = Type.LINEAR_GRADIENT + this.gradientColors = intArrayOf(startColor, endColor) + } + + var color: Int? + get() = mColor + set(color) { + this.mColor = color + calculateFinalColor() + } + + fun setGradientColors(startColor: Int, endColor: Int) { + this.gradientColors = intArrayOf(startColor, endColor) + } + + var alpha: Int + get() = mAlpha + set(alpha) { + this.mAlpha = alpha + calculateFinalColor() + } + + /** + * The final ARGB color to use for filling, combining [color] and [alpha]. + * Exposed so that the Android-only drawing extensions (`fillRect`/`fillPath`) can use it. + */ + val finalColor: Int? + get() = mFinalColor + + private fun calculateFinalColor() { + if (mColor == null) { + mFinalColor = null + } else { + val alpha = floor(((mColor!! shr 24) / 255.0) * (mAlpha / 255.0) * 255.0).toInt() + mFinalColor = (alpha shl 24) or (mColor!! and 0xffffff) + } + } +} From 27a2baba4a0f27493824fbc124bc5ce71595e6d7 Mon Sep 17 00:00:00 2001 From: Hannes Achleitner Date: Sun, 27 Sep 2026 11:44:23 +0200 Subject: [PATCH 16/22] Step A.3 (continuation, part 4): decouple IFillFormatter, migrate LineDataSet - LineDataSet's dashPathEffect/enableDashedLine now use the common DashEffect type instead of android.graphics.DashPathEffect (reusing Step A.5's DashEffect/toAndroidDashPathEffect() pattern). Updated LineChartRenderer.kt's pathEffect assignment accordingly. - fillFormatter removed from ILineDataSet/LineDataSet entirely (IFillFormatter depends on the permanently-Android-only LineDataProvider). New chartLib/data/LineDataSetAndroid.kt defines `var ILineDataSet<*>.fillFormatter: IFillFormatter?` as a WeakHashMap-backed extension property, same side-channel pattern as ILineRadarDataSet.fillDrawable. Added `import info.appdev.charting.data.fillFormatter` at 6 call sites (LineChartRenderer.kt + 5 app example activities). - setCircleColors(colors, context) (ContextCompat-based) moved to the same LineDataSetAndroid.kt as a generic extension function. - Moved data/LineDataSet.kt and interfaces/datasets/ILineDataSet.kt to chartLibCore; dropped @ColorInt/@SuppressLint("RawTypeDataSet"), replaced Color.WHITE/Color.rgb(...) with ColorTemplate equivalents, replaced Timber.e(...) warnings with println(...) (matching the existing chartLibCore warning-logging pattern). LineDataSet is now a fully portable leaf class. - Disclosed behavior change: .copy() no longer propagates a custom fillFormatter to the copy (documented in LineDataSet's class doc, same category as the earlier fillDrawable copy-non-propagation change). - ScatterDataSet/IScatterDataSet remain the last Android-only DataSet leaf, blocked on the IShapeRenderer family + extracting ScatterShape out of the Android-only ScatterChart. - Updated KotlinMultiplatform.md with the changelog entry. Verified: chartLibCore:allTests, chartLib:compileDebugKotlin, chartLibCompose:assembleDebug, app:assembleDebug, full ./gradlew test, and chartLibComposeMultiplatform:build (Android/desktop/iosArm64/ iosSimulatorArm64) all pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- KotlinMultiplatform.md | 66 ++++++++++++++++ .../chartexample/CubicLineChartActivity.kt | 1 + .../info/appdev/chartexample/DataTools.kt | 1 + .../appdev/chartexample/FilledLineActivity.kt | 1 + .../appdev/chartexample/GradientActivity.kt | 1 + .../appdev/chartexample/TimeLineActivity.kt | 1 + .../charting/data/LineDataSetAndroid.kt | 48 ++++++++++++ .../charting/renderer/LineChartRenderer.kt | 4 +- .../info/appdev/charting/data/LineDataSet.kt | 77 ++++++------------- .../interfaces/datasets/ILineDataSet.kt | 14 +--- 10 files changed, 149 insertions(+), 65 deletions(-) create mode 100644 chartLib/src/main/kotlin/info/appdev/charting/data/LineDataSetAndroid.kt rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/data/LineDataSet.kt (76%) rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/interfaces/datasets/ILineDataSet.kt (80%) diff --git a/KotlinMultiplatform.md b/KotlinMultiplatform.md index 692a71ac..4a52cf91 100644 --- a/KotlinMultiplatform.md +++ b/KotlinMultiplatform.md @@ -882,6 +882,72 @@ lint Existing custom lint rules (unchanged) Step A.7 renderer port itself (e.g. a Bar/Candle/Radar/Bubble/Pie data renderer proof-of-concept) is also now viable given the expanded set of portable `DataSet` leaves. +- [x] **Step A.3 (continuation, part 4) — decouple `IFillFormatter`, migrate + `LineDataSet`.** Tackles the second blocker flagged above. `LineDataSet` + had three remaining Android couplings: `android.graphics.DashPathEffect`, + the `IFillFormatter?` stored property (whose interface method takes a + `LineDataProvider`, permanently Android-only per Step A.4), and a + `Context`/`ContextCompat`-based `setCircleColors(colors, context)` + overload. + - `DashPathEffect` → common `DashEffect` (Step A.5's type, already reused + in continuation part 2): `dashPathEffect` property, private backing + field, and `enableDashedLine(...)`'s construction all now use + `DashEffect(floatArrayOf(lineLength, spaceLength), phase)`. One + renderer call site updated, `LineChartRenderer.drawCubicFill`-adjacent + line-paint setup (`paintRender.pathEffect = dataSet.dashPathEffect` → + `...?.toAndroidDashPathEffect()`, reusing the existing conversion + extension). + - **`fillFormatter` (on `ILineDataSet`/`LineDataSet`) removed from + common code entirely**, same side-channel pattern as `fillDrawable` + from continuation part 2: new + `chartLib/data/LineDataSetAndroid.kt` defines + `var ILineDataSet<*>.fillFormatter: IFillFormatter?` backed by a + `WeakHashMap, IFillFormatter>`, with the getter + lazily materializing a `DefaultFillFormatter()` default (matching the + old stored-property's non-null default) and the setter mapping + `null` back to a fresh `DefaultFillFormatter()` (matching the old + setter's behavior). Six call sites updated with an added + `import info.appdev.charting.data.fillFormatter`: + `LineChartRenderer.kt` (2 reads) and five `app` example activities + that set a custom `fillFormatter`. + - **`setCircleColors(colors: IntArray, context: Context)`** (the + `ContextCompat.getColor(...)`-resolving overload) moved to the same + `LineDataSetAndroid.kt` as a generic extension function + (`fun > LineDataSet.setCircleColors(...)`); + call-site syntax is unchanged (no existing call sites in this repo + use this overload, but the public API shape is preserved for + external consumers). + - Trivial cleanup: `@ColorInt` dropped; `Color.WHITE`/ + `Color.rgb(140, 234, 255)` → `ColorTemplate.WHITE`/ + `ColorTemplate.argb(140, 234, 255)`; two `Timber.e(...)` validation + warnings (circle/circle-hole radius) → `println(...)`, matching the + `println`-for-warnings pattern already used elsewhere in + `chartLibCore` (`AxisBase`, `ViewPortHandler`, `DataSet`, + `ChartData`); the `@SuppressLint("RawTypeDataSet")` annotation on the + internal `copy(lineDataSet: LineDataSet<*>)` helper was dropped + (Android-only annotation type, and the custom `RawTypeDataSet` lint + check only runs against the `chartLib` module, not `chartLibCore`). + - **Moved to `chartLibCore` commonMain**: `data/LineDataSet.kt`, + `interfaces/datasets/ILineDataSet.kt`. `LineDataSet` is now a fully + portable leaf class (generic over `BaseEntry`, same as before). + - **Disclosed behavior change**: `.copy()` no longer propagates a + custom `fillFormatter` to the copy (previously copied via the old + `copy(LineDataSet)` helper's `mFillFormatter` assignment) — same + category as the `fillDrawable` copy-non-propagation disclosed in + continuation part 2. Documented in `LineDataSet`'s class doc. + - **`ScatterDataSet`/`IScatterDataSet` still Android-only** — needs the + `IShapeRenderer` family split (`renderer/scatter/*`, Canvas/Paint- + coupled) plus extracting the `ScatterShape` enum (currently nested + inside the Android-only `ScatterChart`) to a portable location. + - Verified: `chartLibCore:compileKotlinDesktop`, + `chartLib:compileDebugKotlin`, full `chartLibCore:allTests`, + `chartLibCompose:assembleDebug`, `app:assembleDebug`, full + `./gradlew test`, and `chartLibComposeMultiplatform:build` (all + targets) all pass. + - **Not yet done**: `IShapeRenderer`/`ScatterDataSet` splitting (the + last remaining Android-only `DataSet` leaf); resuming the Step A.7 + renderer port itself is now viable for every chart type except + Scatter. - [ ] **Step A.8 — Gesture handling** with `pointerInput`/`detectTransformGestures` replacing `ChartTouchListener`/`MotionEvent`. - [ ] **Step A.9 — `demoKmp` Compose Multiplatform demo app** diff --git a/app/src/main/kotlin/info/appdev/chartexample/CubicLineChartActivity.kt b/app/src/main/kotlin/info/appdev/chartexample/CubicLineChartActivity.kt index d67111a4..f1a6727a 100644 --- a/app/src/main/kotlin/info/appdev/chartexample/CubicLineChartActivity.kt +++ b/app/src/main/kotlin/info/appdev/chartexample/CubicLineChartActivity.kt @@ -19,6 +19,7 @@ import info.appdev.charting.components.YAxis import info.appdev.charting.data.EntryFloat import info.appdev.charting.data.LineData import info.appdev.charting.data.LineDataSet +import info.appdev.charting.data.fillFormatter import info.appdev.charting.formatter.IFillFormatter import info.appdev.charting.interfaces.dataprovider.LineDataProvider import info.appdev.charting.interfaces.datasets.ILineDataSet diff --git a/app/src/main/kotlin/info/appdev/chartexample/DataTools.kt b/app/src/main/kotlin/info/appdev/chartexample/DataTools.kt index 6b4d983a..78d574ad 100644 --- a/app/src/main/kotlin/info/appdev/chartexample/DataTools.kt +++ b/app/src/main/kotlin/info/appdev/chartexample/DataTools.kt @@ -9,6 +9,7 @@ import info.appdev.charting.data.EntryFloat import info.appdev.charting.data.LineData import info.appdev.charting.data.LineDataSet import info.appdev.charting.data.fillDrawable +import info.appdev.charting.data.fillFormatter import info.appdev.charting.formatter.IFillFormatter import info.appdev.charting.interfaces.dataprovider.LineDataProvider import info.appdev.charting.interfaces.datasets.ILineDataSet diff --git a/app/src/main/kotlin/info/appdev/chartexample/FilledLineActivity.kt b/app/src/main/kotlin/info/appdev/chartexample/FilledLineActivity.kt index f8e08263..30be0555 100644 --- a/app/src/main/kotlin/info/appdev/chartexample/FilledLineActivity.kt +++ b/app/src/main/kotlin/info/appdev/chartexample/FilledLineActivity.kt @@ -13,6 +13,7 @@ import info.appdev.charting.components.YAxis import info.appdev.charting.data.EntryFloat import info.appdev.charting.data.LineData import info.appdev.charting.data.LineDataSet +import info.appdev.charting.data.fillFormatter import info.appdev.charting.formatter.IFillFormatter import info.appdev.charting.interfaces.dataprovider.LineDataProvider import info.appdev.charting.interfaces.datasets.ILineDataSet diff --git a/app/src/main/kotlin/info/appdev/chartexample/GradientActivity.kt b/app/src/main/kotlin/info/appdev/chartexample/GradientActivity.kt index 251c0bbd..677e11ca 100644 --- a/app/src/main/kotlin/info/appdev/chartexample/GradientActivity.kt +++ b/app/src/main/kotlin/info/appdev/chartexample/GradientActivity.kt @@ -10,6 +10,7 @@ import info.appdev.charting.data.EntryFloat import info.appdev.charting.data.LineData import info.appdev.charting.data.LineDataSet import info.appdev.charting.data.fillDrawable +import info.appdev.charting.data.fillFormatter import info.appdev.charting.formatter.IFillFormatter import info.appdev.charting.interfaces.dataprovider.LineDataProvider import info.appdev.charting.interfaces.datasets.ILineDataSet diff --git a/app/src/main/kotlin/info/appdev/chartexample/TimeLineActivity.kt b/app/src/main/kotlin/info/appdev/chartexample/TimeLineActivity.kt index ba3908ac..4014e068 100644 --- a/app/src/main/kotlin/info/appdev/chartexample/TimeLineActivity.kt +++ b/app/src/main/kotlin/info/appdev/chartexample/TimeLineActivity.kt @@ -21,6 +21,7 @@ import info.appdev.charting.data.EntryDouble import info.appdev.charting.data.EntryFloat import info.appdev.charting.data.LineData import info.appdev.charting.data.LineDataSet +import info.appdev.charting.data.fillFormatter import info.appdev.charting.formatter.IFillFormatter import info.appdev.charting.interfaces.dataprovider.LineDataProvider import info.appdev.charting.interfaces.datasets.ILineDataSet diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/LineDataSetAndroid.kt b/chartLib/src/main/kotlin/info/appdev/charting/data/LineDataSetAndroid.kt new file mode 100644 index 00000000..f43a6f16 --- /dev/null +++ b/chartLib/src/main/kotlin/info/appdev/charting/data/LineDataSetAndroid.kt @@ -0,0 +1,48 @@ +package info.appdev.charting.data + +import android.content.Context +import androidx.annotation.ColorInt +import androidx.core.content.ContextCompat +import info.appdev.charting.formatter.DefaultFillFormatter +import info.appdev.charting.formatter.IFillFormatter +import info.appdev.charting.interfaces.datasets.ILineDataSet +import java.util.WeakHashMap + +/** + * Android-only side channels for [ILineDataSet]/[LineDataSet], mirroring the pattern already + * established for `ILineRadarDataSet.fillDrawable` in `LineRadarDataSetAndroid.kt`. + * + * [IFillFormatter] depends on `LineDataProvider` (permanently Android-only, see Step A.4), so it + * has no portable equivalent and is kept as a `WeakHashMap`-backed extension property here instead + * of a stored property on the common class. + */ +private val fillFormatters = WeakHashMap, IFillFormatter>() + +/** + * Sets a custom IFillFormatter to the chart that handles the position of the + * filled-line for each DataSet. Set this to null to use the default logic. + */ +var ILineDataSet<*>.fillFormatter: IFillFormatter? + get() = fillFormatters.getOrPut(this) { DefaultFillFormatter() } + set(value) { + fillFormatters[this] = value ?: DefaultFillFormatter() + } + +/** + * Sets the colors that should be used for the circles of this DataSet. + * Colors are reused as soon as the number of Entries the DataSet represents + * is higher than the size of the colors array. You can use + * "new String[] { R.color.red, R.color.green, ... }" to provide colors for + * this method. Internally, the colors are resolved using + * getResources().getColor(...) + */ +fun > LineDataSet.setCircleColors(@ColorInt colors: IntArray, context: Context) { + val clrs = this.circleColors + clrs.clear() + + for (color in colors) { + clrs.add(ContextCompat.getColor(context, color)) + } + + this.circleColors = clrs +} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/renderer/LineChartRenderer.kt b/chartLib/src/main/kotlin/info/appdev/charting/renderer/LineChartRenderer.kt index dc6481c3..0bc5a545 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/renderer/LineChartRenderer.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/renderer/LineChartRenderer.kt @@ -11,6 +11,7 @@ import info.appdev.charting.animation.ChartAnimator import info.appdev.charting.data.BaseEntry import info.appdev.charting.data.LineDataSet import info.appdev.charting.data.fillDrawable +import info.appdev.charting.data.fillFormatter import info.appdev.charting.highlight.Highlight import info.appdev.charting.interfaces.dataprovider.LineDataProvider import info.appdev.charting.interfaces.datasets.IBarLineScatterCandleBubbleDataSet @@ -22,6 +23,7 @@ import info.appdev.charting.utils.Transformer import info.appdev.charting.utils.ViewPortHandler import info.appdev.charting.utils.convertDpToPixel import info.appdev.charting.utils.drawImage +import info.appdev.charting.utils.toAndroidDashPathEffect import java.lang.ref.WeakReference import kotlin.math.max import kotlin.math.min @@ -96,7 +98,7 @@ open class LineChartRenderer( return paintRender.strokeWidth = dataSet.lineWidth - paintRender.pathEffect = dataSet.dashPathEffect + paintRender.pathEffect = dataSet.dashPathEffect?.toAndroidDashPathEffect() when (dataSet.lineMode) { LineDataSet.Mode.LINEAR, LineDataSet.Mode.STEPPED -> drawLinear(canvas, dataSet) diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/LineDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/LineDataSet.kt similarity index 76% rename from chartLib/src/main/kotlin/info/appdev/charting/data/LineDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/LineDataSet.kt index 3c369112..21c4bd3b 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/LineDataSet.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/LineDataSet.kt @@ -1,18 +1,24 @@ package info.appdev.charting.data -import android.annotation.SuppressLint -import android.content.Context -import android.graphics.Color -import android.graphics.DashPathEffect -import androidx.annotation.ColorInt -import androidx.core.content.ContextCompat -import info.appdev.charting.formatter.DefaultFillFormatter -import info.appdev.charting.formatter.IFillFormatter import info.appdev.charting.interfaces.datasets.ILineDataSet import info.appdev.charting.utils.ColorTemplate +import info.appdev.charting.utils.DashEffect import info.appdev.charting.utils.convertDpToPixel -import timber.log.Timber +/** + * LineDataSet describes a line-chart-able DataSet. + * + * Two Android-only members were removed in favor of side-channels defined in + * `chartLib` (see `LineDataSetAndroid.kt`), since neither has a portable KMP + * equivalent: + * - `fillFormatter` (an [info.appdev.charting.formatter.IFillFormatter]) is now + * an extension property. As a result, `.copy()` no longer propagates a + * custom `fillFormatter` to the copy (it did before, along with the other + * fields in the old `copy(LineDataSet)` helper). + * - `setCircleColors(colors: IntArray, context: Context)`, the + * `ContextCompat.getColor(...)`-resolving overload, is now an extension + * function taking a `LineDataSet<*>` receiver. + */ open class LineDataSet>(yVals: MutableList = mutableListOf(), label: String = "") : LineRadarDataSet(yVals, label), ILineDataSet { /** * Drawing mode for this line dataset @@ -26,14 +32,12 @@ open class LineDataSet>(yVals: MutableList = mutableList * are already prepared (by calling getResources().getColor(...)) before * adding them to the DataSet. */ - @ColorInt var circleColors: MutableList = mutableListOf() /** * the color of the inner circles */ - @ColorInt - private var mCircleHoleColor = Color.WHITE + private var mCircleHoleColor = ColorTemplate.WHITE /** * the radius of the circle-shaped value indicators @@ -53,12 +57,7 @@ open class LineDataSet>(yVals: MutableList = mutableList /** * the path effect of this DataSet that makes dashed lines possible */ - private var mDashPathEffect: DashPathEffect? = null - - /** - * formatter for customizing the position of the fill-line - */ - private var mFillFormatter: IFillFormatter? = DefaultFillFormatter() + private var mDashPathEffect: DashEffect? = null /** * if true, drawing circles is enabled @@ -71,7 +70,7 @@ open class LineDataSet>(yVals: MutableList = mutableList // default colors // mColors.add(Color.rgb(192, 255, 140)); // mColors.add(Color.rgb(255, 247, 140)); - circleColors.add(Color.rgb(140, 234, 255)) + circleColors.add(ColorTemplate.argb(140, 234, 255)) } @Suppress("UNCHECKED_CAST") @@ -85,7 +84,7 @@ open class LineDataSet>(yVals: MutableList = mutableList return copied as DataSet } - protected fun copy(@SuppressLint("RawTypeDataSet") lineDataSet: LineDataSet<*>) { + protected fun copy(lineDataSet: LineDataSet<*>) { super.copy((lineDataSet as BaseDataSet<*>?)!!) lineDataSet.circleColors = this.circleColors lineDataSet.mCircleHoleColor = mCircleHoleColor @@ -95,7 +94,6 @@ open class LineDataSet>(yVals: MutableList = mutableList lineDataSet.mDashPathEffect = mDashPathEffect lineDataSet.mDrawCircleHole = mDrawCircleHole lineDataSet.mDrawCircles = mDrawCircleHole - lineDataSet.mFillFormatter = mFillFormatter lineDataSet.mLineDataSetMode = mLineDataSetMode } @@ -109,7 +107,7 @@ open class LineDataSet>(yVals: MutableList = mutableList * @param phase offset, in degrees (normally, use 0) */ fun enableDashedLine(lineLength: Float, spaceLength: Float, phase: Float) { - mDashPathEffect = DashPathEffect( + mDashPathEffect = DashEffect( floatArrayOf( lineLength, spaceLength ), phase @@ -156,7 +154,7 @@ open class LineDataSet>(yVals: MutableList = mutableList if (value >= 1f) { mCircleRadius = value.convertDpToPixel() } else { - Timber.e("Circle radius cannot be < 1") + println("Circle radius cannot be < 1") } } @@ -170,7 +168,7 @@ open class LineDataSet>(yVals: MutableList = mutableList if (value >= 0.5f) { mCircleHoleRadius = value.convertDpToPixel() } else { - Timber.e("Circle radius cannot be < 0.5") + println("Circle radius cannot be < 0.5") } } @@ -196,7 +194,7 @@ open class LineDataSet>(yVals: MutableList = mutableList set(value) { mDrawCircleHole = value } - override var dashPathEffect: DashPathEffect? + override var dashPathEffect: DashEffect? get() = mDashPathEffect set(value) { mDashPathEffect = value @@ -211,16 +209,6 @@ open class LineDataSet>(yVals: MutableList = mutableList mDashPathEffect = null } - /** - * Sets a custom IFillFormatter to the chart that handles the position of the - * filled-line for each DataSet. Set this to null to use the default logic. - */ - override var fillFormatter: IFillFormatter? - get() = mFillFormatter - set(value) { - mFillFormatter = value ?: DefaultFillFormatter() - } - /** * Sets the colors that should be used for the circles of this DataSet. * Colors are reused as soon as the number of Entries the DataSet represents @@ -232,25 +220,6 @@ open class LineDataSet>(yVals: MutableList = mutableList this.circleColors = ColorTemplate.createColors(colors) } - /** - * ets the colors that should be used for the circles of this DataSet. - * Colors are reused as soon as the number of Entries the DataSet represents - * is higher than the size of the colors array. You can use - * "new String[] { R.color.red, R.color.green, ... }" to provide colors for - * this method. Internally, the colors are resolved using - * getResources().getColor(...) - */ - fun setCircleColors(@ColorInt colors: IntArray, context: Context) { - val clrs = this.circleColors - clrs.clear() - - for (color in colors) { - clrs.add(ContextCompat.getColor(context, color)) - } - - this.circleColors = clrs - } - /** * Sets the one and ONLY color that should be used for this DataSet. * Internally, this recreates the colors array and adds the specified color. diff --git a/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/ILineDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/ILineDataSet.kt similarity index 80% rename from chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/ILineDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/ILineDataSet.kt index e1465aa8..85233c0e 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/ILineDataSet.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/ILineDataSet.kt @@ -1,9 +1,8 @@ package info.appdev.charting.interfaces.datasets -import android.graphics.DashPathEffect import info.appdev.charting.data.BaseEntry import info.appdev.charting.data.LineDataSet -import info.appdev.charting.formatter.IFillFormatter +import info.appdev.charting.utils.DashEffect interface ILineDataSet> : ILineRadarDataSet { /** @@ -58,18 +57,13 @@ interface ILineDataSet> : ILineRadarDataSet { var isDrawCircleHoleEnabled: Boolean /** - * Returns the DashPathEffect that is used for drawing the lines. + * Returns the DashEffect that is used for drawing the lines. */ - var dashPathEffect: DashPathEffect? + var dashPathEffect: DashEffect? /** * Returns true if the dashed-line effect is enabled, false if not. - * If the DashPathEffect object is null, also return false here. + * If the DashEffect object is null, also return false here. */ var isDashedLineEnabled: Boolean - - /** - * Returns the IFillFormatter that is set for this DataSet. - */ - var fillFormatter: IFillFormatter? } \ No newline at end of file From b602993487bbdcd5ca7176d1f9a1281751697bd6 Mon Sep 17 00:00:00 2001 From: Hannes Achleitner Date: Sun, 27 Sep 2026 13:43:11 +0200 Subject: [PATCH 17/22] Step A.3 (continuation, part 5): split IShapeRenderer, migrate ScatterDataSet Completes the concrete DataSet family migration (Bar/Line/Scatter/Candle/ Bubble/Radar/Pie) that started as a Step A.7 prerequisite. - Extracted ScatterShape out of the Android-only ScatterChart class into a new top-level, portable chartLibCore/charts/ScatterShape.kt (same package, so ScatterChart.kt just drops the nested enum). Source-breaking: ScatterChart.ScatterShape.X -> ScatterShape.X (5 call sites updated). - shapeRenderer removed from IScatterDataSet/ScatterDataSet entirely (IShapeRenderer is Canvas/Paint-coupled, no portable equivalent). New chartLib/data/ScatterDataSetAndroid.kt defines `var IScatterDataSet.shapeRenderer: IShapeRenderer?` as a WeakHashMap-backed extension property, same side-channel pattern as fillDrawable/fillFormatter. Uses containsKey (not getOrPut) so an explicit null stays null instead of resetting to the default SquareShapeRenderer(). - setScatterShape(shape) moved to the same file as an extension function; getRendererForShape(shape) moved from ScatterDataSet's companion object to a top-level function (source-breaking, unused elsewhere in this repo). - Moved data/ScatterDataSet.kt and interfaces/datasets/IScatterDataSet.kt to chartLibCore. ScatterDataSet is now a fully portable leaf class. - Disclosed behavior change: .copy() no longer propagates a custom shapeRenderer to the copy (documented in ScatterDataSet's class doc, same category as prior fillDrawable/fillFormatter changes). - Updated KotlinMultiplatform.md with the changelog entry. Verified: chartLibCore:allTests, chartLib:compileDebugKotlin, chartLibCompose:assembleDebug, app:assembleDebug, full ./gradlew test, and chartLibComposeMultiplatform:build (Android/desktop/iosArm64/ iosSimulatorArm64) all pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- KotlinMultiplatform.md | 58 +++++++++ .../chartexample/ScatterChartActivity.kt | 8 +- .../chartexample/fragments/SimpleFragment.kt | 3 +- .../appdev/charting/charts/ScatterChart.kt | 31 ----- .../appdev/charting/data/ScatterDataSet.kt | 110 ------------------ .../charting/data/ScatterDataSetAndroid.kt | 62 ++++++++++ .../charting/renderer/ScatterChartRenderer.kt | 1 + .../appdev/charting/compose/ChartState.kt | 4 +- .../compose/examples/ChartExamples.kt | 5 +- .../appdev/charting/charts/ScatterShape.kt | 36 ++++++ .../appdev/charting/data/ScatterDataSet.kt | 76 ++++++++++++ .../interfaces/datasets/IScatterDataSet.kt | 6 - 12 files changed, 245 insertions(+), 155 deletions(-) delete mode 100644 chartLib/src/main/kotlin/info/appdev/charting/data/ScatterDataSet.kt create mode 100644 chartLib/src/main/kotlin/info/appdev/charting/data/ScatterDataSetAndroid.kt create mode 100644 chartLibCore/src/commonMain/kotlin/info/appdev/charting/charts/ScatterShape.kt create mode 100644 chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/ScatterDataSet.kt rename {chartLib/src/main => chartLibCore/src/commonMain}/kotlin/info/appdev/charting/interfaces/datasets/IScatterDataSet.kt (71%) diff --git a/KotlinMultiplatform.md b/KotlinMultiplatform.md index 4a52cf91..a896efaa 100644 --- a/KotlinMultiplatform.md +++ b/KotlinMultiplatform.md @@ -948,6 +948,64 @@ lint Existing custom lint rules (unchanged) last remaining Android-only `DataSet` leaf); resuming the Step A.7 renderer port itself is now viable for every chart type except Scatter. +- [x] **Step A.3 (continuation, part 5) — split `IShapeRenderer`, migrate + `ScatterDataSet`.** Tackles the last of the three blockers flagged + above, completing the concrete `DataSet` family migration: every chart + type's `DataSet` is now portable. + - **`ScatterShape` extracted out of the Android-only `ScatterChart` + class** into a new top-level, fully portable + `chartLibCore/charts/ScatterShape.kt` (same package, + `info.appdev.charting.charts`, so `chartLib`'s `ScatterChart.kt` + needed no import change — just deletion of the nested `enum class + ScatterShape { ... }` block). This is a source-breaking change for + callers: `ScatterChart.ScatterShape.SQUARE` → `ScatterShape.SQUARE` + (5 call sites updated: `ScatterDataSetAndroid.kt`, + `ScatterChartActivity.kt`, `SimpleFragment.kt`, + `ChartExamples.kt`, `ChartState.kt`). + - **`shapeRenderer` (on `IScatterDataSet`/`ScatterDataSet`) removed + from common code entirely**, same side-channel pattern as + `fillDrawable`/`fillFormatter`: new + `chartLib/data/ScatterDataSetAndroid.kt` defines + `var IScatterDataSet.shapeRenderer: IShapeRenderer?` backed by a + `WeakHashMap`. Unlike + `fillFormatter`'s "null means reset to default" semantics, + `shapeRenderer` can be legitimately `null` (`ScatterChartRenderer` + treats a `null` renderer as "skip drawing"), so the getter uses + `containsKey` rather than `getOrPut` to distinguish "never set" + (lazily materializes the default `SquareShapeRenderer()`) from + "explicitly set to null" — `getOrPut` cannot tell those apart when + the map's value type is itself nullable. + - **`setScatterShape(shape)`** moved to the same file as an extension + function on `ScatterDataSet`; **`getRendererForShape(shape)`** moved + from `ScatterDataSet`'s companion object to a top-level function in + `chartLib` (source-breaking: `ScatterDataSet.getRendererForShape(...)` + → `getRendererForShape(...)`, unused elsewhere in this repo). + - The `renderer/scatter/*` shape-drawing classes themselves + (`IShapeRenderer` + 7 concrete renderers) stay exactly where they + are, unchanged — they're Canvas/Paint-coupled drawing logic, same + permanently-Android-only category as the `Renderer`/`DataRenderer` + hierarchy from Step A.4, not something to "split". + - **Moved to `chartLibCore` commonMain**: `data/ScatterDataSet.kt`, + `interfaces/datasets/IScatterDataSet.kt`. `ScatterDataSet` is now a + fully portable leaf class — the last of the seven concrete `DataSet` + classes (Bar/Line/Scatter/Candle/Bubble/Radar/Pie) to move. **The + concrete `DataSet` family migration that started as a Step A.7 + prerequisite is now complete.** + - **Disclosed behavior change**: `.copy()` no longer propagates a + custom `shapeRenderer` to the copy — same category as the + `fillDrawable`/`fillFormatter` copy-non-propagation disclosed in + continuation parts 2 and 4. Documented in `ScatterDataSet`'s class + doc. + - Verified: `chartLibCore:compileKotlinDesktop`, + `chartLib:compileDebugKotlin`, full `chartLibCore:allTests`, + `chartLibCompose:assembleDebug`, `app:assembleDebug`, full + `./gradlew test`, and `chartLibComposeMultiplatform:build` (all + targets) all pass. + - **Next up**: resuming the Step A.7 Compose Multiplatform renderer + port itself is now viable for every chart type's data model + (Bar/Line/Scatter/Candle/Bubble/Radar/Pie all have portable + `DataSet`s); alternatively Step A.8 (gesture handling), A.9 + (`demoKmp`), A.10 (CI), or A.11 (Publishing). - [ ] **Step A.8 — Gesture handling** with `pointerInput`/`detectTransformGestures` replacing `ChartTouchListener`/`MotionEvent`. - [ ] **Step A.9 — `demoKmp` Compose Multiplatform demo app** diff --git a/app/src/main/kotlin/info/appdev/chartexample/ScatterChartActivity.kt b/app/src/main/kotlin/info/appdev/chartexample/ScatterChartActivity.kt index e78b5e8f..d50d8cda 100644 --- a/app/src/main/kotlin/info/appdev/chartexample/ScatterChartActivity.kt +++ b/app/src/main/kotlin/info/appdev/chartexample/ScatterChartActivity.kt @@ -14,11 +14,13 @@ import info.appdev.chartexample.DataTools.Companion.getValues import info.appdev.chartexample.custom.CustomScatterShapeRenderer import info.appdev.chartexample.databinding.ActivityScatterchartBinding import info.appdev.chartexample.notimportant.DemoBase -import info.appdev.charting.charts.ScatterChart +import info.appdev.charting.charts.ScatterShape import info.appdev.charting.components.Legend import info.appdev.charting.data.EntryFloat import info.appdev.charting.data.ScatterData import info.appdev.charting.data.ScatterDataSet +import info.appdev.charting.data.setScatterShape +import info.appdev.charting.data.shapeRenderer import info.appdev.charting.highlight.Highlight import info.appdev.charting.interfaces.datasets.IScatterDataSet import info.appdev.charting.listener.OnChartValueSelectedListener @@ -99,10 +101,10 @@ class ScatterChartActivity : DemoBase(), OnSeekBarChangeListener, OnChartValueSe // create a dataset and give it a type val set1 = ScatterDataSet(values1, "DS 1") - set1.setScatterShape(ScatterChart.ScatterShape.SQUARE) + set1.setScatterShape(ScatterShape.SQUARE) set1.color = ColorTemplate.COLORFUL_COLORS[0] val set2 = ScatterDataSet(values2, "DS 2") - set2.setScatterShape(ScatterChart.ScatterShape.CIRCLE) + set2.setScatterShape(ScatterShape.CIRCLE) set2.scatterShapeHoleColor = ColorTemplate.COLORFUL_COLORS[3] set2.scatterShapeHoleRadius = 3f set2.color = ColorTemplate.COLORFUL_COLORS[1] diff --git a/app/src/main/kotlin/info/appdev/chartexample/fragments/SimpleFragment.kt b/app/src/main/kotlin/info/appdev/chartexample/fragments/SimpleFragment.kt index 5907041a..8e359c50 100644 --- a/app/src/main/kotlin/info/appdev/chartexample/fragments/SimpleFragment.kt +++ b/app/src/main/kotlin/info/appdev/chartexample/fragments/SimpleFragment.kt @@ -8,7 +8,7 @@ import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import info.appdev.chartexample.DataTools.Companion.getValues -import info.appdev.charting.charts.ScatterChart.ScatterShape +import info.appdev.charting.charts.ScatterShape import info.appdev.charting.data.BarData import info.appdev.charting.data.BarDataSet import info.appdev.charting.data.BarEntryFloat @@ -20,6 +20,7 @@ import info.appdev.charting.data.PieDataSet import info.appdev.charting.data.PieEntryFloat import info.appdev.charting.data.ScatterData import info.appdev.charting.data.ScatterDataSet +import info.appdev.charting.data.setScatterShape import info.appdev.charting.interfaces.datasets.IBarDataSet import info.appdev.charting.interfaces.datasets.ILineDataSet import info.appdev.charting.interfaces.datasets.IScatterDataSet diff --git a/chartLib/src/main/kotlin/info/appdev/charting/charts/ScatterChart.kt b/chartLib/src/main/kotlin/info/appdev/charting/charts/ScatterChart.kt index 4efae356..ce759814 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/charts/ScatterChart.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/charts/ScatterChart.kt @@ -31,37 +31,6 @@ open class ScatterChart : BarLineChartBase, ScatterDataProvider { override val scatterData: ScatterData? get() = mData - /** - * Predefined ScatterShapes that allow the specification of a shape a ScatterDataSet should be drawn with. - * If a ScatterShape is specified for a ScatterDataSet, the required renderer is set. - */ - enum class ScatterShape(private val shapeIdentifier: String) { - SQUARE("SQUARE"), - CIRCLE("CIRCLE"), - TRIANGLE("TRIANGLE"), - CROSS("CROSS"), - X("X"), - CHEVRON_UP("CHEVRON_UP"), - CHEVRON_DOWN("CHEVRON_DOWN"); - - override fun toString(): String { - return shapeIdentifier - } - - companion object { - val allDefaultShapes: Array - get() = arrayOf( - SQUARE, - CIRCLE, - TRIANGLE, - CROSS, - X, - CHEVRON_UP, - CHEVRON_DOWN - ) - } - } - override val accessibilityDescription: String get() = "This is scatter chart" } diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/ScatterDataSet.kt b/chartLib/src/main/kotlin/info/appdev/charting/data/ScatterDataSet.kt deleted file mode 100644 index aadb8848..00000000 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/ScatterDataSet.kt +++ /dev/null @@ -1,110 +0,0 @@ -package info.appdev.charting.data - -import androidx.annotation.ColorInt -import info.appdev.charting.charts.ScatterChart.ScatterShape -import info.appdev.charting.interfaces.datasets.IScatterDataSet -import info.appdev.charting.renderer.scatter.ChevronDownShapeRenderer -import info.appdev.charting.renderer.scatter.ChevronUpShapeRenderer -import info.appdev.charting.renderer.scatter.CircleShapeRenderer -import info.appdev.charting.renderer.scatter.CrossShapeRenderer -import info.appdev.charting.renderer.scatter.IShapeRenderer -import info.appdev.charting.renderer.scatter.SquareShapeRenderer -import info.appdev.charting.renderer.scatter.TriangleShapeRenderer -import info.appdev.charting.renderer.scatter.XShapeRenderer -import info.appdev.charting.utils.ColorTemplate - -open class ScatterDataSet(yVals: MutableList, label: String = "") : LineScatterCandleRadarDataSet(yVals, label), IScatterDataSet { - /** - * the size the scatterShape will have, in density pixels - */ - private var shapeSize = 15f - - /** - * Renderer responsible for rendering this DataSet, default: square - */ - protected var mShapeRenderer: IShapeRenderer? = SquareShapeRenderer() - - /** - * The radius of the hole in the shape (applies to Square, Circle and Triangle) - * - default: 0.0 - */ - private var mScatterShapeHoleRadius = 0f - - /** - * Color for the hole in the shape. - * Setting to `ColorTemplate.COLOR_NONE` will behave as transparent. - * - default: ColorTemplate.COLOR_NONE - */ - @ColorInt - private var mScatterShapeHoleColor = ColorTemplate.COLOR_NONE - - override fun copy(): DataSet { - val entries: MutableList = mutableListOf() - for (i in entriesInternal.indices) { - entries.add(entriesInternal[i].copy()) - } - val copied = ScatterDataSet(entries, label) - copy(copied) - return copied - } - - protected fun copy(scatterDataSet: ScatterDataSet) { - super.copy((scatterDataSet as BaseDataSet<*>?)!!) - scatterDataSet.shapeSize = shapeSize - scatterDataSet.mShapeRenderer = mShapeRenderer - scatterDataSet.mScatterShapeHoleRadius = mScatterShapeHoleRadius - scatterDataSet.mScatterShapeHoleColor = mScatterShapeHoleColor - } - - /** - * Sets the ScatterShape this DataSet should be drawn with. This will search for an available IShapeRenderer and set this - * renderer for the DataSet. - */ - fun setScatterShape(shape: ScatterShape) { - mShapeRenderer = getRendererForShape(shape) - } - - /** - * Sets the size in density pixels the drawn scatterShape will have. This - * only applies for non custom shapes. - */ - override var scatterShapeSize: Float - get() = shapeSize - set(value) { - shapeSize = value - } - override var scatterShapeHoleRadius: Float - get() = mScatterShapeHoleRadius - set(value) { - mScatterShapeHoleRadius = value - } - override var scatterShapeHoleColor: Int - get() = mScatterShapeHoleColor - set(value) { - mScatterShapeHoleColor = value - } - - /** - * Sets a new IShapeRenderer responsible for drawing this DataSet. - * This can also be used to set a custom IShapeRenderer aside from the default ones. - */ - override var shapeRenderer: IShapeRenderer? - get() = mShapeRenderer - set(value) { - mShapeRenderer = value - } - - companion object { - fun getRendererForShape(shape: ScatterShape): IShapeRenderer? { - return when (shape) { - ScatterShape.SQUARE -> SquareShapeRenderer() - ScatterShape.CIRCLE -> CircleShapeRenderer() - ScatterShape.TRIANGLE -> TriangleShapeRenderer() - ScatterShape.CROSS -> CrossShapeRenderer() - ScatterShape.X -> XShapeRenderer() - ScatterShape.CHEVRON_UP -> ChevronUpShapeRenderer() - ScatterShape.CHEVRON_DOWN -> ChevronDownShapeRenderer() - } - } - } -} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/ScatterDataSetAndroid.kt b/chartLib/src/main/kotlin/info/appdev/charting/data/ScatterDataSetAndroid.kt new file mode 100644 index 00000000..60eaea2d --- /dev/null +++ b/chartLib/src/main/kotlin/info/appdev/charting/data/ScatterDataSetAndroid.kt @@ -0,0 +1,62 @@ +package info.appdev.charting.data + +import info.appdev.charting.charts.ScatterShape +import info.appdev.charting.interfaces.datasets.IScatterDataSet +import info.appdev.charting.renderer.scatter.ChevronDownShapeRenderer +import info.appdev.charting.renderer.scatter.ChevronUpShapeRenderer +import info.appdev.charting.renderer.scatter.CircleShapeRenderer +import info.appdev.charting.renderer.scatter.CrossShapeRenderer +import info.appdev.charting.renderer.scatter.IShapeRenderer +import info.appdev.charting.renderer.scatter.SquareShapeRenderer +import info.appdev.charting.renderer.scatter.TriangleShapeRenderer +import info.appdev.charting.renderer.scatter.XShapeRenderer +import java.util.WeakHashMap + +/** + * Android-only side channels for [IScatterDataSet]/[ScatterDataSet], mirroring the pattern already + * established for `ILineRadarDataSet.fillDrawable`/`ILineDataSet.fillFormatter`. + * + * [IShapeRenderer] is fundamentally Canvas/Paint-coupled and has no portable equivalent, so it is + * kept as a `WeakHashMap`-backed extension property here instead of a stored property on the + * common class. Unlike `fillFormatter`'s "null means default" semantics, `shapeRenderer` can be + * legitimately `null` (`ScatterChartRenderer` treats a `null` renderer as "don't draw"), so the map + * distinguishes "never set" (falls back to the default `SquareShapeRenderer()`) from "explicitly + * set to null" via [MutableMap.containsKey] rather than [getOrPut][kotlin.collections.getOrPut] + * (which cannot tell a stored `null` apart from an absent key). + */ +private val shapeRenderers = WeakHashMap() + +/** + * The [IShapeRenderer] responsible for rendering this DataSet. Default: [SquareShapeRenderer]. + * Can be set to a custom [IShapeRenderer] aside from the default ones, or to `null` to skip + * drawing entirely. + */ +var IScatterDataSet.shapeRenderer: IShapeRenderer? + get() = if (shapeRenderers.containsKey(this)) { + shapeRenderers[this] + } else { + SquareShapeRenderer().also { shapeRenderers[this] = it } + } + set(value) { + shapeRenderers[this] = value + } + +/** + * Sets the ScatterShape this DataSet should be drawn with. This will search for an available + * IShapeRenderer and set this renderer for the DataSet. + */ +fun ScatterDataSet.setScatterShape(shape: ScatterShape) { + this.shapeRenderer = getRendererForShape(shape) +} + +fun getRendererForShape(shape: ScatterShape): IShapeRenderer? { + return when (shape) { + ScatterShape.SQUARE -> SquareShapeRenderer() + ScatterShape.CIRCLE -> CircleShapeRenderer() + ScatterShape.TRIANGLE -> TriangleShapeRenderer() + ScatterShape.CROSS -> CrossShapeRenderer() + ScatterShape.X -> XShapeRenderer() + ScatterShape.CHEVRON_UP -> ChevronUpShapeRenderer() + ScatterShape.CHEVRON_DOWN -> ChevronDownShapeRenderer() + } +} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/renderer/ScatterChartRenderer.kt b/chartLib/src/main/kotlin/info/appdev/charting/renderer/ScatterChartRenderer.kt index cf4a1c10..da621ac5 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/renderer/ScatterChartRenderer.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/renderer/ScatterChartRenderer.kt @@ -2,6 +2,7 @@ package info.appdev.charting.renderer import android.graphics.Canvas import info.appdev.charting.animation.ChartAnimator +import info.appdev.charting.data.shapeRenderer import info.appdev.charting.highlight.Highlight import info.appdev.charting.interfaces.dataprovider.ScatterDataProvider import info.appdev.charting.interfaces.datasets.IScatterDataSet diff --git a/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/ChartState.kt b/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/ChartState.kt index 89c93d0c..f2421a7e 100644 --- a/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/ChartState.kt +++ b/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/ChartState.kt @@ -7,7 +7,7 @@ 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.charts.ScatterShape import info.appdev.charting.data.* import info.appdev.charting.highlight.Highlight @@ -164,7 +164,7 @@ class RadarChartState : ChartState() { */ @Stable class ScatterChartState : ChartState() { - var scaleType by mutableStateOf(ScatterChart.ScatterShape.CIRCLE) + var scaleType by mutableStateOf(ScatterShape.CIRCLE) } /** 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 index 7575aece..49857dd7 100644 --- a/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/examples/ChartExamples.kt +++ b/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/examples/ChartExamples.kt @@ -22,7 +22,7 @@ import androidx.compose.runtime.setValue 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.charts.ScatterShape import info.appdev.charting.components.Legend import info.appdev.charting.components.XAxis import info.appdev.charting.compose.BarChart @@ -45,6 +45,7 @@ import info.appdev.charting.data.RadarDataSet import info.appdev.charting.data.RadarEntryFloat import info.appdev.charting.data.ScatterData import info.appdev.charting.data.ScatterDataSet +import info.appdev.charting.data.setScatterShape /** * Example composable demonstrating all chart types in Compose. @@ -255,7 +256,7 @@ fun ScatterChartExample() { val dataSet = remember(entries) { ScatterDataSet(entries.toMutableList(), "Data Points").apply { color = android.graphics.Color.rgb(255, 87, 34) - setScatterShape(ScatterChart.ScatterShape.CIRCLE) + setScatterShape(ScatterShape.CIRCLE) scatterShapeSize = 12f isDrawValues = false } diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/charts/ScatterShape.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/charts/ScatterShape.kt new file mode 100644 index 00000000..cf837071 --- /dev/null +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/charts/ScatterShape.kt @@ -0,0 +1,36 @@ +package info.appdev.charting.charts + +/** + * Predefined ScatterShapes that allow the specification of a shape a ScatterDataSet should be drawn with. + * If a ScatterShape is specified for a ScatterDataSet, the required renderer is set. + * + * Extracted out of the (Android-only) `ScatterChart` class so that `ScatterDataSet.setScatterShape(...)` + * can reference it from portable code; previously accessed as the nested `ScatterChart.ScatterShape`, + * now a top-level type in the same package, so call sites use `ScatterShape.SQUARE` etc. directly. + */ +enum class ScatterShape(private val shapeIdentifier: String) { + SQUARE("SQUARE"), + CIRCLE("CIRCLE"), + TRIANGLE("TRIANGLE"), + CROSS("CROSS"), + X("X"), + CHEVRON_UP("CHEVRON_UP"), + CHEVRON_DOWN("CHEVRON_DOWN"); + + override fun toString(): String { + return shapeIdentifier + } + + companion object { + val allDefaultShapes: Array + get() = arrayOf( + SQUARE, + CIRCLE, + TRIANGLE, + CROSS, + X, + CHEVRON_UP, + CHEVRON_DOWN + ) + } +} diff --git a/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/ScatterDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/ScatterDataSet.kt new file mode 100644 index 00000000..329e93df --- /dev/null +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/data/ScatterDataSet.kt @@ -0,0 +1,76 @@ +package info.appdev.charting.data + +import info.appdev.charting.interfaces.datasets.IScatterDataSet +import info.appdev.charting.utils.ColorTemplate + +/** + * ScatterDataSet describes a scatter-chart-able DataSet. + * + * `setScatterShape(shape)`/`shapeRenderer`/`getRendererForShape(shape)` were removed from common + * code entirely and replaced with Android-only side-channels in `chartLib`'s + * `ScatterDataSetAndroid.kt`, since `IShapeRenderer` (the renderer interface these deal with) is + * fundamentally Canvas/Paint-coupled and has no portable equivalent (same category of drawing + * logic as the `Renderer`/`DataRenderer` hierarchy, permanently Android-only per Step A.4): + * - `shapeRenderer` is now a `WeakHashMap`-backed extension property on `IScatterDataSet`, same + * side-channel pattern as `ILineRadarDataSet.fillDrawable`/`ILineDataSet.fillFormatter`. As a + * result, `.copy()` no longer propagates a custom `shapeRenderer` to the copy. + * - `setScatterShape(shape: ScatterShape)` is now an extension function on `ScatterDataSet`. + * - `getRendererForShape(shape: ScatterShape)` is now a top-level function in `chartLib` (was + * previously `ScatterDataSet.getRendererForShape(shape)` on the companion object). + */ +open class ScatterDataSet(yVals: MutableList, label: String = "") : LineScatterCandleRadarDataSet(yVals, label), IScatterDataSet { + /** + * the size the scatterShape will have, in density pixels + */ + private var shapeSize = 15f + + /** + * The radius of the hole in the shape (applies to Square, Circle and Triangle) + * - default: 0.0 + */ + private var mScatterShapeHoleRadius = 0f + + /** + * Color for the hole in the shape. + * Setting to `ColorTemplate.COLOR_NONE` will behave as transparent. + * - default: ColorTemplate.COLOR_NONE + */ + private var mScatterShapeHoleColor = ColorTemplate.COLOR_NONE + + override fun copy(): DataSet { + val entries: MutableList = mutableListOf() + for (i in entriesInternal.indices) { + entries.add(entriesInternal[i].copy()) + } + val copied = ScatterDataSet(entries, label) + copy(copied) + return copied + } + + protected fun copy(scatterDataSet: ScatterDataSet) { + super.copy((scatterDataSet as BaseDataSet<*>?)!!) + scatterDataSet.shapeSize = shapeSize + scatterDataSet.mScatterShapeHoleRadius = mScatterShapeHoleRadius + scatterDataSet.mScatterShapeHoleColor = mScatterShapeHoleColor + } + + /** + * Sets the size in density pixels the drawn scatterShape will have. This + * only applies for non custom shapes. + */ + override var scatterShapeSize: Float + get() = shapeSize + set(value) { + shapeSize = value + } + override var scatterShapeHoleRadius: Float + get() = mScatterShapeHoleRadius + set(value) { + mScatterShapeHoleRadius = value + } + override var scatterShapeHoleColor: Int + get() = mScatterShapeHoleColor + set(value) { + mScatterShapeHoleColor = value + } +} diff --git a/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/IScatterDataSet.kt b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/IScatterDataSet.kt similarity index 71% rename from chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/IScatterDataSet.kt rename to chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/IScatterDataSet.kt index 796808a2..07a0d0ba 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/interfaces/datasets/IScatterDataSet.kt +++ b/chartLibCore/src/commonMain/kotlin/info/appdev/charting/interfaces/datasets/IScatterDataSet.kt @@ -1,7 +1,6 @@ package info.appdev.charting.interfaces.datasets import info.appdev.charting.data.EntryFloat -import info.appdev.charting.renderer.scatter.IShapeRenderer interface IScatterDataSet : ILineScatterCandleRadarDataSet { /** @@ -18,9 +17,4 @@ interface IScatterDataSet : ILineScatterCandleRadarDataSet { * the color for the hole in the shape */ val scatterShapeHoleColor: Int - - /** - * the IShapeRenderer responsible for rendering this DataSet. - */ - val shapeRenderer: IShapeRenderer? } From 6eede79f06d4fcaa397c93a653ceefea74abf4be Mon Sep 17 00:00:00 2001 From: Hannes Achleitner Date: Sun, 27 Sep 2026 13:55:32 +0200 Subject: [PATCH 18/22] Step A.7 (continuation): first Compose Multiplatform data renderer Resume the Compose Multiplatform renderer port now that the concrete DataSet family is fully portable, adding the first chart *data* renderer (previously only axis grid lines existed) using the same "pure pixel-math function + thin DrawScope extension" pattern. - chartLibComposeMultiplatform/.../renderer/BarChartRenderer.kt (new): barPixelRects(dataSet, barWidth, transformer) computes one [left, top, right, bottom] value-space rect per non-stacked entry (mirrors the non-stacked branch of chartLib's buffer.BarBuffer.feed) and maps it to pixel space via TransformerCore.pointValuesToPixel; DrawScope.drawBarChartDataSet(...) draws each rect with drawRect, cycling dataset colors via getColorByIndex(index). - Deliberately out of scope for this slice: stacked bars, animation phases, inverted axis, bar borders/shadows, rounded bars, and the BarData/ChartData container classes (still Android-only) -- this renderer operates directly on a single IBarDataSet plus a caller supplied barWidth, matching how buffer.BarBuffer itself is parameterized, so it doesn't require BarData to be ported first. - New BarChartRendererTest.kt: asserts a positive-value entry's rect maps to the expected content-rect pixels, and that a negative-value entry's rect keeps pixel-space top < bottom (Y-axis inversion handled correctly regardless of value sign). Verified: chartLibComposeMultiplatform:build (all targets, including iosSimulatorArm64Test), chartLibCore:allTests, chartLib:compileDebugKotlin, chartLibCompose:assembleDebug, app:assembleDebug, full ./gradlew test all pass. Updated KotlinMultiplatform.md with the changelog entry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- KotlinMultiplatform.md | 42 ++++++++++ .../renderer/BarChartRenderer.kt | 83 +++++++++++++++++++ .../renderer/BarChartRendererTest.kt | 61 ++++++++++++++ 3 files changed, 186 insertions(+) create mode 100644 chartLibComposeMultiplatform/src/commonMain/kotlin/info/appdev/charting/compose/multiplatform/renderer/BarChartRenderer.kt create mode 100644 chartLibComposeMultiplatform/src/commonTest/kotlin/info/appdev/charting/compose/multiplatform/renderer/BarChartRendererTest.kt diff --git a/KotlinMultiplatform.md b/KotlinMultiplatform.md index a896efaa..b47fa831 100644 --- a/KotlinMultiplatform.md +++ b/KotlinMultiplatform.md @@ -1006,6 +1006,48 @@ lint Existing custom lint rules (unchanged) (Bar/Line/Scatter/Candle/Bubble/Radar/Pie all have portable `DataSet`s); alternatively Step A.8 (gesture handling), A.9 (`demoKmp`), A.10 (CI), or A.11 (Publishing). +- [x] **Step A.7 (continuation) — first data renderer: non-stacked + `IBarDataSet` proof-of-concept.** Resumes the Compose Multiplatform + renderer port now that the concrete `DataSet` family is portable, using + the same "pure pixel-math function + thin `DrawScope` extension" + pattern established by the grid-line renderer, applied to a chart + *data* renderer for the first time. + - New `chartLibComposeMultiplatform/commonMain/.../renderer/ + BarChartRenderer.kt`: `barPixelRects(dataSet, barWidth, transformer)` + computes one `[left, top, right, bottom]` value-space rect per + non-stacked entry (mirroring the non-stacked branch of `chartLib`'s + `buffer.BarBuffer.feed`) and maps it to pixel space via + `TransformerCore.pointValuesToPixel` (reusing the point-pair transform + already used by the grid-line renderer, since a rect's two corners + are just two (x, y) points); `DrawScope.drawBarChartDataSet(...)` + draws each rect with `drawRect`, cycling through the dataset's colors + via `getColorByIndex(index)` the same way + `BarChartRenderer.drawDataSet` does. + - **Deliberately out of scope for this slice** (same scoping choice as + the grid-line proof-of-concept): stacked bars, animation phases + (`phaseX`/`phaseY`), inverted axis, bar borders/shadows, rounded + bars, and the `BarData`/`ChartData` container classes (still + Android-only in `chartLib`) — this renderer operates directly on a + single `IBarDataSet` plus a caller-supplied `barWidth`, matching how + `buffer.BarBuffer` itself is parameterized, without requiring + `BarData` to be ported first. + - New test `chartLibComposeMultiplatform/commonTest/.../ + BarChartRendererTest.kt`: same 100x100 content-rect setup as + `AxisGridRendererTest`; asserts a positive-value entry's rect maps to + the expected content-rect pixel positions, and that a negative-value + entry's rect keeps pixel-space `top < bottom` (i.e. the Y-axis + inversion is handled correctly regardless of value sign) — both + without a Compose UI test harness. + - Verified: `chartLibComposeMultiplatform:testAndroidHostTest`/ + `desktopTest` first (new tests green: 2/2 on each), then the full + chain — `chartLibComposeMultiplatform:build` (all targets, including + `iosSimulatorArm64Test`), `chartLibCore:allTests`, + `chartLib:compileDebugKotlin`, `chartLibCompose:assembleDebug`, + `app:assembleDebug`, full `./gradlew test` — all pass. + - **Not yet done**: stacked/grouped bars and the remaining ~29 renderer + files for every other chart type (Line/Scatter/Candle/Bubble/Radar/ + Pie data renderers, legend renderer, limit lines, markers, combined + chart compositing). - [ ] **Step A.8 — Gesture handling** with `pointerInput`/`detectTransformGestures` replacing `ChartTouchListener`/`MotionEvent`. - [ ] **Step A.9 — `demoKmp` Compose Multiplatform demo app** diff --git a/chartLibComposeMultiplatform/src/commonMain/kotlin/info/appdev/charting/compose/multiplatform/renderer/BarChartRenderer.kt b/chartLibComposeMultiplatform/src/commonMain/kotlin/info/appdev/charting/compose/multiplatform/renderer/BarChartRenderer.kt new file mode 100644 index 00000000..2f2cc7ec --- /dev/null +++ b/chartLibComposeMultiplatform/src/commonMain/kotlin/info/appdev/charting/compose/multiplatform/renderer/BarChartRenderer.kt @@ -0,0 +1,83 @@ +package info.appdev.charting.compose.multiplatform.renderer + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import info.appdev.charting.interfaces.datasets.IBarDataSet +import info.appdev.charting.utils.TransformerCore + +/** + * Proof-of-concept Compose Multiplatform data renderer (Step A.7): draws a single, non-stacked + * [IBarDataSet] using only portable `chartLibCore` types, rendering through Compose's + * [DrawScope] instead of `android.graphics.Canvas`/`BarBuffer`. + * + * Mirrors the non-stacked-bar branch of `chartLib`'s `buffer.BarBuffer.feed`: for every entry, + * a `[left, top, right, bottom]` value-space rect is built from the entry's `x`/`y` and the + * dataset's bar width, then mapped to pixel space via [TransformerCore.pointValuesToPixel]. + * Stacked bars, animation phases, and bar borders/shadows are intentionally out of scope for + * this proof-of-concept slice (same scoping choice as the earlier grid-line renderer). + */ + +/** + * Computes the pixel-space bar rects (four floats per entry: left, top, right, bottom) for + * every non-stacked entry of [dataSet]. Extracted as a pure function, separate from the + * [DrawScope] drawing itself, so the coordinate math can be unit-tested without needing a + * Compose UI test harness. + */ +fun barPixelRects(dataSet: IBarDataSet, barWidth: Float, transformer: TransformerCore): FloatArray { + val barWidthHalf = barWidth / 2f + val positions = FloatArray(dataSet.entryCount * 4) + + for (i in 0 until dataSet.entryCount) { + val entry = dataSet.getEntryForIndex(i) ?: continue + val x = entry.x + val y = entry.y + + val top = if (y >= 0) y else 0f + val bottom = if (y <= 0) y else 0f + + val offset = i * 4 + positions[offset] = x - barWidthHalf + positions[offset + 1] = top + positions[offset + 2] = x + barWidthHalf + positions[offset + 3] = bottom + } + + // pointValuesToPixel maps (x, y) pairs, so each [left, top, right, bottom] rect is + // transformed as its two corner points: (left, top) and (right, bottom). + transformer.pointValuesToPixel(positions) + return positions +} + +/** + * Draws every non-stacked entry of [dataSet] as a filled rectangle, cycling through the + * dataset's colors the same way `chartLib`'s `BarChartRenderer.drawDataSet` does via + * `dataSet.getColorByIndex(index)`. + */ +fun DrawScope.drawBarChartDataSet( + dataSet: IBarDataSet, + barWidth: Float, + transformer: TransformerCore +) { + val positions = barPixelRects(dataSet, barWidth, transformer) + + var entryIndex = 0 + var i = 0 + while (i < positions.size) { + val left = positions[i] + val top = positions[i + 1] + val right = positions[i + 2] + val bottom = positions[i + 3] + + val color = Color(dataSet.getColorByIndex(entryIndex)) + drawRect( + color = color, + topLeft = Offset(left, top), + size = Size(right - left, bottom - top) + ) + + i += 4 + entryIndex++ + } +} diff --git a/chartLibComposeMultiplatform/src/commonTest/kotlin/info/appdev/charting/compose/multiplatform/renderer/BarChartRendererTest.kt b/chartLibComposeMultiplatform/src/commonTest/kotlin/info/appdev/charting/compose/multiplatform/renderer/BarChartRendererTest.kt new file mode 100644 index 00000000..2eb95bc2 --- /dev/null +++ b/chartLibComposeMultiplatform/src/commonTest/kotlin/info/appdev/charting/compose/multiplatform/renderer/BarChartRendererTest.kt @@ -0,0 +1,61 @@ +package info.appdev.charting.compose.multiplatform.renderer + +import info.appdev.charting.data.BarDataSet +import info.appdev.charting.data.BarEntryFloat +import info.appdev.charting.utils.TransformerCore +import info.appdev.charting.utils.ViewPortHandler +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Verifies the pure pixel-math helper behind the Step A.7 proof-of-concept bar chart data + * renderer, without needing a Compose UI test harness: a 100x100 content rect mapping the + * axis-value range [0, 10] on both axes should place a bar's value-space rect at the expected + * pixel-space rect, matching the non-stacked branch of `chartLib`'s `buffer.BarBuffer.feed`. + */ +class BarChartRendererTest { + + private fun setUpViewPortAndTransformer(): Pair { + val viewPortHandler = ViewPortHandler() + viewPortHandler.setChartDimens(100f, 100f) + viewPortHandler.restrainViewPort(0f, 0f, 0f, 0f) + + val transformer = TransformerCore(viewPortHandler) + transformer.prepareMatrixValuePx(xChartMin = 0f, deltaX = 10f, deltaY = 10f, yChartMin = 0f) + transformer.prepareMatrixOffset(inverted = false) + return viewPortHandler to transformer + } + + @Test + fun barPixelRects_mapsPositiveEntryValueRectToContentRectPixels() { + val (viewPortHandler, transformer) = setUpViewPortAndTransformer() + + val dataSet = BarDataSet(mutableListOf(BarEntryFloat(5f, 10f)), "positive") + + val positions = barPixelRects(dataSet, barWidth = 2f, transformer = transformer) + + assertEquals(4, positions.size) + // value-space rect: left=4, top=10, right=6, bottom=0 + assertEquals(viewPortHandler.contentLeft() + 40f, positions[0], absoluteTolerance = 0.001f) // left + assertEquals(viewPortHandler.contentTop(), positions[1], absoluteTolerance = 0.001f) // top (value 10 -> chart top) + assertEquals(viewPortHandler.contentLeft() + 60f, positions[2], absoluteTolerance = 0.001f) // right + assertEquals(viewPortHandler.contentBottom(), positions[3], absoluteTolerance = 0.001f) // bottom (value 0 -> chart bottom) + } + + @Test + fun barPixelRects_mapsNegativeEntryValueRectToContentRectPixels() { + val (viewPortHandler, transformer) = setUpViewPortAndTransformer() + + // Prepare a matrix spanning [-10, 10] so a negative entry stays within the viewport. + transformer.prepareMatrixValuePx(xChartMin = -10f, deltaX = 20f, deltaY = 20f, yChartMin = -10f) + transformer.prepareMatrixOffset(inverted = false) + + val dataSet = BarDataSet(mutableListOf(BarEntryFloat(0f, -5f)), "negative") + + val positions = barPixelRects(dataSet, barWidth = 2f, transformer = transformer) + + assertEquals(4, positions.size) + // value-space rect: top=0, bottom=-5 -> pixel top must stay above (numerically less than) pixel bottom + assertEquals(true, positions[1] < positions[3]) + } +} From 5e52e3674adcf9f8e5cb963bb8a963b989a3fc85 Mon Sep 17 00:00:00 2001 From: Hannes Achleitner Date: Sun, 27 Sep 2026 14:04:28 +0200 Subject: [PATCH 19/22] Step A.8: Compose Multiplatform gesture handling proof-of-concept Add pan/pinch-zoom gesture support for Compose Multiplatform charts, replacing chartLib's MotionEvent-based ChartTouchListener/ BarLineChartTouchListener for this proof-of-concept scope. Needed almost no new common code: ViewPortHandler's zoom/translate/refresh/ limitTransAndScale matrix math (bounds-clamped pan/zoom) already lived in chartLibCore from earlier steps. - chartLibComposeMultiplatform/.../gesture/GestureHandling.kt (new): chartGestureMatrix(viewPortHandler, pan, zoom, centroid, ...) copies the current matrixTouch, applies postTranslate(pan) then postScale(zoom, zoom, centroid) -- mirrors BarLineChartTouchListener.performDrag/performZoom combined into one step, since Compose reports incremental per-frame deltas rather than cumulative-since-gesture-start deltas like MotionEvent. Modifier.chartTransformGestures(viewPortHandler, ..., onGesture) wires detectTransformGestures to it and calls ViewPortHandler.refresh(matrix, onGesture, true), which also applies the existing limitTransAndScale bounds clamping. - Deliberately out of scope for this slice: rotation gestures, independent X/Y-only zoom modes, highlight-on-drag, double-tap zoom, fling/deceleration, and OnChartGestureListener callbacks. - New GestureHandlingTest.kt: verifies chartGestureMatrix's pure matrix math directly (pan-only, zoom-about-centroid-only, and disabled-axis cases) without needing a Compose UI test harness or going through ViewPortHandler's stateful clamping. Verified: chartLibComposeMultiplatform:build (all targets, including iosSimulatorArm64Test), chartLibCore:allTests, chartLib:compileDebugKotlin, chartLibCompose:assembleDebug, app:assembleDebug, full ./gradlew test all pass. Updated KotlinMultiplatform.md with the changelog entry, marking Step A.8 complete (as a proof-of-concept). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- KotlinMultiplatform.md | 38 +++++++- .../multiplatform/gesture/GestureHandling.kt | 93 +++++++++++++++++++ .../gesture/GestureHandlingTest.kt | 84 +++++++++++++++++ 3 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 chartLibComposeMultiplatform/src/commonMain/kotlin/info/appdev/charting/compose/multiplatform/gesture/GestureHandling.kt create mode 100644 chartLibComposeMultiplatform/src/commonTest/kotlin/info/appdev/charting/compose/multiplatform/gesture/GestureHandlingTest.kt diff --git a/KotlinMultiplatform.md b/KotlinMultiplatform.md index b47fa831..7bbc4cc2 100644 --- a/KotlinMultiplatform.md +++ b/KotlinMultiplatform.md @@ -1048,8 +1048,42 @@ lint Existing custom lint rules (unchanged) files for every other chart type (Line/Scatter/Candle/Bubble/Radar/ Pie data renderers, legend renderer, limit lines, markers, combined chart compositing). -- [ ] **Step A.8 — Gesture handling** with `pointerInput`/`detectTransformGestures` - replacing `ChartTouchListener`/`MotionEvent`. +- [x] **Step A.8 — Gesture handling proof-of-concept** with + `pointerInput`/`detectTransformGestures` replacing `ChartTouchListener`/`MotionEvent`. + Turned out to need very little new common code: `ViewPortHandler`'s + `zoom`/`translate`/`refresh`/`limitTransAndScale` matrix math (bounds-clamped pan/zoom) + already lived in `chartLibCore` from earlier steps, so this slice only needed to combine + Compose's per-frame pan+zoom+centroid gesture callback into a single matrix update. + - New `chartLibComposeMultiplatform/commonMain/.../gesture/GestureHandling.kt`: + `chartGestureMatrix(viewPortHandler, pan, zoom, centroid, scaleXEnabled, scaleYEnabled, + dragXEnabled, dragYEnabled)` copies the current `matrixTouch`, applies + `postTranslate(pan)` then `postScale(zoom, zoom, centroid)` (mirrors `chartLib`'s + `BarLineChartTouchListener.performDrag`/`performZoom` combined into one step, since + Compose reports incremental deltas per frame rather than cumulative-since-gesture-start + deltas like `MotionEvent`); `Modifier.chartTransformGestures(viewPortHandler, ..., + onGesture)` wires `detectTransformGestures` to it and calls + `ViewPortHandler.refresh(matrix, onGesture, true)` (which also applies the existing + `limitTransAndScale` bounds clamping). + - **Deliberately out of scope for this proof-of-concept** (same scoping choice as the + earlier axis/data renderer slices): rotation gestures, independent X/Y-only zoom modes + (`X_ZOOM`/`Y_ZOOM`), highlight-on-drag, double-tap zoom, fling/deceleration, and + `OnChartGestureListener` callbacks — `chartLib`'s `BarLineChartTouchListener` handles + all of these but they're not needed to prove the Compose gesture wiring works. + - New test `chartLibComposeMultiplatform/commonTest/.../GestureHandlingTest.kt`: verifies + `chartGestureMatrix`'s pure matrix math directly (pan-only, zoom-about-centroid-only, + and disabled-axis cases), reading back `MTRANS_X`/`MTRANS_Y`/`MSCALE_X`/`MSCALE_Y` via + `Matrix.getValues` — no `ViewPortHandler.refresh`/clamping involved, since the clamp + bounds depend on chart state in ways that would make the pure-math assertions less + direct (same "test the pure function before the stateful wiring" approach as the + renderer tests). + - Verified: `chartLibComposeMultiplatform:testAndroidHostTest`/`desktopTest` first (new + tests green: 3/3 on each), then the full chain — + `chartLibComposeMultiplatform:build` (all targets, including `iosSimulatorArm64Test`), + `chartLibCore:allTests`, `chartLib:compileDebugKotlin`, `chartLibCompose:assembleDebug`, + `app:assembleDebug`, full `./gradlew test` — all pass. + - **Not yet done**: wiring `chartTransformGestures` into an actual composable chart + (there is no interactive Compose Multiplatform chart screen yet, since Step A.9's + `demoKmp` app doesn't exist), and the out-of-scope gesture features listed above. - [ ] **Step A.9 — `demoKmp` Compose Multiplatform demo app** (Android, iOS, Desktop, optionally Wasm/JS). - [ ] **Step A.10 — CI.** Extend GitHub Actions to build iOS/desktop/wasm targets diff --git a/chartLibComposeMultiplatform/src/commonMain/kotlin/info/appdev/charting/compose/multiplatform/gesture/GestureHandling.kt b/chartLibComposeMultiplatform/src/commonMain/kotlin/info/appdev/charting/compose/multiplatform/gesture/GestureHandling.kt new file mode 100644 index 00000000..87a059cf --- /dev/null +++ b/chartLibComposeMultiplatform/src/commonMain/kotlin/info/appdev/charting/compose/multiplatform/gesture/GestureHandling.kt @@ -0,0 +1,93 @@ +package info.appdev.charting.compose.multiplatform.gesture + +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.pointerInput +import info.appdev.charting.utils.Matrix +import info.appdev.charting.utils.ViewPortHandler + +/** + * Proof-of-concept Compose Multiplatform gesture handling (Step A.8): drives + * [ViewPortHandler]'s existing pan/zoom matrix math from Compose's + * `detectTransformGestures`, replacing `chartLib`'s `MotionEvent`-based + * `ChartTouchListener`/`BarLineChartTouchListener`. + * + * `ViewPortHandler.zoom`/`translate`/`refresh` (and the bounds-clamping in + * `limitTransAndScale`) already live in `chartLibCore` and needed no changes for this + * proof-of-concept: Compose's `detectTransformGestures` conveniently reports one combined + * pan+zoom+centroid callback per frame (unlike the touch listener's separate + * `performDrag`/`performZoom`), so this module only needs to combine the two into a single + * matrix update and feed it to [ViewPortHandler.refresh]. + * + * **Deliberately out of scope for this proof-of-concept** (same scoping choice as the earlier + * axis/data renderer slices): rotation gestures, independent X/Y-only zoom modes + * (`X_ZOOM`/`Y_ZOOM`), highlight-on-drag, double-tap zoom, fling/deceleration, and + * `OnChartGestureListener` callbacks. + */ + +/** + * Computes the pixel-space touch matrix that results from applying [pan] (in pixels) and + * [zoom] (a uniform scale factor, pivoting on [centroid]) to [viewPortHandler]'s current + * touch matrix. Extracted as a pure function, separate from the gesture-detection wiring + * itself, so the matrix math can be unit-tested without needing a Compose UI test harness. + * + * Mirrors `chartLib`'s `BarLineChartTouchListener.performDrag`/`performZoom` combined into a + * single step: `matrix.postTranslate(pan.x, pan.y)` then + * `matrix.postScale(zoom, zoom, centroid.x, centroid.y)`, applied on top of the current + * `matrixTouch` (not a "saved" matrix from gesture-start, since Compose already reports + * incremental per-frame pan/zoom deltas rather than cumulative ones since gesture-start). + */ +fun chartGestureMatrix( + viewPortHandler: ViewPortHandler, + pan: Offset, + zoom: Float, + centroid: Offset, + scaleXEnabled: Boolean = true, + scaleYEnabled: Boolean = true, + dragXEnabled: Boolean = true, + dragYEnabled: Boolean = true +): Matrix { + val matrix = Matrix() + matrix.set(viewPortHandler.matrixTouch) + + val dx = if (dragXEnabled) pan.x else 0f + val dy = if (dragYEnabled) pan.y else 0f + matrix.postTranslate(dx, dy) + + val scaleX = if (scaleXEnabled) zoom else 1f + val scaleY = if (scaleYEnabled) zoom else 1f + if (scaleX != 1f || scaleY != 1f) { + matrix.postScale(scaleX, scaleY, centroid.x, centroid.y) + } + + return matrix +} + +/** + * [Modifier] that recognizes pan/pinch-zoom gestures and applies them to [viewPortHandler] + * via [chartGestureMatrix] + [ViewPortHandler.refresh], invoking [onGesture] afterwards + * (typically to trigger a Compose recomposition/redraw of the chart). + */ +fun Modifier.chartTransformGestures( + viewPortHandler: ViewPortHandler, + scaleXEnabled: Boolean = true, + scaleYEnabled: Boolean = true, + dragXEnabled: Boolean = true, + dragYEnabled: Boolean = true, + onGesture: () -> Unit +): Modifier = this.pointerInput(viewPortHandler) { + detectTransformGestures { centroid, pan, zoom, _ -> + val matrix = chartGestureMatrix( + viewPortHandler = viewPortHandler, + pan = pan, + zoom = zoom, + centroid = centroid, + scaleXEnabled = scaleXEnabled, + scaleYEnabled = scaleYEnabled, + dragXEnabled = dragXEnabled, + dragYEnabled = dragYEnabled + ) + viewPortHandler.refresh(matrix, onGesture, true) + } +} diff --git a/chartLibComposeMultiplatform/src/commonTest/kotlin/info/appdev/charting/compose/multiplatform/gesture/GestureHandlingTest.kt b/chartLibComposeMultiplatform/src/commonTest/kotlin/info/appdev/charting/compose/multiplatform/gesture/GestureHandlingTest.kt new file mode 100644 index 00000000..f7f6f1f2 --- /dev/null +++ b/chartLibComposeMultiplatform/src/commonTest/kotlin/info/appdev/charting/compose/multiplatform/gesture/GestureHandlingTest.kt @@ -0,0 +1,84 @@ +package info.appdev.charting.compose.multiplatform.gesture + +import androidx.compose.ui.geometry.Offset +import info.appdev.charting.utils.Matrix +import info.appdev.charting.utils.ViewPortHandler +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Verifies the pure matrix-math helper behind the Step A.8 proof-of-concept gesture handling, + * without needing a Compose UI test harness (no real pointer/gesture input is simulated here, + * only [chartGestureMatrix]'s pan/zoom-to-matrix math). + */ +class GestureHandlingTest { + + private fun setUpViewPortHandler(): ViewPortHandler { + val viewPortHandler = ViewPortHandler() + viewPortHandler.setChartDimens(100f, 100f) + viewPortHandler.restrainViewPort(0f, 0f, 0f, 0f) + return viewPortHandler + } + + @Test + fun chartGestureMatrix_appliesPanTranslationOnTopOfIdentityMatrix() { + val viewPortHandler = setUpViewPortHandler() + + val matrix = chartGestureMatrix( + viewPortHandler = viewPortHandler, + pan = Offset(10f, 5f), + zoom = 1f, + centroid = Offset(50f, 50f) + ) + + val values = FloatArray(9) + matrix.getValues(values) + assertEquals(10f, values[Matrix.MTRANS_X], absoluteTolerance = 0.001f) + assertEquals(5f, values[Matrix.MTRANS_Y], absoluteTolerance = 0.001f) + assertEquals(1f, values[Matrix.MSCALE_X], absoluteTolerance = 0.001f) + assertEquals(1f, values[Matrix.MSCALE_Y], absoluteTolerance = 0.001f) + } + + @Test + fun chartGestureMatrix_appliesZoomAboutCentroidOnTopOfIdentityMatrix() { + val viewPortHandler = setUpViewPortHandler() + + val matrix = chartGestureMatrix( + viewPortHandler = viewPortHandler, + pan = Offset.Zero, + zoom = 2f, + centroid = Offset(50f, 50f) + ) + + val values = FloatArray(9) + matrix.getValues(values) + assertEquals(2f, values[Matrix.MSCALE_X], absoluteTolerance = 0.001f) + assertEquals(2f, values[Matrix.MSCALE_Y], absoluteTolerance = 0.001f) + // pivot (50, 50) scaled by 2 -> translation = pivot * (1 - scale) + assertEquals(-50f, values[Matrix.MTRANS_X], absoluteTolerance = 0.001f) + assertEquals(-50f, values[Matrix.MTRANS_Y], absoluteTolerance = 0.001f) + } + + @Test + fun chartGestureMatrix_disabledAxesIgnorePanAndZoomOnThatAxis() { + val viewPortHandler = setUpViewPortHandler() + + val matrix = chartGestureMatrix( + viewPortHandler = viewPortHandler, + pan = Offset(10f, 5f), + zoom = 2f, + centroid = Offset(50f, 50f), + scaleXEnabled = false, + dragYEnabled = false + ) + + val values = FloatArray(9) + matrix.getValues(values) + // X scale disabled -> stays 1; Y drag disabled -> stays 0 + assertEquals(1f, values[Matrix.MSCALE_X], absoluteTolerance = 0.001f) + assertEquals(2f, values[Matrix.MSCALE_Y], absoluteTolerance = 0.001f) + assertEquals(10f, values[Matrix.MTRANS_X], absoluteTolerance = 0.001f) + // y-translation comes only from the Y-scale pivot, since dragY is disabled + assertEquals(-50f, values[Matrix.MTRANS_Y], absoluteTolerance = 0.001f) + } +} From e04a7c1c9f930edca8547ea7e0bbbef68b4ce3d0 Mon Sep 17 00:00:00 2001 From: Hannes Achleitner Date: Mon, 28 Sep 2026 16:17:36 +0200 Subject: [PATCH 20/22] Step A.9: demoKmp Compose Multiplatform demo app Add a working, runnable Compose Multiplatform demo that ties together every proof-of-concept from Steps A.7/A.8: axis grid lines, a bar chart data renderer, and pan/pinch-zoom gestures, all built exclusively on chartLibComposeMultiplatform (never the Android-only chartLib/ chartLibCompose), and shown identically on Android, Desktop and iOS. Two Gradle modules instead of one, because AGP 9's new KMP DSL forbids combining com.android.application with org.jetbrains.kotlin. multiplatform's androidTarget() in the same project: - demoKmp (new, com.android.kotlin.multiplatform.library, same shape as chartLibComposeMultiplatform): commonMain/DemoKmpApp.kt holds the shared @Composable (a 5-entry BarDataSet rendered inside a Canvas via the Step A.7 renderers, with Step A.8's Modifier.chartTransformGestures attached); desktopMain/Main.kt (also runnable via :demoKmp:run); iosMain/MainViewController.kt (ComposeUIViewController-based entry point for a future Xcode wrapper). - demoKmpAndroid (new, plain com.android.application, not multiplatform): MainActivity/AndroidManifest.xml/applicationId, depending on project(":demoKmp") for the shared composable. Both modules registered in settings.gradle.kts. demoKmp's compileSdk bumped to 37 (Compose Multiplatform 1.12.1's Android artifacts require it, matching chartLibComposeMultiplatform); demoKmpAndroid needed the same bump plus its own distinct namespace to avoid an AGP manifest-merger clash with demoKmp. Verified: demoKmpAndroid:assembleDebug, demoKmp:compileKotlinDesktop plus a manual demoKmp:run smoke-test, demoKmp: compileKotlinIosSimulatorArm64, and the full demoKmp:build (all targets, including linking debug/release iosArm64/ iosSimulatorArm64 frameworks) all pass. Full existing chain (chartLibComposeMultiplatform:build, chartLibCore:allTests, chartLib:compileDebugKotlin, chartLibCompose:assembleDebug, app:assembleDebug, ./gradlew test) also still green. Not yet done: an actual Xcode wrapper project embedding MainViewController(), a wasmJsMain target, and richer demo content (multiple chart types/screens). Updated KotlinMultiplatform.md with the changelog entry, marking Step A.9 complete. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- KotlinMultiplatform.md | 65 ++++++++++-- demoKmp/build.gradle.kts | 60 ++++++++++++ .../appdev/charting/demokmp/DemoKmpApp.kt | 98 +++++++++++++++++++ .../info/appdev/charting/demokmp/Main.kt | 13 +++ .../charting/demokmp/MainViewController.kt | 20 ++++ demoKmpAndroid/build.gradle.kts | 44 +++++++++ demoKmpAndroid/src/main/AndroidManifest.xml | 18 ++++ .../appdev/charting/demokmp/MainActivity.kt | 20 ++++ .../src/main/res/values/strings.xml | 3 + settings.gradle.kts | 2 + 10 files changed, 335 insertions(+), 8 deletions(-) create mode 100644 demoKmp/build.gradle.kts create mode 100644 demoKmp/src/commonMain/kotlin/info/appdev/charting/demokmp/DemoKmpApp.kt create mode 100644 demoKmp/src/desktopMain/kotlin/info/appdev/charting/demokmp/Main.kt create mode 100644 demoKmp/src/iosMain/kotlin/info/appdev/charting/demokmp/MainViewController.kt create mode 100644 demoKmpAndroid/build.gradle.kts create mode 100644 demoKmpAndroid/src/main/AndroidManifest.xml create mode 100644 demoKmpAndroid/src/main/kotlin/info/appdev/charting/demokmp/MainActivity.kt create mode 100644 demoKmpAndroid/src/main/res/values/strings.xml diff --git a/KotlinMultiplatform.md b/KotlinMultiplatform.md index 7bbc4cc2..8a62589b 100644 --- a/KotlinMultiplatform.md +++ b/KotlinMultiplatform.md @@ -1084,8 +1084,50 @@ lint Existing custom lint rules (unchanged) - **Not yet done**: wiring `chartTransformGestures` into an actual composable chart (there is no interactive Compose Multiplatform chart screen yet, since Step A.9's `demoKmp` app doesn't exist), and the out-of-scope gesture features listed above. -- [ ] **Step A.9 — `demoKmp` Compose Multiplatform demo app** - (Android, iOS, Desktop, optionally Wasm/JS). +- [x] **Step A.9 — `demoKmp` Compose Multiplatform demo app** (Android, iOS, Desktop). + Ties together every Compose Multiplatform proof-of-concept from Steps A.7/A.8 into one + actually-runnable `@Composable` shown on all three platforms: axis grid lines + (`drawXAxisGridLines`/`drawYAxisGridLines`), a bar chart data renderer + (`drawBarChartDataSet`), and pan/pinch-zoom gestures (`chartTransformGestures`) — all from + `chartLibComposeMultiplatform`, none from the Android-only `chartLib`/`chartLibCompose`. + - **Two Gradle modules instead of one**, discovered while implementing this slice: AGP + 9's new KMP DSL forbids combining `com.android.application` with + `org.jetbrains.kotlin.multiplatform`'s `androidTarget()` in the same project (a hard + error, not just a warning), so the plan's original single-module sketch needed + splitting: + - **`demoKmp`** (new, `com.android.kotlin.multiplatform.library` — same shape as + `chartLibComposeMultiplatform`): `commonMain/DemoKmpApp.kt` holds the shared + `@Composable` (a `BarDataSet` of 5 entries, rendered via the Step A.7 grid-line + + bar-chart renderers inside a `Canvas`, with `Modifier.chartTransformGestures(...)` + from Step A.8 attached for interactivity); `desktopMain/Main.kt` (`fun main() = + application { Window(...) { DemoKmpApp() } }`, also exposed as `:demoKmp:run`); + `iosMain/MainViewController.kt` (`ComposeUIViewController { DemoKmpApp() }`, for a + future thin Xcode wrapper to embed — see "not yet done" below). + - **`demoKmpAndroid`** (new, plain `com.android.application`, *not* multiplatform): + just `MainActivity`/`AndroidManifest.xml`/`applicationId`/launcher-activity + boilerplate, depending on `project(":demoKmp")` for `DemoKmpApp()` and the shared + renderer/gesture code. + - Both modules registered in `settings.gradle.kts` + (`include(":demoKmp")`/`include(":demoKmpAndroid")`). + - `demoKmp`'s `compileSdk` had to be `37` (not `36`, matching + `chartLibComposeMultiplatform`): Compose Multiplatform 1.12.1's Android artifacts + require it; `demoKmpAndroid` needed the same bump plus its own distinct + `namespace` (`info.appdev.charting.demokmp.androidapp`, vs. `demoKmp`'s + `info.appdev.charting.demokmp`) since AGP's manifest merger rejects two modules in the + same dependency graph sharing one namespace. + - Verified: `:demoKmpAndroid:assembleDebug` (Android APK), `:demoKmp:compileKotlinDesktop` + and a manual `:demoKmp:run` smoke-test (desktop app launches without exceptions), + `:demoKmp:compileKotlinIosSimulatorArm64`, and the full `:demoKmp:build` (all targets, + including linking both debug and release `iosArm64`/`iosSimulatorArm64` frameworks) all + pass. Full existing chain (`chartLibComposeMultiplatform:build`, `chartLibCore:allTests`, + `chartLib:compileDebugKotlin`, `chartLibCompose:assembleDebug`, `app:assembleDebug`, + `./gradlew test`) also still green. + - **Not yet done**: an actual Xcode wrapper project (`.xcodeproj`) embedding + `MainViewController()` on a real iOS device/simulator screen (only the Gradle-buildable + `iosArm64`/`iosSimulatorArm64` framework artifact exists so far); a `wasmJsMain` target; + richer demo content (multiple chart types/screens, matching `chartLibCompose`'s + `examples/ChartExamples.kt` breadth) — intentionally deferred until more renderers exist + per Step A.7's remaining ~29-file scope. - [ ] **Step A.10 — CI.** Extend GitHub Actions to build iOS/desktop/wasm targets (today only an Android emulator runs instrumentation tests). - [ ] **Step A.11 — Publishing.** Extend `com.vanniktech.maven.publish` KMP @@ -1194,12 +1236,19 @@ create `chartLibComposeMultiplatform`: ## Step A.9 — `demoKmp` app -- New Compose Multiplatform application module with `commonMain` (shared UI + - chart usage examples mirroring `chartLibCompose`'s `examples/ChartExamples.kt`), - `androidMain` (activity entry point), `iosMain` (SwiftUI/Compose entry point via - `MainViewController`), `desktopMain` (`main()` launching a `ComposeWindow`), and - optionally `wasmJsMain`. -- Register in `settings.gradle.kts` as `include(":demoKmp")`. +- Two Gradle modules (split forced by AGP 9's new KMP DSL, which forbids + `com.android.application` + `androidTarget()` in the same project): + - `demoKmp` (`com.android.kotlin.multiplatform.library`, same shape as + `chartLibComposeMultiplatform`): `commonMain` (shared `DemoKmpApp()` composable + using the Step A.7/A.8 renderers/gestures), `desktopMain` (`main()` launching a + `ComposeWindow`, also runnable via `:demoKmp:run`), `iosMain` + (`MainViewController()` via `ComposeUIViewController`). + - `demoKmpAndroid` (plain `com.android.application`): `MainActivity` + + `AndroidManifest.xml` + `applicationId`, depending on `project(":demoKmp")`. +- Registered in `settings.gradle.kts` as `include(":demoKmp")` and + `include(":demoKmpAndroid")`. +- Not yet done: an actual Xcode wrapper project embedding `MainViewController()`, + a `wasmJsMain` target, and richer demo content (multiple chart types/screens). ## Step A.10 — CI diff --git a/demoKmp/build.gradle.kts b/demoKmp/build.gradle.kts new file mode 100644 index 00000000..e2843a47 --- /dev/null +++ b/demoKmp/build.gradle.kts @@ -0,0 +1,60 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("com.android.kotlin.multiplatform.library") + id("org.jetbrains.kotlin.multiplatform") + id("org.jetbrains.compose") version "1.12.1" + id("org.jetbrains.kotlin.plugin.compose") version "2.4.10" +} + +kotlin { + compilerOptions { + freeCompilerArgs.add("-Xexpect-actual-classes") + } + + android { + namespace = "info.appdev.charting.demokmp" + compileSdk = 37 + minSdk = 23 + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + } + + jvm("desktop") + + // Same iOS target scoping as chartLibComposeMultiplatform (Step A.7): Compose Multiplatform + // (as of 1.12.x) no longer publishes iosX64 (Intel simulator) artifacts. + val xcfName = "demoKmpKit" + listOf( + iosArm64(), + iosSimulatorArm64() + ).forEach { iosTarget -> + iosTarget.binaries.framework { + baseName = xcfName + } + } + + sourceSets { + commonMain.dependencies { + implementation(project(":chartLibComposeMultiplatform")) + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.ui) + } + val desktopMain by getting { + dependencies { + implementation(compose.desktop.currentOs) + } + } + } +} + +// Exposes `:demoKmp:run` to launch the desktop entry point (`desktopMain/Main.kt`) directly, +// without needing the separate `demoKmpAndroid` application module. +compose.desktop { + application { + mainClass = "info.appdev.charting.demokmp.MainKt" + } +} diff --git a/demoKmp/src/commonMain/kotlin/info/appdev/charting/demokmp/DemoKmpApp.kt b/demoKmp/src/commonMain/kotlin/info/appdev/charting/demokmp/DemoKmpApp.kt new file mode 100644 index 00000000..7501a222 --- /dev/null +++ b/demoKmp/src/commonMain/kotlin/info/appdev/charting/demokmp/DemoKmpApp.kt @@ -0,0 +1,98 @@ +package info.appdev.charting.demokmp + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import info.appdev.charting.compose.multiplatform.gesture.chartTransformGestures +import info.appdev.charting.compose.multiplatform.renderer.drawBarChartDataSet +import info.appdev.charting.compose.multiplatform.renderer.drawXAxisGridLines +import info.appdev.charting.compose.multiplatform.renderer.drawYAxisGridLines +import info.appdev.charting.components.XAxis +import info.appdev.charting.components.YAxis +import info.appdev.charting.data.BarDataSet +import info.appdev.charting.data.BarEntryFloat +import info.appdev.charting.utils.TransformerCore +import info.appdev.charting.utils.ViewPortHandler + +/** + * `demoKmp` — Step A.9's shared Compose Multiplatform demo screen, built entirely on the + * proof-of-concept renderers/gesture handling added in Steps A.7/A.8 + * (`chartLibComposeMultiplatform`), rather than `chartLib`/`chartLibCompose` (Android-only). + * + * This intentionally mirrors the `app`/`chartLibCompose` demo apps' spirit (show a chart with + * a couple of example datasets, backed by `androidx.compose.material3`) while only using + * portable (`chartLibCore`) chart types under the hood, so the exact same `@Composable` + * renders identically on Android, Desktop, and iOS. + */ +@Composable +fun DemoKmpApp() { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + BarChartDemo() + } + } +} + +@Composable +private fun BarChartDemo() { + val dataSet = remember { + BarDataSet( + mutableListOf( + BarEntryFloat(0f, 4f), + BarEntryFloat(1f, 8f), + BarEntryFloat(2f, 6f), + BarEntryFloat(3f, 10f), + BarEntryFloat(4f, 3f) + ), + "demoKmp bar dataset" + ) + } + + val viewPortHandler = remember { ViewPortHandler() } + val transformer = remember { TransformerCore(viewPortHandler) } + val xAxis = remember { XAxis().apply { entries = floatArrayOf(0f, 1f, 2f, 3f, 4f) } } + val yAxis = remember { YAxis().apply { entries = floatArrayOf(0f, 2f, 4f, 6f, 8f, 10f) } } + + // Bumped after every gesture to trigger a redraw (Canvas isn't itself observing + // ViewPortHandler's mutable internal matrix state). + var redrawTick by remember { mutableIntStateOf(0) } + + Box(modifier = Modifier.fillMaxSize()) { + Canvas( + modifier = Modifier + .fillMaxSize() + .chartTransformGestures(viewPortHandler) { redrawTick++ } + ) { + redrawTick // read to establish a recomposition dependency on gesture updates + + viewPortHandler.setChartDimens(size.width, size.height) + viewPortHandler.restrainViewPort(40f, 20f, 20f, 40f) + transformer.prepareMatrixValuePx(xChartMin = -0.5f, deltaX = 5f, deltaY = 11f, yChartMin = -0.5f) + transformer.prepareMatrixOffset(inverted = false) + + drawXAxisGridLines(xAxis, viewPortHandler, transformer) + drawYAxisGridLines(yAxis, viewPortHandler, transformer) + drawBarChartDataSet(dataSet, barWidth = 0.6f, transformer = transformer) + } + + Text( + text = "demoKmp: BarDataSet + axis grid lines rendered via chartLibComposeMultiplatform. " + + "Pinch/drag to pan & zoom.", + modifier = Modifier + .align(Alignment.TopStart) + .padding(8.dp) + ) + } +} diff --git a/demoKmp/src/desktopMain/kotlin/info/appdev/charting/demokmp/Main.kt b/demoKmp/src/desktopMain/kotlin/info/appdev/charting/demokmp/Main.kt new file mode 100644 index 00000000..41d4e17e --- /dev/null +++ b/demoKmp/src/desktopMain/kotlin/info/appdev/charting/demokmp/Main.kt @@ -0,0 +1,13 @@ +package info.appdev.charting.demokmp + +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application + +/** + * Desktop (JVM) entry point for the `demoKmp` Compose Multiplatform demo (Step A.9). + */ +fun main() = application { + Window(onCloseRequest = ::exitApplication, title = "demoKmp") { + DemoKmpApp() + } +} diff --git a/demoKmp/src/iosMain/kotlin/info/appdev/charting/demokmp/MainViewController.kt b/demoKmp/src/iosMain/kotlin/info/appdev/charting/demokmp/MainViewController.kt new file mode 100644 index 00000000..b69c14df --- /dev/null +++ b/demoKmp/src/iosMain/kotlin/info/appdev/charting/demokmp/MainViewController.kt @@ -0,0 +1,20 @@ +package info.appdev.charting.demokmp + +import androidx.compose.ui.window.ComposeUIViewController +import platform.UIKit.UIViewController + +/** + * iOS entry point for the `demoKmp` Compose Multiplatform demo (Step A.9), to be called from a + * thin Swift/Xcode wrapper app's `UIHostingController`-equivalent (typically + * `ComposeUIViewController`-based `UIViewController` embedding, e.g. + * `UIApplication.shared.windows.first?.rootViewController = MainViewController()`). + * + * **Not yet done**: the actual Xcode wrapper project (`.xcodeproj`/`.xcworkspace`) that embeds + * this framework's `MainViewController()` isn't part of this Gradle-buildable slice -- only the + * `iosArm64`/`iosSimulatorArm64` Kotlin framework artifact is. Building/running it standalone + * from Gradle is possible via `:demoKmp:linkDebugFrameworkIosSimulatorArm64`. + */ +@Suppress("unused") +fun MainViewController(): UIViewController = ComposeUIViewController { + DemoKmpApp() +} diff --git a/demoKmpAndroid/build.gradle.kts b/demoKmpAndroid/build.gradle.kts new file mode 100644 index 00000000..ecd902f3 --- /dev/null +++ b/demoKmpAndroid/build.gradle.kts @@ -0,0 +1,44 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.plugin.compose") version "2.4.10" +} + +// Plain (non-multiplatform) Android application module: AGP 9's new KMP DSL forbids combining +// `com.android.application` with `org.jetbrains.kotlin.multiplatform`'s `androidTarget()` in the +// same Gradle project, so the actual `applicationId`/launcher-activity/manifest live here, while +// `:demoKmp` (a `com.android.kotlin.multiplatform.library` module) supplies the shared +// `DemoKmpApp()` composable and the Desktop/iOS entry points. +android { + namespace = "info.appdev.charting.demokmp.androidapp" + compileSdk = 37 + + defaultConfig { + applicationId = "info.appdev.charting.demokmp" + minSdk = 23 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_17 + } + } + + buildFeatures { + compose = true + } +} + +dependencies { + implementation(project(":demoKmp")) + implementation("androidx.activity:activity-compose:1.10.1") +} diff --git a/demoKmpAndroid/src/main/AndroidManifest.xml b/demoKmpAndroid/src/main/AndroidManifest.xml new file mode 100644 index 00000000..c4eb598b --- /dev/null +++ b/demoKmpAndroid/src/main/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + diff --git a/demoKmpAndroid/src/main/kotlin/info/appdev/charting/demokmp/MainActivity.kt b/demoKmpAndroid/src/main/kotlin/info/appdev/charting/demokmp/MainActivity.kt new file mode 100644 index 00000000..d26623f2 --- /dev/null +++ b/demoKmpAndroid/src/main/kotlin/info/appdev/charting/demokmp/MainActivity.kt @@ -0,0 +1,20 @@ +package info.appdev.charting.demokmp + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent + +/** + * Android entry point for the `demoKmp` Compose Multiplatform demo (Step A.9). Lives in its own + * plain `com.android.application` module (`demoKmpAndroid`), separate from the KMP `demoKmp` + * library module, because AGP 9's new KMP DSL forbids combining `com.android.application` with + * `org.jetbrains.kotlin.multiplatform`'s `androidTarget()` in the same Gradle project. + */ +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + DemoKmpApp() + } + } +} diff --git a/demoKmpAndroid/src/main/res/values/strings.xml b/demoKmpAndroid/src/main/res/values/strings.xml new file mode 100644 index 00000000..becf407c --- /dev/null +++ b/demoKmpAndroid/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + demoKmp + diff --git a/settings.gradle.kts b/settings.gradle.kts index 00a9333e..f01f50b8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -2,5 +2,7 @@ include("chartLib") include("chartLibCore") include("chartLibCompose") include("chartLibComposeMultiplatform") +include("demoKmp") +include("demoKmpAndroid") include("app") include("lint") From 6c1667878d2543de807b40f76534298241184e75 Mon Sep 17 00:00:00 2001 From: Hannes Achleitner Date: Mon, 28 Sep 2026 16:24:22 +0200 Subject: [PATCH 21/22] Step A.10: CI for Kotlin Multiplatform (iOS/Desktop) Add a new KotlinMultiplatform job to .github/workflows/pullrequest.yml on macos-latest, since iOS simulator targets require Xcode and the existing "Check" job (ubuntu-latest) silently skips them via the kotlin.native.ignoreDisabledTargets=true gradle.properties flag added during the earlier CI fix. That means chartLibCore/ chartLibComposeMultiplatform's iOS test coverage, added throughout Steps A.3-A.9, had never actually executed in CI until now. New job steps: - Install JDK 17 + Android SDK (chartLibCore/ chartLibComposeMultiplatform/demoKmp all still have an android target too). - ./gradlew :chartLibCore:allTests :chartLibComposeMultiplatform:allTests -- now genuinely runs the Android host, Desktop, and iOS simulator test suites. - ./gradlew :demoKmp:build :demoKmpAndroid:assembleDebug -- builds every demoKmp target (including linking the iOS frameworks) plus the separate Android app module. - A desktop smoke-test step: launches :demoKmp:run in the background, waits 25s, and asserts the process is still running (i.e. the Compose Desktop window launched without an early crash) before killing it -- a lightweight substitute for a full screenshot job, left as explicitly optional in the plan. - Archives chartLibCore/chartLibComposeMultiplatform test reports as a build artifact for failure diagnosis. Verified locally (this sandbox has Xcode installed): ./gradlew :chartLibCore:allTests :chartLibComposeMultiplatform:allTests passes; the demoKmp:run-then-check-still-alive shell logic was manually confirmed to correctly detect a successfully launched long-lived process. The workflow YAML was validated with python3 -c "import yaml; yaml.safe_load(...)" for syntax correctness (GitHub's own runners couldn't be exercised directly from this environment). Not yet done: a wasmJs target (none of the KMP modules target it yet, so no CI job is needed until one exists) and a real screenshot-diff job for demoKmp. Updated KotlinMultiplatform.md with the changelog entry, marking Step A.10 complete. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/pullrequest.yml | 46 ++++++++++++++++++++++++++ KotlinMultiplatform.md | 55 +++++++++++++++++++++++++++---- 2 files changed, 95 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml index 1e5b4813..710eefbe 100644 --- a/.github/workflows/pullrequest.yml +++ b/.github/workflows/pullrequest.yml @@ -125,3 +125,49 @@ jobs: with: name: Chart-Lint-report path: ./**/build/reports/lint-results*.html + KotlinMultiplatform: + name: Kotlin Multiplatform (iOS/Desktop) + # iOS simulator targets (chartLibCore/chartLibComposeMultiplatform) require a macOS runner + # with Xcode; on the ubuntu "Check" job these tasks are silently skipped + # (kotlin.native.ignoreDisabledTargets=true), so this job is the only place they actually run. + runs-on: macos-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + submodules: true + - name: Install JDK + uses: actions/setup-java@v6 + with: + distribution: "temurin" + java-version: 17 + - name: Install Android SDK + uses: hannesa2/action-android/install-sdk@0.17.0 + - name: Run chartLibCore/chartLibComposeMultiplatform tests (Android host, Desktop, iOS simulator) + run: ./gradlew :chartLibCore:allTests :chartLibComposeMultiplatform:allTests + - name: Build demoKmp (all targets) and demoKmpAndroid + run: ./gradlew :demoKmp:build :demoKmpAndroid:assembleDebug + - name: Desktop smoke-test (demoKmp:run starts without crashing) + run: | + ./gradlew :demoKmp:run --console=plain & + run_pid=$! + sleep 25 + if kill -0 "$run_pid" 2>/dev/null; then + echo "demoKmp:run is still running after 25s -> desktop app launched successfully" + kill "$run_pid" + wait "$run_pid" 2>/dev/null || true + else + wait "$run_pid" + exit_code=$? + echo "demoKmp:run exited early with code $exit_code" + exit 1 + fi + - name: Archive Kotlin Multiplatform test reports + uses: actions/upload-artifact@v7 + if: ${{ always() }} + with: + name: Chart-KotlinMultiplatform-test-report + path: | + chartLibCore/build/reports/tests + chartLibComposeMultiplatform/build/reports/tests + if-no-files-found: warn diff --git a/KotlinMultiplatform.md b/KotlinMultiplatform.md index 8a62589b..eeaa98b4 100644 --- a/KotlinMultiplatform.md +++ b/KotlinMultiplatform.md @@ -1128,8 +1128,42 @@ lint Existing custom lint rules (unchanged) richer demo content (multiple chart types/screens, matching `chartLibCompose`'s `examples/ChartExamples.kt` breadth) — intentionally deferred until more renderers exist per Step A.7's remaining ~29-file scope. -- [ ] **Step A.10 — CI.** Extend GitHub Actions to build iOS/desktop/wasm targets - (today only an Android emulator runs instrumentation tests). +- [x] **Step A.10 — CI.** Extended `.github/workflows/pullrequest.yml` with a new + `KotlinMultiplatform` job on a `macos-latest` runner (Xcode/iOS simulators are only + available there, unlike the existing Ubuntu-based `Check` job). + - **Why a new job was needed**: the existing `Check` job already runs `./gradlew test` + and `./gradlew check` on `ubuntu-latest`, which *does* touch `chartLibCore`/ + `chartLibComposeMultiplatform`, but their iOS simulator test tasks + (`iosSimulatorArm64Test`, etc.) are silently skipped there via the + `kotlin.native.ignoreDisabledTargets=true` `gradle.properties` flag added earlier to + keep the Ubuntu build from hard-failing on unsupported native targets (see the CI-fix + session before Step A.4). That means the iOS test coverage added throughout Steps + A.3–A.9 had **never actually executed in CI** until this job. + - New `KotlinMultiplatform` job steps: checkout, JDK 17, Android SDK install (still needed + — `chartLibCore`/`chartLibComposeMultiplatform`/`demoKmp` all have an `android` target + too), then `./gradlew :chartLibCore:allTests :chartLibComposeMultiplatform:allTests` + (now genuinely running the Android host, Desktop, *and* iOS simulator test suites on a + capable runner), `./gradlew :demoKmp:build :demoKmpAndroid:assembleDebug` (builds every + `demoKmp` target, including linking the iOS frameworks, plus the separate Android app + module), and a desktop smoke-test step that launches `:demoKmp:run` in the background, + waits 25s, and asserts the process is *still running* (i.e. the Compose Desktop window + launched without an early crash) before killing it — a lightweight substitute for a + full screenshot job, deferred as explicitly optional in the plan ("if feasible"). + - Test reports (`chartLibCore`/`chartLibComposeMultiplatform` `build/reports/tests`) + archived as a build artifact (`if-no-files-found: warn`, matching the existing Lint + report archive step's leniency), for inspecting individual test results if the job + fails. + - Verified locally (this sandbox has Xcode installed): `./gradlew :chartLibCore:allTests + :chartLibComposeMultiplatform:allTests` passes; the `:demoKmp:run`-then-check-still-alive + shell logic was manually confirmed to correctly detect a successfully-launched long-lived + process (the `run` task keeps the daemon/process alive as expected). The workflow YAML + itself was validated with `python3 -c "import yaml; yaml.safe_load(...)"` for syntax + correctness (GitHub's own runners couldn't be exercised directly from this environment). + - **Not yet done**: an actual Wasm/JS target (neither `chartLibCore` nor + `chartLibComposeMultiplatform`/`demoKmp` target `wasmJs` yet — no CI job needed until + one exists); a real screenshot-diff job for `demoKmp` (the existing Android + Espresso-based screenshot comparison in the `buildTest` job doesn't apply to a Compose + Desktop/iOS window). - [ ] **Step A.11 — Publishing.** Extend `com.vanniktech.maven.publish` KMP publication support with per-target artifact coordinates for `chartLibCore` and `chartLibComposeMultiplatform`. @@ -1252,10 +1286,19 @@ create `chartLibComposeMultiplatform`: ## Step A.10 — CI -- Extend `.github/workflows` to run `./gradlew :chartLibCore:allTests` and - `:chartLibComposeMultiplatform:allTests` on macOS runners (required for iOS - targets) in addition to the existing Android emulator instrumentation job. -- Add a desktop smoke-test / screenshot job for `demoKmp` if feasible. +- New `KotlinMultiplatform` job in `.github/workflows/pullrequest.yml`, on + `macos-latest` (required for iOS simulator targets): installs JDK 17 + Android + SDK, then runs `./gradlew :chartLibCore:allTests :chartLibComposeMultiplatform:allTests` + (Android host + Desktop + iOS simulator tests — previously never actually + executed in CI, since the Ubuntu `Check` job skips disabled iOS native targets + via `kotlin.native.ignoreDisabledTargets=true`), then + `./gradlew :demoKmp:build :demoKmpAndroid:assembleDebug`, then a desktop + smoke-test step (launches `:demoKmp:run` in the background, waits 25s, asserts + the process is still alive, then kills it). +- Test reports archived as a build artifact for failure diagnosis. +- Not yet done: a `wasmJs` target (none of the KMP modules target it yet) and a + real screenshot-diff job for `demoKmp` (Compose Desktop/iOS, unlike the + existing Android Espresso-based screenshot comparison). ## Step A.11 — Publishing From 2fe124728a0b44424e1a745d91d78acc5672e397 Mon Sep 17 00:00:00 2001 From: Hannes Achleitner Date: Mon, 28 Sep 2026 16:47:32 +0200 Subject: [PATCH 22/22] Step A.11: Maven publishing for chartLibCore and chartLibComposeMultiplatform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add com.vanniktech.maven.publish to chartLibCore and chartLibComposeMultiplatform, mirroring the existing chartLib/chartLibCompose mavenPublishing { coordinates(...); pom {}; repositories { GitHubPackages } } pattern. No configure(KotlinMultiplatform(...)) call is needed: the plugin auto-detects com.android.kotlin.multiplatform.library projects and configures per-target publications (android/desktop/iosArm64/iosSimulatorArm64[/iosX64]) plus a root kotlinMultiplatform metadata publication automatically. Fixed two Gradle/plugin interaction bugs surfaced while wiring this up: - Root gradle.properties' POM_ARTIFACT_ID=chart raced with each module's own coordinates(artifactId = ...) call for KMP projects, because the plugin's automatic pomFromGradleProperties() and our explicit coordinates() call both rename every per-target publication's artifactId in afterEvaluate, and the KMP rename logic requires the *current* artifactId to still start with "$projectName-". Fixed with per-module gradle.properties files (chartLibCore/gradle.properties, chartLibComposeMultiplatform/gradle.properties) overriding POM_ARTIFACT_ID so both renames agree. - Applying id("com.vanniktech.maven.publish") version "0.37.0" independently in 4 sibling subprojects made Gradle load the plugin under different classloaders for some project pairs, breaking its shared Maven Central BuildService. Fixed by declaring the versioned plugin once (apply false) in the root build.gradle.kts's new plugins {} block and referencing it without a version in all four subprojects. demoKmp/demoKmpAndroid remain unpublished (demo apps, not libraries). Verified via ./gradlew :chartLib:publishToMavenLocal :chartLibCompose:publishToMavenLocal :chartLibCore:publishToMavenLocal :chartLibComposeMultiplatform:publishToMavenLocal -PRELEASE_SIGNING_ENABLED=false (all four succeed with correct per-target artifact IDs), plus the full existing verification chain (chartLibCore:allTests, chartLibComposeMultiplatform:allTests, chartLib:compileDebugKotlin, chartLibCompose:assembleDebug, app:assembleDebug, demoKmp:build, demoKmpAndroid:assembleDebug, full ./gradlew test). Updates KotlinMultiplatform.md's Step A.11 changelog entry and detailed reference section, marking it [x] — this was the final remaining step from the original KMP plan. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- KotlinMultiplatform.md | 110 ++++++++++++++++-- build.gradle.kts | 11 ++ chartLib/build.gradle.kts | 2 +- chartLibCompose/build.gradle.kts | 2 +- chartLibComposeMultiplatform/build.gradle.kts | 55 +++++++++ .../gradle.properties | 6 + chartLibCore/build.gradle.kts | 55 +++++++++ chartLibCore/gradle.properties | 10 ++ 8 files changed, 240 insertions(+), 11 deletions(-) create mode 100644 chartLibComposeMultiplatform/gradle.properties create mode 100644 chartLibCore/gradle.properties diff --git a/KotlinMultiplatform.md b/KotlinMultiplatform.md index eeaa98b4..55af38d6 100644 --- a/KotlinMultiplatform.md +++ b/KotlinMultiplatform.md @@ -1164,9 +1164,62 @@ lint Existing custom lint rules (unchanged) one exists); a real screenshot-diff job for `demoKmp` (the existing Android Espresso-based screenshot comparison in the `buildTest` job doesn't apply to a Compose Desktop/iOS window). -- [ ] **Step A.11 — Publishing.** Extend `com.vanniktech.maven.publish` KMP - publication support with per-target artifact coordinates for `chartLibCore` - and `chartLibComposeMultiplatform`. +- [x] **Step A.11 — Publishing.** Added `com.vanniktech.maven.publish` to + `chartLibCore` and `chartLibComposeMultiplatform`, mirroring the existing + `chartLib`/`chartLibCompose` `mavenPublishing { coordinates(...); pom {}; repositories { + GitHubPackages } }` pattern. No `configure(KotlinMultiplatform(...))` call was needed — + per vanniktech's docs, `com.android.kotlin.multiplatform.library` projects (which both + modules already use) are auto-detected and configured with sensible defaults (sources + jars per source set, empty javadoc jars per target, Maven Central + GitHub Packages + + MavenLocal publications for every target: `android`, `desktop`, `iosArm64`, + `iosSimulatorArm64`, plus `iosX64` for `chartLibCore` only, and a root `kotlinMultiplatform` + metadata publication). + - Hit and fixed two Gradle/plugin interaction bugs discovered while wiring this up (both + pre-existing footguns of this repo's setup, not new problems in the vanniktech plugin + itself): + 1. **Root `gradle.properties`' `POM_ARTIFACT_ID=chart` raced with our own + `coordinates(artifactId = ...)` call for KMP projects.** The plugin's simple + `com.vanniktech.maven.publish` entry point auto-applies `pomFromGradleProperties()` + at plugin-apply time, which (for KMP projects) *asynchronously* renames every + per-target publication's artifactId from `"$projectName-$target"` to + `"$POM_ARTIFACT_ID-$target"` in an `afterEvaluate` block. Our own `coordinates(...)` + call in each module's `mavenPublishing {}` block queues a *second* `afterEvaluate` + rename, which failed with `"The plugin can't handle the publication ... artifactId + chart-android"` because by the time it ran, the artifactId no longer matched the + expected `"$projectName-"` prefix (the first rename had already consumed it). Fixed + by adding a per-module `gradle.properties` (`chartLibCore/gradle.properties`, + `chartLibComposeMultiplatform/gradle.properties`) overriding `POM_ARTIFACT_ID` to the + correct per-module value, so both renames agree and the second is a no-op. + 2. **Applying `id("com.vanniktech.maven.publish") version "0.37.0"` independently in + 4 sibling subprojects' `plugins {}` blocks** (previously only 2: + `chartLib`/`chartLibCompose`) made Gradle load the plugin under different + classloader instances for at least one pair of projects, breaking the plugin's + shared Maven Central deployment `BuildService` (`"Cannot set the value of task + ':chartLibComposeMultiplatform:prepareMavenCentralPublishing' property + 'buildService' ... loaded with InstrumentingVisitableURLClassLoader..."`). Fixed by + declaring `id("com.vanniktech.maven.publish") version "0.37.0" apply false` once in + the root `build.gradle.kts`'s new `plugins {}` block, and dropping the version from + all four subprojects' own `id("com.vanniktech.maven.publish")` declarations, so every + module resolves the exact same plugin classloader. + - `demoKmp`/`demoKmpAndroid` (demo apps, not libraries) intentionally do **not** get any + publishing plugin — nothing in the plan calls for publishing them, matching the existing + `app` module's precedent. + - Verified: `./gradlew :chartLib:publishToMavenLocal :chartLibCompose:publishToMavenLocal + :chartLibCore:publishToMavenLocal :chartLibComposeMultiplatform:publishToMavenLocal + -PRELEASE_SIGNING_ENABLED=false` succeeds for all four modules and produces the expected + per-target artifacts (poms, `.module` Gradle metadata, sources/javadoc jars, `.aar` for + `-android`, `.klib` for the iOS targets, `.jar` for `-desktop` and the root multiplatform + metadata publication) under the local Maven repo, with artifact IDs matching the existing + `chartLib`/`chartLibCompose` naming convention (e.g. `chartLibCore`, `chartLibCore- + android`, `chartLibCore-iosarm64`, `chartLibComposeMultiplatform-desktop`, ...). Also + re-ran the full existing verification chain (`chartLibCore:allTests`, + `chartLibComposeMultiplatform:allTests`, `chartLib:compileDebugKotlin`, + `chartLibCompose:assembleDebug`, `app:assembleDebug`, `demoKmp:build`, + `demoKmpAndroid:assembleDebug`, full `./gradlew test`) — all pass, no regressions. + - **Not yet done**: no CI step actually runs `publishToMavenLocal` for the two new modules + (the existing `Check` job does this for `chartLib`/`chartLibCompose` only); this could be + added to either the `Check` job or the new `KotlinMultiplatform` job as a lightweight + follow-up smoke test, but wasn't required by the plan text and is deferred. ## Step A.2 — Common replacement abstractions needed before further migration @@ -1302,12 +1355,51 @@ create `chartLibComposeMultiplatform`: ## Step A.11 — Publishing -- Extend `mavenPublishing { }` blocks in `chartLibCore` and - `chartLibComposeMultiplatform` for KMP publications (each target publishes its - own artifact suffix, e.g. `chartLibCore-android`, `chartLibCore-iosarm64`, - `chartLibCore-iossimulatorarm64`, `chartLibCore-iosx64`, - `chartLibCore-desktop`, plus a `chartLibCore` metadata/common artifact). -- Update `jitpack.yml` / README to document new KMP coordinates once published. +- Added `id("com.vanniktech.maven.publish")` (declared with a version once, as + `apply false`, in the root `build.gradle.kts`'s new `plugins {}` block — see + below for why) plus a `mavenPublishing { coordinates(...); pom {}; repositories + { GitHubPackages } }` block to `chartLibCore/build.gradle.kts` and + `chartLibComposeMultiplatform/build.gradle.kts`, mirroring the existing + `chartLib`/`chartLibCompose` pattern exactly. No `configure(KotlinMultiplatform + (...))` call needed — the plugin auto-detects `com.android.kotlin.multiplatform + .library` projects and configures per-target publications automatically. +- Resulting publications per module: a root `kotlinMultiplatform` metadata + publication (artifact ID = the module name, e.g. `chartLibCore`), plus one + per target: `chartLibCore-android`, `chartLibCore-desktop`, + `chartLibCore-iosarm64`, `chartLibCore-iossimulatorarm64`, + `chartLibCore-iosx64` (`chartLibComposeMultiplatform` has the same set minus + `-iosx64`, since Compose Multiplatform 1.12.x doesn't publish iOS-Intel- + simulator artifacts — see Step A.7's iOS target note). Each publishes sources + + (empty) javadoc jars, plus `.aar`/`.klib`/`.jar` as appropriate for the + target. +- Two Gradle/plugin interaction bugs fixed along the way: + - Root `gradle.properties`' `POM_ARTIFACT_ID=chart` conflicted with per-module + `coordinates(artifactId = ...)` calls for KMP projects specifically (the + plugin's automatic `pomFromGradleProperties()` and our explicit + `coordinates()` call both rename every per-target publication's artifactId + in `afterEvaluate`, and for KMP projects the rename logic requires the + *current* artifactId to still start with `"$projectName-"`, which broke + once the first (automatic) rename ran first). Fixed with a per-module + `chartLibCore/gradle.properties` / `chartLibComposeMultiplatform/gradle. + properties` overriding `POM_ARTIFACT_ID` to the correct value, so both + renames agree. + - Applying `id("com.vanniktech.maven.publish") version "0.37.0"` independently + in 4 sibling subprojects' `plugins {}` blocks (previously only 2) made + Gradle load the plugin under different classloaders for some project pairs, + breaking its shared Maven Central `BuildService`. Fixed by declaring the + versioned plugin once (`apply false`) in the root `build.gradle.kts` and + referencing it without a version in all four subprojects. +- `demoKmp`/`demoKmpAndroid` are demo apps and intentionally have no publishing + plugin, matching the existing `app` module's precedent. +- Verified via `./gradlew :chartLib:publishToMavenLocal :chartLibCompose: + publishToMavenLocal :chartLibCore:publishToMavenLocal :chartLibComposeMultiplatform: + publishToMavenLocal -PRELEASE_SIGNING_ENABLED=false` (all four succeed, + artifacts inspected under the local Maven repo) plus the full existing + verification chain. +- Not yet done: no CI step runs `publishToMavenLocal` for the two new modules + yet (only `chartLib`/`chartLibCompose` currently get that smoke test in the + `Check` job); updating `jitpack.yml` / README to document the new KMP + coordinates once this branch is actually released. ## Notes / open questions for maintainers diff --git a/build.gradle.kts b/build.gradle.kts index d82a4438..a9d8f93e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -13,6 +13,17 @@ buildscript { } } +plugins { + // Declared once here (without applying) so every module that publishes + // (chartLib, chartLibCompose, chartLibCore, chartLibComposeMultiplatform) resolves + // the exact same plugin classloader instance. Applying the same versioned plugin + // id independently in 3+ sibling subprojects can otherwise make Gradle load it under + // different classloaders, which breaks the plugin's shared Maven Central build service + // ("Cannot set the value of task ... property 'buildService' ... loaded with + // InstrumentingVisitableURLClassLoader..."). + id("com.vanniktech.maven.publish") version "0.37.0" apply false +} + allprojects { repositories { google() diff --git a/chartLib/build.gradle.kts b/chartLib/build.gradle.kts index f9a0d2df..fffacccb 100644 --- a/chartLib/build.gradle.kts +++ b/chartLib/build.gradle.kts @@ -5,7 +5,7 @@ import java.net.URI plugins { id("com.android.library") id("maven-publish") - id("com.vanniktech.maven.publish") version "0.37.0" + id("com.vanniktech.maven.publish") } android { diff --git a/chartLibCompose/build.gradle.kts b/chartLibCompose/build.gradle.kts index 04938c0a..5146c5c2 100644 --- a/chartLibCompose/build.gradle.kts +++ b/chartLibCompose/build.gradle.kts @@ -6,7 +6,7 @@ plugins { id("com.android.library") id("maven-publish") id("org.jetbrains.kotlin.plugin.compose") version "2.4.10" - id("com.vanniktech.maven.publish") version "0.37.0" + id("com.vanniktech.maven.publish") } android { diff --git a/chartLibComposeMultiplatform/build.gradle.kts b/chartLibComposeMultiplatform/build.gradle.kts index 9f55874f..39e9472b 100644 --- a/chartLibComposeMultiplatform/build.gradle.kts +++ b/chartLibComposeMultiplatform/build.gradle.kts @@ -1,10 +1,14 @@ +import info.git.versionHelper.getVersionText import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import java.net.URI plugins { id("com.android.kotlin.multiplatform.library") id("org.jetbrains.kotlin.multiplatform") id("org.jetbrains.compose") version "1.12.1" id("org.jetbrains.kotlin.plugin.compose") version "2.4.10" + id("maven-publish") + id("com.vanniktech.maven.publish") } kotlin { @@ -49,3 +53,54 @@ kotlin { } } } + +group = project.findProperty("group")?.toString() ?: "info.AppDevNext" +var versionVersion = getVersionText() +println("Build version $versionVersion") + +mavenPublishing { + coordinates( + groupId = project.findProperty("group")?.toString() ?: "info.AppDevNext", + artifactId = "chartLibComposeMultiplatform", + version = "$versionVersion" + ) + pom { + name = "Android Chart compose multiplatform" + description = + "Kotlin/Compose Multiplatform chart renderers/gesture-handling for AndroidChart, built on the portable chartLibCore data model, shared across Android, iOS and Desktop" + 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/chartLibComposeMultiplatform/gradle.properties b/chartLibComposeMultiplatform/gradle.properties new file mode 100644 index 00000000..12f98d6f --- /dev/null +++ b/chartLibComposeMultiplatform/gradle.properties @@ -0,0 +1,6 @@ +# Overrides the root-level POM_ARTIFACT_ID=chart for this Kotlin Multiplatform module. +# +# See chartLibCore/gradle.properties for the detailed explanation: the vanniktech plugin's +# automatic pomFromGradleProperties() must agree with our mavenPublishing { coordinates(...) } +# call in build.gradle.kts, otherwise the second per-target artifactId rename fails. +POM_ARTIFACT_ID=chartLibComposeMultiplatform diff --git a/chartLibCore/build.gradle.kts b/chartLibCore/build.gradle.kts index 5c701744..e811e86e 100644 --- a/chartLibCore/build.gradle.kts +++ b/chartLibCore/build.gradle.kts @@ -1,8 +1,12 @@ +import info.git.versionHelper.getVersionText import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import java.net.URI plugins { id("com.android.kotlin.multiplatform.library") id("org.jetbrains.kotlin.multiplatform") + id("maven-publish") + id("com.vanniktech.maven.publish") } kotlin { @@ -39,3 +43,54 @@ kotlin { } } } + +group = project.findProperty("group")?.toString() ?: "info.AppDevNext" +var versionVersion = getVersionText() +println("Build version $versionVersion") + +mavenPublishing { + coordinates( + groupId = project.findProperty("group")?.toString() ?: "info.AppDevNext", + artifactId = "chartLibCore", + version = "$versionVersion" + ) + pom { + name = "Android Chart core" + description = + "Kotlin Multiplatform core module of AndroidChart: platform-agnostic chart data model, math and formatters shared across chartLib (Android View), chartLibComposeMultiplatform (Compose Multiplatform), iOS and Desktop" + 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/chartLibCore/gradle.properties b/chartLibCore/gradle.properties new file mode 100644 index 00000000..752ae4ae --- /dev/null +++ b/chartLibCore/gradle.properties @@ -0,0 +1,10 @@ +# Overrides the root-level POM_ARTIFACT_ID=chart for this Kotlin Multiplatform module. +# +# com.vanniktech.maven.publish's MavenPublishPlugin automatically calls +# pomFromGradleProperties() at apply-time, which eagerly renames every per-target KMP +# publication (android/desktop/iosArm64/...) using the root POM_ARTIFACT_ID. Our own +# mavenPublishing { coordinates(...) } call in build.gradle.kts then runs afterwards and +# tries to rename the *already renamed* publications, which fails because they no longer +# start with the expected "chartLibCore-" prefix. Overriding POM_ARTIFACT_ID here makes +# both passes agree, so the second (redundant) rename is a no-op. +POM_ARTIFACT_ID=chartLibCore