Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,24 @@ name: Build
on: [push, pull_request]

jobs:
test:
name: Run JVM Tests
timeout-minutes: 40
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4.0.0
- name: Run JVM Tests
run: ./gradlew domain:jvmTest composeApp:jvmTest

android:
name: Build Android & Run JVM Tests
name: Build Android
timeout-minutes: 40
runs-on: ubuntu-latest
steps:
Expand All @@ -18,8 +34,6 @@ jobs:
uses: gradle/actions/setup-gradle@v4.0.0
- name: Build Android App
run: ./gradlew androidApp:assembleDebug
- name: Run JVM Tests
run: ./gradlew domain:jvmTest
ios:
name: Build iOS
timeout-minutes: 40
Expand Down
10 changes: 7 additions & 3 deletions composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ kotlin {
}

// Do not really support desktop, but this is required to get previews working.
jvm("desktop") {
jvm {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
freeCompilerArgs.add("-Xexpect-actual-classes")
Expand Down Expand Up @@ -75,8 +75,8 @@ kotlin {
implementation(libs.androidx.ui.tooling)
}

val desktopMain by getting
desktopMain.dependencies {
val jvmMain by getting
jvmMain.dependencies {
implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutines.swing)

Expand All @@ -88,6 +88,10 @@ kotlin {
kotlin.srcDir(project.layout.buildDirectory.file("generated/kotlin/version/"))
}

commonTest.dependencies {
implementation(libs.kotlin.test)
}

commonMain.dependencies {
implementation(project(":domain"))
implementation(project(":data"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,15 +102,15 @@ fun DiveConfigurationScreen(

}
}
) {
) { paddingValues ->
Box(
Modifier
.verticalScroll(rememberScrollState())
) {

val configuration by planningRepository.configuration.collectAsState()

Column(modifier = Modifier.padding(it)) {
Column(modifier = Modifier.padding(paddingValues)) {
SettingsSubTitle(subTitle = "Algorithm")

SingleChoicePreference(
Expand Down Expand Up @@ -287,6 +287,20 @@ fun DiveConfigurationScreen(
}
}

NumberPreference(
label = "Gas switch time",
description = "Adds a flat section at each depth where a gas switch occurs during decompression, to account for the time needed to switch gases.",
initialValue = configuration.gasSwitchTime,
minValue = 0,
maxValue = 5,
valueFormatter = { "$it min"},
textFieldVisualTransformation = SuffixVisualTransformation(" min")
) { gasSwitchTime ->
planningRepository.updateConfiguration {
it.copy(gasSwitchTime = gasSwitchTime)
}
}

val allowedPPO2values = persistentListOf(1.2, 1.3, 1.4, 1.5, 1.6)

fun Iterable<Double>.indexByNearest(target: Double): Int? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
Expand Down Expand Up @@ -321,21 +320,28 @@ fun DecoPlanTable(
}
) {

var currentGas: Gas? = null

val segments = divePlan.segmentsCollapsed
.toMutableList()
.compactSimilarSegments(compactAscentsBetweenDecoStops = settings.showBasicDecoTable)

segments.forEach { diveSegment ->
segments.forEachIndexed { index, diveSegment ->

// For gas switch segments, resolve the target gas the diver is switching to, so the
// table reads as an instruction ("switch to Nx50") rather than showing the old gas that
// is used for the tissue loading calculation.
val displayGas = if (diveSegment.isGasSwitch) {
// Theoretically speaking this should not occur, a gas switch is never the last
// segment in a dive
segments.getOrNull(index + 1)?.cylinder?.gas ?: diveSegment.cylinder.gas
} else {
diveSegment.cylinder.gas
}
row {
DecoPlanRow(
diveSegment = diveSegment,
previousGas = currentGas,
runtime = diveSegment.end
runtime = diveSegment.end,
gas = displayGas,
)
currentGas = diveSegment.cylinder.gas
}
}
}
Expand Down Expand Up @@ -363,18 +369,13 @@ fun LoadingBoxWithBlur(
@Composable
private fun RowScope.DecoPlanRow(
diveSegment: DiveSegment,
previousGas: Gas?,
runtime: Int
runtime: Int,
gas: Gas,
) {
val typeIcon = when (diveSegment.type) {
DiveSegment.Type.FLAT -> {
if (diveSegment.isDecompression) {
Res.drawable.ic_outline_stop_circle_24
} else {
Res.drawable.ic_outline_trending_flat_24
}
}

DiveSegment.Type.DECO_STOP -> Res.drawable.ic_outline_stop_circle_24
DiveSegment.Type.GAS_SWITCH -> Res.drawable.ic_outline_change_circle_24
DiveSegment.Type.FLAT -> Res.drawable.ic_outline_trending_flat_24
DiveSegment.Type.DECENT -> Res.drawable.ic_outline_trending_down_24
DiveSegment.Type.ASCENT -> Res.drawable.ic_outline_trending_up_24
}
Expand All @@ -392,15 +393,10 @@ private fun RowScope.DecoPlanRow(
modifier = Modifier.weight(0.2f),
text = "+${diveSegment.duration}",
)
val gasIcon: Painter = if (previousGas != null && previousGas != diveSegment.cylinder.gas) {
painterResource(resource = Res.drawable.ic_outline_change_circle_24)
} else {
ColorPainter(Color.Transparent)
}
TextWithStartIcon(

Text(
modifier = Modifier.weight(0.2f),
text = diveSegment.cylinder.gas.toString(),
icon = gasIcon
text = gas.toString(),
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,14 +193,9 @@ fun DecoPlanGraph(
) {

AreaPlot2(
data = //listOf(DefaultPoint(0f, 0f)) +
divePlan.segments.map { segment ->
// End should be used here, but by shifting the whole graph 1 minute to the left,
// it will never visually touch the dive line, which makes for a slightly nicer
// UX (although be it a slightly less accurate graph (as long as the
// underlying data is accurate this should not matter)
DefaultPoint(segment.start.toFloat(), -segment.gfCeilingAtEnd.toFloat())
} + DefaultPoint(divePlan.runtime.toFloat(), -divePlan.segments.last().gfCeilingAtEnd.toFloat()),
// Offset the gradient graph ever so slightly to the top so it does not interfere
// visually too much with the depth line, 0.25f is essentially equal to 25cm.
data = buildGfCeilingPlotPoints(divePlan.segments).offset(y = 0.25f).coerceIn(yMax = 0f),
lineStyle = LineStyle(
brush = SolidColor(MaterialTheme.colorScheme.error),
strokeWidth = 1.dp
Expand All @@ -213,40 +208,17 @@ fun DecoPlanGraph(
animationSpec = if(LocalInspectionMode.current) { none() } else { KoalaPlotTheme.animationSpec }
)

var runtime = 0L
LinePlot2(
data = divePlan.segments.map { segment ->
DefaultPoint(runtime.toFloat(), -segment.startDepth.toFloat()).also {
runtime += segment.duration
}
} + DefaultPoint(runtime.toFloat(), -divePlan.segments.last().endDepth.toFloat()),
data = buildDepthProfilePlotPoints(divePlan.segments),
lineStyle = LineStyle(
brush = SolidColor(MaterialTheme.colorScheme.primary),
strokeWidth = 2.dp
),
animationSpec = if(LocalInspectionMode.current) { none() } else { KoalaPlotTheme.animationSpec }
)

var runningAverage = 0.0
var runningDuration = 0L

val points = divePlan.segments.flatMapIndexed { index: Int, segment: DiveSegment ->
val range = if (index < divePlan.segments.size - 1) {
0..<segment.duration
} else {
0..segment.duration
}
range.map {
val depthAtMinute = segment.depthAt(it)
runningAverage += depthAtMinute
runningDuration += 1
val runningAverageDepth = runningAverage / runningDuration
DefaultPoint(runningDuration.toFloat() - 1, -runningAverageDepth.toFloat())
}
}

LinePlot2(
data = points,
data = buildAverageDepthPlotPoints(divePlan.segments),
lineStyle = LineStyle(
brush = SolidColor(MaterialTheme.colorScheme.outline),
pathEffect = PathEffect.dashPathEffect(intervals = floatArrayOf(15.0f, 15.0f)),
Expand All @@ -267,10 +239,10 @@ private fun DecoPlanGraphPreview() {
DecoPlanGraph(
modifier = Modifier, divePlan = DivePlan(
segments = persistentListOf(
DiveSegment(0,5, 0.0, 25.0, cylinder, isDecompression = false, gfCeilingAtEnd = 0.0),
DiveSegment(0,5, 0.0, 25.0, cylinder, type = DiveSegment.Type.FLAT, gfCeilingAtEnd = 0.0),

DiveSegment(5,20, 25.0, 20.0, cylinder, isDecompression = false, gfCeilingAtEnd = 0.0),
DiveSegment(25,20, 25.0, 0.0, cylinder, isDecompression = false, gfCeilingAtEnd = 0.0),
DiveSegment(5,20, 25.0, 20.0, cylinder, type = DiveSegment.Type.FLAT, gfCeilingAtEnd = 0.0),
DiveSegment(25,20, 25.0, 0.0, cylinder, type = DiveSegment.Type.FLAT, gfCeilingAtEnd = 0.0),

),
alternativeAccents = persistentMapOf(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
* Abysner - Dive planner
* Copyright (C) 2026 Neotech
*
* Abysner is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License version 3,
* as published by the Free Software Foundation.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/

package org.neotech.app.abysner.presentation.screens.planner.decoplan

import io.github.koalaplot.core.xygraph.DefaultPoint
import org.neotech.app.abysner.domain.decompression.model.DiveSegment

/**
* Returns a copy of each point shifted by [x] and [y].
*/
fun List<DefaultPoint<Float, Float>>.offset(x: Float = 0f, y: Float = 0f): List<DefaultPoint<Float, Float>> =
map { DefaultPoint(it.x + x, it.y + y) }

/**
* Returns a copy of each point with x and y coerced to the given bounds. Only axes you name are
* coerced.
*/
fun List<DefaultPoint<Float, Float>>.coerceIn(
xMin: Float = Float.NEGATIVE_INFINITY,
xMax: Float = Float.POSITIVE_INFINITY,
yMin: Float = Float.NEGATIVE_INFINITY,
yMax: Float = Float.POSITIVE_INFINITY,
): List<DefaultPoint<Float, Float>> =
map { DefaultPoint(it.x.coerceIn(xMin, xMax), it.y.coerceIn(yMin, yMax)) }

/**
* Returns the (time, depth) plot points for the GF ceiling area, where x is time in minutes
* and y is the negative ceiling depth (0 = surface, more negative = deeper).
*/
fun buildGfCeilingPlotPoints(segments: List<DiveSegment>): List<DefaultPoint<Float, Float>> {
// Skip zero-duration segments (GAS_SWITCH etc.) to avoid unnecessary duplicate x-coordinates.
val nonZeroSegments = segments.filter { it.duration > 0 }

// Drop all leading zero-ceiling segments except the one directly before the first
// non-zero ceiling (keeps a single clean zero-height starting point for the filled area).
val subListStart = (nonZeroSegments.indexOfFirst { it.gfCeilingAtEnd > 0.0 } - 1).coerceAtLeast(0)

return buildList {
// Rare edge case: if ceiling is already > 0 from the very first segment (diver starts with
// residual loading). DiveSegment.end of the first segment is higher than 0, so we need an
// explicit (0, 0) anchor to close the left edge of the filled graph area.
if ((nonZeroSegments.firstOrNull()?.gfCeilingAtEnd ?: 0.0) > 0.0) {
add(DefaultPoint(0f, 0f))
}

// Use segment.end as x (gfCeilingAtEnd belongs at the end of the segment).
nonZeroSegments.subList(subListStart, nonZeroSegments.size).mapTo(this) { segment ->
DefaultPoint(segment.end.toFloat(), -segment.gfCeilingAtEnd.toFloat())
}
}
}

/**
* Returns the (time, depth) plot points for the depth profile line, where x is elapsed time
* in minutes and y is the negative depth (0 = surface).
*/
fun buildDepthProfilePlotPoints(segments: List<DiveSegment>): List<DefaultPoint<Float, Float>> {
var runtime = 0L
return buildList {
segments.mapTo(this) { segment ->
DefaultPoint(runtime.toFloat(), -segment.startDepth.toFloat()).also {
runtime += segment.duration
}
}
// Add a final point at the last segment's end depth so the line reaches the surface.
add(DefaultPoint(runtime.toFloat(), -segments.last().endDepth.toFloat()))
}
}

/**
* Returns the (time, depth) plot points for the running-average depth line, where x is elapsed
* time in minutes and y is the negative running-average depth.
*/
fun buildAverageDepthPlotPoints(segments: List<DiveSegment>): List<DefaultPoint<Float, Float>> {
var runningTotal = 0.0
var runningDuration = 0L
return segments.map { segment ->
runningTotal += segment.averageDepth * segment.duration
runningDuration += segment.duration
DefaultPoint(segment.end.toFloat(), -(runningTotal / runningDuration).toFloat())
}
}
Loading
Loading