diff --git a/.paul/ROADMAP.md b/.paul/ROADMAP.md index a1dfbff..73f68e8 100644 --- a/.paul/ROADMAP.md +++ b/.paul/ROADMAP.md @@ -10,7 +10,8 @@ KMP real-time chart library. Six phases v0.1.0 MVP shipped; v0.2.0 polish shippe |-----------|---------|--------|-----------| | v0.1.0 MVP | 0.1.0 | ✓ Shipped | 2026-05-20 | | v0.2.0 Polish | 0.5.0-SNAPSHOT | ✓ Shipped | 2026-05-21 | -| v0.3.0 (TBD) | - | Not started | - | +| v0.3.0 iOS Build Pipeline | 0.5.0-SNAPSHOT | ✓ Shipped | 2026-05-21 | +| v0.4.0 (TBD) | - | Not started | - | --- @@ -56,16 +57,37 @@ Summary: .paul/phases/v0.2.0-plan-SUMMARY.md --- -## v0.3.0 — Potential Scope (TBD) +## v0.3.0 — iOS Build Pipeline (SHIPPED) + +Status: ✓ Complete +Plan: .paul/phases/v0.3.0-ios-build-plan.md + +### Delivered + +- 3 iOS targets compile green (iosX64, iosArm64, iosSimulatorArm64) +- `applyDefaultHierarchyTemplate()` wires iosMain/iosTest across targets +- `linkDebugFrameworkIosSimulatorArm64` green +- `iosSimulatorArm64Test` green — 33 tests, 0 failures +- XCFramework `ChartRealtime` produced (debug + release): ios-arm64 + ios-arm64_x86_64-simulator slices +- Pre-existing compile bugs fixed: + - `@Volatile` → `kotlin.concurrent.Volatile` import + - `@JvmName` → `kotlin.jvm.JvmName` import (OptionalExpectation) + - `String.format` in AxisRenderer → manual `formatFixed`/`formatScientific`/`pow10` helpers + - `NSDate.date()` → `NSDate()` + `timeIntervalSinceReferenceDate` + 1970 epoch offset +- Android non-regression confirmed (`assembleRelease`, `publishToMavenLocal --dry-run`) +- Local Mac config: removed Linux JDK pin from `gradle.properties`, added `local.properties` for Android SDK + +--- + +## v0.4.0 — Potential Scope (TBD) | Item | Effort | Priority | |------|--------|----------| -| Dirty flag render (skip frame if no new data) | S | High — thermal | -| targetFps=30 default | S | High — thermal | -| KDoc on public API surface | M | Medium | -| README with integration example | S | Medium | -| Publish to Maven Central / GitHub Packages | M | Low | -| iOS expect/actual (beyond currentTimeMs) | L | Future | +| Publish to Maven Central / GitHub Packages | M | Medium | +| iOS demo app (SwiftUI) | L | Medium | +| CI workflow GitHub Actions (Android + iOS) | M | Medium | +| Compose compiler stability annotation on SignalConfig | S | Low | +| CocoaPods / SPM publication | M | Low | --- *Roadmap updated: 2026-05-21* diff --git a/.paul/STATE.md b/.paul/STATE.md index a41b849..801eafb 100644 --- a/.paul/STATE.md +++ b/.paul/STATE.md @@ -5,25 +5,26 @@ See: .paul/PROJECT.md (updated 2026-05-21) **Core value:** Android devs plot 200Hz+ multi-signal sensor data without frame drops -**Current focus:** v0.3.0 IN PROGRESS — dirty flag render + targetFps=30 shipped +**Current focus:** v0.3.0 iOS build pipeline SHIPPED — 33 tests pass on iOS sim, XCFramework produced ## Current Position -Milestone: v0.2.0-polish +Milestone: v0.3.0-ios-build Phase: 6 of 6 — Complete -Plan: .paul/phases/v0.2.0-plan.md +Plan: .paul/phases/v0.3.0-ios-build-plan.md Status: Complete -Last activity: 2026-05-21 — dirty flag render, targetFps=30, 17 unit tests, KDoc, README, iOS targets (iosX64/iosArm64/iosSimulatorArm64) + Platform.ios.kt actual. +Last activity: 2026-05-21 — iOS pipeline: compile 3 targets, link debug framework, iosSimulatorArm64Test green (33 tests, 0 failures), XCFramework "ChartRealtime" output (ios-arm64 + ios-arm64_x86_64-simulator slices). Android non-regression confirmed. Progress: - v0.1.0 milestone: [██████████] 100% SHIPPED - v0.2.0 milestone: [██████████] 100% SHIPPED +- v0.3.0 milestone: [██████████] 100% SHIPPED ## Loop Position ``` PLAN ──▶ APPLY ──▶ UNIFY - ✓ ✓ ✓ [v0.2.0 SHIPPED — v0.5.0-SNAPSHOT] + ✓ ✓ ✓ [v0.3.0 iOS build SHIPPED — v0.5.0-SNAPSHOT] ``` ## Accumulated Context @@ -41,6 +42,11 @@ PLAN ──▶ APPLY ──▶ UNIFY | Sensor cap 200Hz (5_000μs) | v0.2.0 | Thermal/battery optimization | | Package dev.dtrentin | v0.2.0 | Personal project, not company | | iOS targets shipped | v0.3.0 | iosX64/iosArm64/iosSimulatorArm64 + Platform.ios.kt | +| iOS build pipeline green | v0.3.0 iOS | 33 tests pass on iOS sim, XCFramework "ChartRealtime" produced | +| timeIntervalSinceReferenceDate + epoch offset | v0.3.0 iOS | K/Native binding doesn't expose timeIntervalSince1970; use 978_307_200s constant | +| applyDefaultHierarchyTemplate() explicit | v0.3.0 iOS | iosMain/iosTest auto-wired across 3 iOS targets | +| Manual format helpers in AxisRenderer | v0.3.0 iOS | String.format JVM-only; private formatFixed/formatScientific/pow10 | +| kotlin.concurrent.Volatile + kotlin.jvm.JvmName imports | v0.3.0 iOS | Multiplatform replacements for JVM-default annotations | ### Deferred Issues @@ -51,21 +57,27 @@ PLAN ──▶ APPLY ──▶ UNIFY | ~~iOS expect/actual beyond currentTimeMs~~ | Init | L | ✓ v0.3.0 | | ~~KDoc on public API surface~~ | v0.2.0 | M | ✓ v0.3.0 | | ~~README with integration example~~ | v0.2.0 | S | ✓ v0.3.0 | -| Publish to Maven Central / GitHub Packages | v0.2.0 | M | v0.3.0 | +| ~~iOS build/test pipeline (compile + iosTest + XCFramework)~~ | v0.3.0 plan | M | ✓ v0.3.0 iOS | +| Publish to Maven Central / GitHub Packages | v0.2.0 | M | v0.4.0 | +| iOS demo app (SwiftUI) | v0.3.0 iOS | L | v0.4.0 | +| CI workflow for iOS build (GitHub Actions) | v0.3.0 iOS | M | v0.4.0 | +| Compose compiler unstable warning on SignalConfig | v0.3.0 iOS | S | v0.4.0 | ### Blockers/Concerns None. ## Session Continuity -Last session: 2026-05-21 -Stopped at: v0.2.0 complete. All AC pass. Git pushed to remote. -Next action: Define v0.3.0 scope (dirty flag render, targetFps=30, KDoc, publish) +Last session: 2026-05-21 (iOS build pipeline) +Stopped at: v0.3.0 iOS shipped. All 5 acceptance items pass (A1-A5). 33 tests green on iOS sim. ChartRealtime.xcframework at chart-realtime/build/XCFrameworks/release/. +Next action: Define v0.4.0 scope (Maven Central publish, iOS demo app, CI) Resume context: -- Library: AndroidCharts/chart-realtime, published dev.dtrentin:chart-realtime:0.5.0-SNAPSHOT to mavenLocal +- Library: chart-realtime, published dev.dtrentin:chart-realtime:0.5.0-SNAPSHOT to mavenLocal - App: AndroidChartsApp consuming library, builds clean - Remote: ssh://git@3nt-git.duckdns.org:222/davide.trentin/KMPCharts.git - Package: dev.dtrentin.chart +- iOS: 3 targets compile, link, test (33 tests). XCFramework name `ChartRealtime`, baseName CamelCase. +- Local Mac setup: Xcode.app at /Applications/Xcode.app, iOS sim runtime installed (iPhone 17 family), local.properties points to ~/Library/Android/sdk, JDK 21 Temurin via JAVA_HOME. --- *STATE.md — Updated after every significant action* diff --git a/.paul/phases/v0.3.0-ios-build-plan.md b/.paul/phases/v0.3.0-ios-build-plan.md new file mode 100644 index 0000000..df4ab8c --- /dev/null +++ b/.paul/phases/v0.3.0-ios-build-plan.md @@ -0,0 +1,109 @@ +# v0.3.0 — iOS Build & Test Pipeline Plan + +## Goal +Wire `chart-realtime` for full iOS pipeline: compile all 3 iOS targets, link debug framework on `iosSimulatorArm64`, run `commonTest` on iOS sim, produce release `XCFramework`. Keep Android green. No new features, no demo app, no SPM/CocoaPods/CI. + +Acceptance: +- A1: `./gradlew :chart-realtime:compileKotlinIosX64 compileKotlinIosArm64 compileKotlinIosSimulatorArm64` green +- A2: `./gradlew :chart-realtime:linkDebugFrameworkIosSimulatorArm64` green +- A3: `./gradlew :chart-realtime:iosSimulatorArm64Test` green, executes existing common tests +- A4: `./gradlew :chart-realtime:assembleXCFramework` produces `chart-realtime/build/XCFrameworks/release/chart-realtime.xcframework` +- A5: `./gradlew :chart-realtime:assemble` green (Android non-regression) + +## Locked decisions +- T0: remove `org.gradle.java.home` pin (rely on JAVA_HOME) +- XCFramework baseName: `ChartRealtime` (CamelCase iOS convention) +- Include `iosX64` target in XCFramework +- Local Mac dev only, no CI + +--- + +## Tasks + +### T0 — Mac JDK / gradle.properties sanity +- Goal: Unblock local Mac build. Current `gradle.properties` pins Linux JDK path. Pre-req for A1–A5. +- Files: `gradle.properties` +- Agent: `gradle-expert` +- Action: remove line `org.gradle.java.home=/home/dtrentin/.local/share/JetBrains/Toolbox/apps/android-studio/jbr`. Keep other lines unchanged. +- Deps: none +- Verify: `./gradlew :chart-realtime:help` + +### T1+T2+T4 — Batched build.gradle.kts edit +- Goal: iOS hierarchy explicit + XCFramework task. +- Files: `chart-realtime/build.gradle.kts` +- Agent: `gradle-expert` (single edit, single agent) +- Actions: + - Apply default hierarchy template explicitly (`applyDefaultHierarchyTemplate()`) + - Configure each iOS target with `binaries.framework { baseName = "ChartRealtime"; xcf.add(this) }` using `XCFramework("ChartRealtime")` helper from `org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework` + - Keep `compose.material3` in `commonMain` (CMP 1.8.0 supports iOS native) + - Do not break Android target, publishing, or existing source-set deps +- Deps: T0 +- Verify: + - `./gradlew :chart-realtime:compileKotlinIosX64` + - `./gradlew :chart-realtime:compileKotlinIosArm64` + - `./gradlew :chart-realtime:compileKotlinIosSimulatorArm64` + - `./gradlew :chart-realtime:linkDebugFrameworkIosSimulatorArm64` + - `./gradlew :chart-realtime:assembleXCFramework` + +### T3 — iosTest wiring +- Goal: `iosTest` source set runs existing `commonTest` files on iOS sim. +- Files: `chart-realtime/build.gradle.kts` (only if hierarchy template doesn't auto-wire) +- Agent: `unit-test-writer` +- Inputs: existing `commonTest.dependencies { implementation(libs.kotlin.test) }`. Default hierarchy template wires `iosTest → commonTest` automatically. Verify; add explicit block only if test discovery fails. +- Deps: T1+T2+T4 +- Verify: `./gradlew :chart-realtime:iosSimulatorArm64Test` + +### T5 — Android non-regression guard +- Goal: Confirm Android target/build/publish untouched. +- Agent: `android-build-resolver` +- Deps: T1+T2+T4 +- Verify: `./gradlew :chart-realtime:assembleRelease publishToMavenLocal --dry-run` + +### T6 — Smoke acceptance run +- Goal: Sequential run of all 5 acceptance commands. +- Agent: `general-purpose` +- Deps: T3, T5 +- Verify: all 5 commands green + +--- + +## Parallelism + +| Group | Tasks | +|-------|-------| +| P0 | T0 | +| P1 | T1+T2+T4 (one batched agent call) | +| P2 | T3, T5 (parallel) | +| P3 | T6 | +| P4 | Unify | + +--- + +## Risks + +- R1: gradle.properties Linux JDK path — fixed in T0 +- R2: `compose.material3` iOS native — supported in 1.8.0; fallback = isolate to non-iOS if compile fails +- R3: Hierarchy template silent inactivity — `applyDefaultHierarchyTemplate()` made explicit +- R4: `commonTest` not seen by `iosTest` — same hierarchy fix +- R5: XCFramework name collision — single `XCFramework("ChartRealtime")` registration +- R6: Compose link errors on iOS — defer to v0.4.0 if hit +- R7: Android regression — T5 final gate, revert if needed +- R8: Sim runtime requirement — confirmed installed (iPhone 17 family available) + +## New Deps +None. KGP + CMP 1.8.0 + Kotlin 2.1.0 cover everything. + +## Verification Commands + +```bash +./gradlew :chart-realtime:help # T0 +./gradlew :chart-realtime:compileKotlinIosX64 # A1 +./gradlew :chart-realtime:compileKotlinIosArm64 # A1 +./gradlew :chart-realtime:compileKotlinIosSimulatorArm64 # A1 +./gradlew :chart-realtime:linkDebugFrameworkIosSimulatorArm64 # A2 +./gradlew :chart-realtime:iosSimulatorArm64Test # A3 +./gradlew :chart-realtime:assembleXCFramework # A4 +ls chart-realtime/build/XCFrameworks/release/ChartRealtime.xcframework # A4 +./gradlew :chart-realtime:assembleRelease # A5 +./gradlew :chart-realtime:publishToMavenLocal --dry-run # T5 +``` diff --git a/chart-realtime/build.gradle.kts b/chart-realtime/build.gradle.kts index b3e8003..cf0a643 100644 --- a/chart-realtime/build.gradle.kts +++ b/chart-realtime/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework + plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.android.library) @@ -10,6 +12,8 @@ group = "dev.dtrentin" version = "0.5.0-SNAPSHOT" kotlin { + applyDefaultHierarchyTemplate() + androidTarget { compilations.all { compileTaskProvider.configure { @@ -21,9 +25,17 @@ kotlin { publishLibraryVariants("release") } - iosX64() - iosArm64() - iosSimulatorArm64() + val xcf = XCFramework("ChartRealtime") + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64() + ).forEach { target -> + target.binaries.framework { + baseName = "ChartRealtime" + xcf.add(this) + } + } sourceSets { commonMain.dependencies { diff --git a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/RealtimeChartState.kt b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/RealtimeChartState.kt index 94cfba4..3d019a9 100644 --- a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/RealtimeChartState.kt +++ b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/RealtimeChartState.kt @@ -5,6 +5,8 @@ import dev.dtrentin.chart.buffer.TieredBuffer import dev.dtrentin.chart.model.ChartConfig import dev.dtrentin.chart.model.SignalConfig import dev.dtrentin.chart.model.T0 +import kotlin.concurrent.Volatile +import kotlin.jvm.JvmName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow diff --git a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/buffer/CircularBuffer.kt b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/buffer/CircularBuffer.kt index 19d7bb7..14a5340 100644 --- a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/buffer/CircularBuffer.kt +++ b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/buffer/CircularBuffer.kt @@ -1,5 +1,7 @@ package dev.dtrentin.chart.buffer +import kotlin.concurrent.Volatile + /** * Lock-free circular buffer for (timestampMs, value) sensor samples. * Pre-allocated — zero allocations after construction. diff --git a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/render/AxisRenderer.kt b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/render/AxisRenderer.kt index a929ac2..4ab527f 100644 --- a/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/render/AxisRenderer.kt +++ b/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/render/AxisRenderer.kt @@ -164,15 +164,50 @@ internal object AxisRenderer { private fun formatAxisValue(value: Double, decimals: Int): String { val absVal = kotlin.math.abs(value) - return if (value == 0.0) { - "0" - } else if (absVal < 0.01 || absVal >= 1e5) { - "%.${decimals}e".format(value) - } else { - "%.${decimals}f".format(value) + return when { + value == 0.0 -> "0" + absVal < 0.01 || absVal >= 1e5 -> formatScientific(value, decimals) + else -> formatFixed(value, decimals) } } + private fun formatFixed(value: Double, decimals: Int): String { + val negative = value < 0.0 + val absVal = kotlin.math.abs(value) + val factor = pow10(decimals) + val rounded = kotlin.math.round(absVal * factor) + val whole = (rounded / factor).toLong() + val frac = (rounded - whole * factor).toLong() + val sign = if (negative && (whole != 0L || frac != 0L)) "-" else "" + return if (decimals <= 0) { + "$sign$whole" + } else { + val fracStr = frac.toString().padStart(decimals, '0') + "$sign$whole.$fracStr" + } + } + + private fun formatScientific(value: Double, decimals: Int): String { + if (value == 0.0) return formatFixed(0.0, decimals) + "e+00" + val negative = value < 0.0 + val absVal = kotlin.math.abs(value) + val exp = kotlin.math.floor(kotlin.math.log10(absVal)).toInt() + val mantissa = absVal / pow10(exp) + val mantStr = formatFixed(mantissa, decimals) + val sign = if (negative) "-" else "" + val expSign = if (exp >= 0) "+" else "-" + val expAbs = kotlin.math.abs(exp).toString().padStart(2, '0') + return "$sign${mantStr}e$expSign$expAbs" + } + + private fun pow10(n: Int): Double { + if (n == 0) return 1.0 + var r = 1.0 + val absN = kotlin.math.abs(n) + repeat(absN) { r *= 10.0 } + return if (n < 0) 1.0 / r else r + } + private fun niceInterval(range: Double, targetTickCount: Int): Double { if (range <= 0) return 1.0 val rough = range / targetTickCount diff --git a/chart-realtime/src/iosMain/kotlin/dev/dtrentin/chart/Platform.ios.kt b/chart-realtime/src/iosMain/kotlin/dev/dtrentin/chart/Platform.ios.kt index e6c89c0..cb2946a 100644 --- a/chart-realtime/src/iosMain/kotlin/dev/dtrentin/chart/Platform.ios.kt +++ b/chart-realtime/src/iosMain/kotlin/dev/dtrentin/chart/Platform.ios.kt @@ -2,4 +2,10 @@ package dev.dtrentin.chart import platform.Foundation.NSDate -internal actual fun currentTimeMs(): Long = (NSDate.date().timeIntervalSince1970 * 1000).toLong() +private const val REFERENCE_DATE_OFFSET_SEC = 978_307_200.0 + +internal actual fun currentTimeMs(): Long { + val date = NSDate() + val secsSince1970 = date.timeIntervalSinceReferenceDate + REFERENCE_DATE_OFFSET_SEC + return (secsSince1970 * 1000).toLong() +} diff --git a/gradle.properties b/gradle.properties index aa064a1..07f7284 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,3 @@ -org.gradle.java.home=/home/dtrentin/.local/share/JetBrains/Toolbox/apps/android-studio/jbr org.gradle.parallel=true org.gradle.caching=true org.gradle.jvmargs=-Xmx4g -XX:+UseParallelGC