From c8849eab1fad701bed6c876ba4c316ca7adf7fa8 Mon Sep 17 00:00:00 2001 From: Rolf Smit Date: Thu, 22 Aug 2024 01:42:26 +0200 Subject: [PATCH] Add: Image share option for dive plans Right now this serves as an initial starting point, a sort of MVP implementation. It would be good to offer more information in shared images in the future; like graphs, additional general information (max depth, average depth) and more. Also the way in which compose is used to render images is experimental at best. Implements: #1 --- composeApp/build.gradle.kts | 2 + .../src/androidMain/AndroidManifest.xml | 10 + .../presentation/theme/Theme.android.kt | 2 + .../utilities/ShareExtensions.android.kt | 48 +++ .../res/xml/file_provider_paths.xml | 15 + .../drawable/ic_outline_share_24_android.xml | 5 + .../drawable/ic_outline_share_24_ios.xml | 5 + .../abysner/presentation/MainNavController.kt | 4 +- .../presentation/preview/PreviewData.kt | 44 +++ .../presentation/screens/ShareImage.kt | 206 ++++++++++++ .../screens/planner/PlanScreen.kt | 41 ++- .../screens/planner/PlanScreenViewModel.kt | 11 +- .../screens/planner/decoplan/DecoPlanCard.kt | 158 +++++----- .../screens/planner/gasplan/GasPlanCard.kt | 294 ++++++++++-------- .../planner/plan/PlanPickerBottomSheet.kt | 12 +- .../app/abysner/presentation/theme/Icons.kt | 31 ++ .../app/abysner/presentation/theme/Theme.kt | 12 +- .../utilities/ComposeImageExtensions.kt | 169 ++++++++++ .../presentation/utilities/ShareExtensions.kt | 17 + .../theme/{Theme.jvm.kt => Theme.desktop.kt} | 6 +- .../utilities/ShareExtensions.desktop.kt | 19 ++ .../abysner/presentation/theme/Theme.ios.kt | 4 +- .../utilities/ShareExtensions.ios.kt | 148 +++++++++ .../decompression/DecompressionPlanner.kt | 2 - .../app/abysner/domain/utilities/Polyfills.kt | 3 + gradle/libs.versions.toml | 8 +- 26 files changed, 1040 insertions(+), 236 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/org/neotech/app/abysner/presentation/utilities/ShareExtensions.android.kt create mode 100644 composeApp/src/androidMain/res/xml/file_provider_paths.xml create mode 100644 composeApp/src/commonMain/composeResources/drawable/ic_outline_share_24_android.xml create mode 100644 composeApp/src/commonMain/composeResources/drawable/ic_outline_share_24_ios.xml create mode 100644 composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/preview/PreviewData.kt create mode 100644 composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/ShareImage.kt create mode 100644 composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/theme/Icons.kt create mode 100644 composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/utilities/ComposeImageExtensions.kt create mode 100644 composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/utilities/ShareExtensions.kt rename composeApp/src/desktopMain/kotlin/org/neotech/app/abysner/presentation/theme/{Theme.jvm.kt => Theme.desktop.kt} (82%) create mode 100644 composeApp/src/desktopMain/kotlin/org/neotech/app/abysner/presentation/utilities/ShareExtensions.desktop.kt create mode 100644 composeApp/src/iosMain/kotlin/org/neotech/app/abysner/presentation/utilities/ShareExtensions.ios.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index dc2ac71..1853b04 100755 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -100,6 +100,8 @@ kotlin { implementation(libs.kotlinInject.runtimeKmp) implementation(libs.navigation.compose) implementation(libs.jetbrains.lifecycle.viewmodel) + implementation(libs.jetbrains.lifecycle.runtime) + implementation(libs.jetbrains.kotlinx.datetime) implementation(compose.runtime) implementation(compose.foundation) diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index b12c2b2..a73967f 100755 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -28,6 +28,16 @@ + + + + diff --git a/composeApp/src/androidMain/kotlin/org/neotech/app/abysner/presentation/theme/Theme.android.kt b/composeApp/src/androidMain/kotlin/org/neotech/app/abysner/presentation/theme/Theme.android.kt index e9f82a9..7162990 100644 --- a/composeApp/src/androidMain/kotlin/org/neotech/app/abysner/presentation/theme/Theme.android.kt +++ b/composeApp/src/androidMain/kotlin/org/neotech/app/abysner/presentation/theme/Theme.android.kt @@ -47,3 +47,5 @@ actual fun applyPlatformSpecificThemeConfiguration(colorScheme: ColorScheme, isD // } // } } + +actual fun platform(): Platform = Platform.ANDROID diff --git a/composeApp/src/androidMain/kotlin/org/neotech/app/abysner/presentation/utilities/ShareExtensions.android.kt b/composeApp/src/androidMain/kotlin/org/neotech/app/abysner/presentation/utilities/ShareExtensions.android.kt new file mode 100644 index 0000000..e13b98e --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/neotech/app/abysner/presentation/utilities/ShareExtensions.android.kt @@ -0,0 +1,48 @@ +/* + * Abysner - Dive planner + * Copyright (C) 2024 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.utilities + +import android.content.Intent +import android.graphics.Bitmap +import android.net.Uri +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.core.content.FileProvider +import org.neotech.app.abysner.applicationContext +import org.neotech.app.abysner.currentActivity +import java.io.File +import java.io.FileOutputStream + +actual fun shareImageBitmap(image: ImageBitmap) { + val bitmap = image.asAndroidBitmap() + + val file = File(applicationContext.cacheDir, "share/shared-image.png") + file.parentFile?.mkdirs() + FileOutputStream(file).use { out -> + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) + } + + val uri: Uri = FileProvider.getUriForFile( + applicationContext, + "${applicationContext.packageName}.provider", + file + ) + + val intent = Intent(Intent.ACTION_SEND).apply { + type = "image/png" + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + currentActivity.get()!!.startActivity(Intent.createChooser(intent, "Share Image")) +} diff --git a/composeApp/src/androidMain/res/xml/file_provider_paths.xml b/composeApp/src/androidMain/res/xml/file_provider_paths.xml new file mode 100644 index 0000000..5905974 --- /dev/null +++ b/composeApp/src/androidMain/res/xml/file_provider_paths.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/ic_outline_share_24_android.xml b/composeApp/src/commonMain/composeResources/drawable/ic_outline_share_24_android.xml new file mode 100644 index 0000000..d7509f8 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/ic_outline_share_24_android.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/ic_outline_share_24_ios.xml b/composeApp/src/commonMain/composeResources/drawable/ic_outline_share_24_ios.xml new file mode 100644 index 0000000..366c2ec --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/ic_outline_share_24_ios.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/MainNavController.kt b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/MainNavController.kt index 2238ef2..e891e19 100644 --- a/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/MainNavController.kt +++ b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/MainNavController.kt @@ -31,6 +31,7 @@ import org.neotech.app.abysner.presentation.utilities.DestinationDefinition import org.neotech.app.abysner.presentation.utilities.NavHost import org.neotech.app.abysner.presentation.utilities.composable import org.neotech.app.abysner.presentation.theme.AbysnerTheme +import org.neotech.app.abysner.presentation.utilities.BitmapRenderRoot enum class Destinations(override val destinationName: String) : DestinationDefinition { PLANNER("planner"), @@ -66,7 +67,8 @@ fun MainNavController( } val navController = rememberNavController() - AbysnerTheme { + + BitmapRenderRoot { NavHost(navController = navController, startDestination = startDestination) { composable( diff --git a/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/preview/PreviewData.kt b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/preview/PreviewData.kt new file mode 100644 index 0000000..e902232 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/preview/PreviewData.kt @@ -0,0 +1,44 @@ +/* + * Abysner - Dive planner + * Copyright (C) 2024 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.preview + +import org.neotech.app.abysner.domain.core.model.Configuration +import org.neotech.app.abysner.domain.core.model.Cylinder +import org.neotech.app.abysner.domain.core.model.Gas +import org.neotech.app.abysner.domain.diveplanning.DivePlanner +import org.neotech.app.abysner.domain.diveplanning.model.DivePlanSet +import org.neotech.app.abysner.domain.diveplanning.model.DiveProfileSection +import org.neotech.app.abysner.domain.gasplanning.GasPlanner + +object PreviewData { + val divePlan: DivePlanSet + get() { + val divePlan = DivePlanner().apply { + configuration = Configuration() + }.getDecoPlan( + plan = listOf( + DiveProfileSection(16, 45, Cylinder(gas = Gas.Air, pressure = 232.0, waterVolume = 12.0)), + ), + decoGases = listOf(Cylinder.aluminium80Cuft(Gas.Oxygen50)), + ) + + val gasPlan = GasPlanner().calculateGasPlan( + divePlan + ) + return DivePlanSet( + base = divePlan, + deeperAndLonger = divePlan, + gasPlan = gasPlan + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/ShareImage.kt b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/ShareImage.kt new file mode 100644 index 0000000..bde4f65 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/ShareImage.kt @@ -0,0 +1,206 @@ +/* + * Abysner - Dive planner + * Copyright (C) 2024 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 + +import abysner.composeapp.generated.resources.Res +import abysner.composeapp.generated.resources.abysner_logo +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Card +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import kotlinx.datetime.Clock +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.format +import kotlinx.datetime.toLocalDateTime +import org.jetbrains.compose.resources.vectorResource +import org.neotech.app.abysner.domain.diveplanning.model.DivePlanSet +import org.neotech.app.abysner.domain.settings.model.SettingsModel +import org.neotech.app.abysner.domain.utilities.format +import org.neotech.app.abysner.presentation.component.BigNumberDisplay +import org.neotech.app.abysner.presentation.component.BigNumberSize +import org.neotech.app.abysner.presentation.component.appendBold +import org.neotech.app.abysner.presentation.preview.PreviewData +import org.neotech.app.abysner.presentation.screens.planner.decoplan.DecoPlanTable +import org.neotech.app.abysner.presentation.screens.planner.gasplan.CylindersTable +import org.neotech.app.abysner.presentation.screens.planner.gasplan.GasLimitsTable +import org.neotech.app.abysner.presentation.theme.AbysnerTheme +import org.neotech.app.abysner.presentation.theme.platform +import org.neotech.app.abysner.version.VersionInfo + +@Composable +fun ShareImage( + divePlan: DivePlanSet, + settingsModel: SettingsModel, +) { + // Disable scaling and dark theme and dynamic color, the image should be the same for every device. + CompositionLocalProvider(LocalDensity provides Density(LocalDensity.current.density, 1f)) { + AbysnerTheme(darkTheme = false, dynamicColor = false) { + Card { + + val backgroundImage = rememberVectorPainter(vectorResource(Res.drawable.abysner_logo)) + + Column( + modifier = Modifier + .drawBehind { + val vectorSize = backgroundImage.intrinsicSize.times(2f) + translate( + left = (size.width - vectorSize.width) / 2f, + top = (size.height - vectorSize.height) / 2f + ) { + with(backgroundImage) { + draw(size = backgroundImage.intrinsicSize.times(2f), alpha = 0.05f) + } + } + } + .padding(vertical = 16.dp, horizontal = 16.dp) + .fillMaxWidth() + ) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleLarge, + text = "Dive plan" + ) + DecoPlanTable( + divePlan = divePlan.base, + settings = settingsModel + ) + + Row( + modifier = Modifier.padding(top = 16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy( + 16.dp, + Alignment.CenterHorizontally + ), + ) { + BigNumberDisplay( + modifier = Modifier.width(96.dp), + size = BigNumberSize.SMALL, + value = "${divePlan.base.totalCns.format(0)}%", + label = "CNS" + ) + BigNumberDisplay( + modifier = Modifier.width(96.dp), + size = BigNumberSize.SMALL, + value = divePlan.base.totalOtu.format(0), + label = "OTU" + ) + } + + // DecoPlanExtraInfo( + // modifier = Modifier.padding(horizontal = 16.dp).padding(top = 16.dp), + // divePlan = divePlan.base + // ) + + Text( + modifier = Modifier.padding(top = 16.dp, bottom = 4.dp), + text = "Cylinders", + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold) + ) + CylindersTable(divePlanSet = divePlan) + + Text( + modifier = Modifier.padding(top = 16.dp, bottom = 4.dp), + text = "Limits", + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold) + ) + GasLimitsTable(divePlanSet = divePlan) + + Text( + modifier = Modifier.padding(top = 16.dp, bottom = 4.dp), + text = "Configuration", + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold) + ) + + val configuration = divePlan.configuration + + Text( + text = buildAnnotatedString { + appendBold("Deco model: ") + append(configuration.algorithm.shortName) + append(" (${configuration.gf})") + }, + style = MaterialTheme.typography.bodySmall + ) + Text( + text = buildAnnotatedString { + appendBold("Salinity: ") + append(configuration.environment.salinity.humanReadableName) + append(" (${configuration.environment.salinity.density.format(0)} kg/m3)") + }, + style = MaterialTheme.typography.bodySmall + ) + Text( + text = buildAnnotatedString { + appendBold("Atmospheric pressure: ") + append(configuration.environment.atmosphericPressure.format(3)) + append(" hPa") + append(" (${configuration.altitude.format(0)} meters)") + }, + style = MaterialTheme.typography.bodySmall + ) + + val date = + Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date + Text( + style = MaterialTheme.typography.bodySmall.copy( + fontStyle = FontStyle.Italic, + color = LocalTextStyle.current.color.copy(alpha = 0.8f) + ), + modifier = Modifier.padding(top = 16.dp) + .align(Alignment.CenterHorizontally), + textAlign = TextAlign.Center, + text = "Created with Abysner for ${platform().humanReadable} ${VersionInfo.VERSION_NAME} (${VersionInfo.COMMIT_HASH})\non ${date.format(LocalDate.Formats.ISO)}" + ) + } + } + } + } +} + + +@Preview +@Composable +private fun ShareImagePreview() { + ShareImage( + divePlan = PreviewData.divePlan, + settingsModel = SettingsModel( + showBasicDecoTable = true, + termsAndConditionsAccepted = true + ) + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/planner/PlanScreen.kt b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/planner/PlanScreen.kt index 6c3bc26..6f645b4 100644 --- a/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/planner/PlanScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/planner/PlanScreen.kt @@ -16,6 +16,7 @@ import abysner.composeapp.generated.resources.Res import abysner.composeapp.generated.resources.ic_outline_settings_24 import abysner.composeapp.generated.resources.ic_outline_tune_24 import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsetsSides @@ -44,13 +45,17 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch import me.tatarka.inject.annotations.Assisted import me.tatarka.inject.annotations.Inject import org.jetbrains.compose.resources.painterResource @@ -61,13 +66,17 @@ import org.neotech.app.abysner.domain.diveplanning.PlanningRepository import org.neotech.app.abysner.domain.settings.SettingsRepository import org.neotech.app.abysner.domain.settings.model.SettingsModel import org.neotech.app.abysner.presentation.Destinations +import org.neotech.app.abysner.presentation.screens.ShareImage +import org.neotech.app.abysner.presentation.screens.planner.cylinders.CylinderPickerBottomSheet import org.neotech.app.abysner.presentation.screens.planner.cylinders.CylinderSelectionCardComponent import org.neotech.app.abysner.presentation.screens.planner.decoplan.DecoPlanCardComponent -import org.neotech.app.abysner.presentation.screens.planner.cylinders.CylinderPickerBottomSheet import org.neotech.app.abysner.presentation.screens.planner.gasplan.GasPlanCardComponent import org.neotech.app.abysner.presentation.screens.planner.plan.PlanPickerBottomSheet import org.neotech.app.abysner.presentation.screens.planner.plan.PlanSelectionCardComponent import org.neotech.app.abysner.presentation.theme.AbysnerTheme +import org.neotech.app.abysner.presentation.theme.IconSet +import org.neotech.app.abysner.presentation.utilities.LocalBitmapRenderController +import org.neotech.app.abysner.presentation.utilities.shareImageBitmap typealias PlannerScreen = @Composable (navController: NavHostController) -> Unit @@ -86,6 +95,7 @@ fun PlannerScreen( } val viewState: PlanScreenViewModel.ViewState by viewModel.uiState.collectAsState() + val coroutineScope = rememberCoroutineScope() AbysnerTheme { @@ -114,6 +124,34 @@ fun PlannerScreen( } }, actions = { + + val bitmapRenderController = LocalBitmapRenderController.current + + val plan = viewState.divePlanSet.getOrNull() + if(plan != null && plan.isEmpty.not()) { + IconButton(onClick = { + coroutineScope.launch { + bitmapRenderController.renderBitmap( + width = 1080, + height = null, + onRendered = { + shareImageBitmap(it) + } + ) { + ShareImage( + divePlan = plan, + settingsModel = settings, + ) + } + } + }) { + Icon( + imageVector = IconSet.share, + contentDescription = "Share" + ) + } + } + var showMenu by remember { mutableStateOf(false) } DropdownMenu( @@ -176,7 +214,6 @@ fun PlannerScreen( }, onCylinderChecked = { gas, isChecked -> viewModel.toggleCylinder(gas, isChecked) - // TODO check if toggle is possible }, onEditCylinder = { gas -> cylinderBeingEdited = gas diff --git a/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/planner/PlanScreenViewModel.kt b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/planner/PlanScreenViewModel.kt index 6a6534f..86049b1 100644 --- a/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/planner/PlanScreenViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/planner/PlanScreenViewModel.kt @@ -12,6 +12,7 @@ package org.neotech.app.abysner.presentation.screens.planner +import androidx.compose.material3.Text import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers @@ -24,14 +25,14 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import me.tatarka.inject.annotations.Inject -import org.neotech.app.abysner.domain.core.model.Cylinder -import org.neotech.app.abysner.domain.diveplanning.model.DiveProfileSection -import org.neotech.app.abysner.domain.diveplanning.PlanningRepository import org.neotech.app.abysner.domain.core.model.Configuration -import org.neotech.app.abysner.domain.diveplanning.DivePlanner -import org.neotech.app.abysner.domain.gasplanning.GasPlanner +import org.neotech.app.abysner.domain.core.model.Cylinder import org.neotech.app.abysner.domain.core.model.Gas +import org.neotech.app.abysner.domain.diveplanning.DivePlanner +import org.neotech.app.abysner.domain.diveplanning.PlanningRepository import org.neotech.app.abysner.domain.diveplanning.model.DivePlanSet +import org.neotech.app.abysner.domain.diveplanning.model.DiveProfileSection +import org.neotech.app.abysner.domain.gasplanning.GasPlanner import kotlin.time.measureTimedValue @Inject diff --git a/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/planner/decoplan/DecoPlanCard.kt b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/planner/decoplan/DecoPlanCard.kt index d2dbbd3..4e78dc0 100644 --- a/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/planner/decoplan/DecoPlanCard.kt +++ b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/planner/decoplan/DecoPlanCard.kt @@ -65,6 +65,7 @@ import org.neotech.app.abysner.domain.diveplanning.model.DivePlanSet import org.neotech.app.abysner.domain.diveplanning.model.DiveProfileSection import org.neotech.app.abysner.domain.settings.model.SettingsModel import org.neotech.app.abysner.domain.utilities.DecimalFormat +import org.neotech.app.abysner.domain.utilities.DecimalFormatter import org.neotech.app.abysner.presentation.component.SingleChoiceSegmentedButtonRow import org.neotech.app.abysner.presentation.component.Table import org.neotech.app.abysner.presentation.component.TextWithStartIcon @@ -148,81 +149,21 @@ fun DecoPlanCardComponent( divePlan = planToShow ) - DecoPlanTable(divePlan = planToShow, settings = settings) + DecoPlanTable( + modifier = Modifier.padding(vertical = 16.dp, horizontal = 16.dp), + divePlan = planToShow, + settings = settings + ) Row( verticalAlignment = Alignment.Bottom ) { - Column(modifier = Modifier.weight(1f).padding(horizontal = 16.dp)) { - - Text( - text = buildAnnotatedString { - appendBold("Average depth: ") - append( - "${ - DecimalFormat.format( - 2, - planToShow.averageDepth - ) - } meters" - ) - }, - style = MaterialTheme.typography.bodySmall - ) - Text( - text = buildAnnotatedString { - appendBold("Total deco time: ") - append("${planToShow.totalDeco} minutes") - }, - style = MaterialTheme.typography.bodySmall - ) - if(planToShow.firstDeco != -1) { - Text( - text = buildAnnotatedString { - appendBold("First deco after: ") - append("${planToShow.firstDeco} minutes") - }, - style = MaterialTheme.typography.bodySmall - ) - } - Text( - text = buildAnnotatedString { - appendBold("Deepest ceiling: ") - append( - "${ - DecimalFormat.format( - 2, - planToShow.deepestCeiling - ) - } meter" - ) - }, - style = MaterialTheme.typography.bodySmall - ) - if(planToShow.maxTimeToSurface != null) { - Text( - text = buildAnnotatedString { - appendBold("Max TTS: ") - append("${planToShow.maxTimeToSurface!!.ttsAfter} @ ${planToShow.maxTimeToSurface!!.end} minutes") - }, - style = MaterialTheme.typography.bodySmall - ) - } - Text( - text = buildAnnotatedString { - appendBold("CNS: ") - append("${DecimalFormat.format(0, ceil(planToShow.totalCns))}%") - }, - style = MaterialTheme.typography.bodySmall - ) - Text( - text = buildAnnotatedString { - appendBold("OTU: ") - append(DecimalFormat.format(0, ceil(planToShow.totalOtu))) - }, - style = MaterialTheme.typography.bodySmall - ) - } + + DecoPlanExtraInfo( + modifier = Modifier.weight(1f).padding(horizontal = 16.dp), + divePlan = planToShow + ) + var showConfigurationInfo by remember { mutableStateOf(false) } IconButton( @@ -247,12 +188,83 @@ fun DecoPlanCardComponent( } } + +@Composable +fun DecoPlanExtraInfo( + modifier: Modifier = Modifier, + divePlan: DivePlan +) { + Column(modifier = modifier) { + + Text( + text = buildAnnotatedString { + appendBold("Average depth: ") + append( + "${DecimalFormat.format(2, divePlan.averageDepth)} meters" + ) + }, + style = MaterialTheme.typography.bodySmall + ) + Text( + text = buildAnnotatedString { + appendBold("Total deco time: ") + append("${divePlan.totalDeco} minutes") + }, + style = MaterialTheme.typography.bodySmall + ) + if(divePlan.firstDeco != -1) { + Text( + text = buildAnnotatedString { + appendBold("First deco after: ") + append("${divePlan.firstDeco} minutes") + }, + style = MaterialTheme.typography.bodySmall + ) + } + Text( + text = buildAnnotatedString { + appendBold("Deepest ceiling: ") + append( + "${DecimalFormat.format(2, divePlan.deepestCeiling)} meter" + ) + }, + style = MaterialTheme.typography.bodySmall + ) + if(divePlan.maxTimeToSurface != null) { + Text( + text = buildAnnotatedString { + appendBold("Max TTS: ") + append("${divePlan.maxTimeToSurface!!.ttsAfter} @ ${divePlan.maxTimeToSurface!!.end} minutes") + }, + style = MaterialTheme.typography.bodySmall + ) + } + Text( + text = buildAnnotatedString { + appendBold("CNS: ") + append("${DecimalFormat.format(0, ceil(divePlan.totalCns))}%") + }, + style = MaterialTheme.typography.bodySmall + ) + Text( + text = buildAnnotatedString { + appendBold("OTU: ") + append(DecimalFormat.format(0, ceil(divePlan.totalOtu))) + }, + style = MaterialTheme.typography.bodySmall + ) + } +} + + + @Composable -private fun ColumnScope.DecoPlanTable( +fun DecoPlanTable( + modifier: Modifier = Modifier, divePlan: DivePlan, settings: SettingsModel, ) { - Table(modifier = Modifier.padding(vertical = 16.dp, horizontal = 16.dp), + Table(modifier = modifier, header = { TextWithStartIcon( modifier = Modifier.weight(0.2f), diff --git a/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/planner/gasplan/GasPlanCard.kt b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/planner/gasplan/GasPlanCard.kt index 659d354..cc066b5 100644 --- a/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/planner/gasplan/GasPlanCard.kt +++ b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/planner/gasplan/GasPlanCard.kt @@ -115,75 +115,10 @@ fun GasPlanCardComponent( style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold) ) - Table( + CylindersTable( modifier = Modifier.padding(horizontal = 16.dp), - header = { - Text( - modifier = Modifier.weight(0.1f), - text = "No.", - ) - Text( - modifier = Modifier.weight(0.2f), - text = "Mix", - ) - Text( - modifier = Modifier.weight(0.2f), - text = "Size (ℓ)", - ) - Text( - modifier = Modifier.weight(0.3f), - text = "Usage (bar)" - ) - } - ) { - - gasRequirements.total.forEachIndexed { index, (gas, volume) -> - row { - Text( - modifier = Modifier.weight(0.1f), - text = (index + 1).toString(), - ) - Text( - modifier = Modifier.weight(0.2f), - text = gas.gas.toString(), - ) - val size = DecimalFormat.format(1, gas.waterVolume) - Text( - modifier = Modifier.weight(0.2f), - text = size, - ) - - // TODO extract these values to a CylinderUsageModel? That is calculated as part of the gas plan? - val endPressureBase = - gas.pressureAfter(volumeUsage = gasRequirements.sortedBase[index].second) - val endPressure = gas.pressureAfter(volumeUsage = volume) - - val startPressure = DecimalFormat.format(0, gas.pressure) - - val alertSeverity: AlertSeverity - val pressureText = - if (endPressureBase == null || endPressure == null) { - alertSeverity = AlertSeverity.ERROR - "$startPressure > empty" - } else { - alertSeverity = AlertSeverity.NONE - "$startPressure > ${ - DecimalFormat.format( - 0, - endPressureBase - ) - } (${DecimalFormat.format(0, endPressure)})" - } - - TextAlert( - modifier = Modifier.weight(0.3f), - alertSeverity = alertSeverity, - text = pressureText - ) - } - } - } - + divePlanSet = divePlanSet, + ) Text( modifier = Modifier.padding(top = 16.dp, bottom = 4.dp) @@ -192,77 +127,12 @@ fun GasPlanCardComponent( style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold) ) - Table( + GasLimitsTable( modifier = Modifier.padding(horizontal = 16.dp) .padding(bottom = 16.dp), - header = { - Text( - modifier = Modifier.weight(0.2f), - text = "Mix", - ) - Text( - modifier = Modifier.weight(0.3f), - text = "Depth (m)", - ) - Text( - modifier = Modifier.weight(0.3f), - text = "Density (g/ℓ)", - ) - Text( - modifier = Modifier.weight(0.2f), - text = "PPO2" - ) - } - ) { - + divePlanSet + ) - (divePlanSet.deeperAndLonger.maximumGasDensities + divePlanSet.base.maximumGasDensities) - .distinct().sortedBy { it.gas.oxygenFraction }.forEach { - - row { - Text( - modifier = Modifier.weight(0.2f), - text = it.gas.toString(), - ) - - Text( - modifier = Modifier.weight(0.3f), - text = "${it.depth.toInt()}m", - ) - - val density = DecimalFormat.format(2, it.density) - - val alertSeverityDensity = - if (it.density.higherThenDelta(Gas.MAX_GAS_DENSITY, 0.01)) { - AlertSeverity.ERROR - } else if (it.density.higherThenDelta(Gas.MAX_RECOMMENDED_GAS_DENSITY, 0.01)) { - AlertSeverity.WARNING - } else { - AlertSeverity.NONE - } - - TextAlert( - alertSeverity = alertSeverityDensity, - modifier = Modifier.weight(0.3f), - text = density, - ) - - val ppo2 = DecimalFormat.format(2, it.ppo2) - val alertSeverityPPO2 = - if (it.ppo2.higherThenDelta(Gas.MAX_PPO2, 0.01)) { - AlertSeverity.ERROR - } else { - AlertSeverity.NONE - } - - TextAlert( - alertSeverity = alertSeverityPPO2, - modifier = Modifier.weight(0.2f), - text = ppo2, - ) - } - } - } ExpandableText( modifier = Modifier.padding(horizontal = 16.dp), text = "Note: All gas information is calculated based on the contingency (deeper & longer) plan. 'Baseline' represents the gas requirement for a single diver to normally complete the contingency plan. 'Lost gas extra' represents the extra gas that is needed for a safe ascent (including potential deco) should a team buddy lose one or more gas mixes at the worst-possible time during the dive (calculated using the out-of-air SAC rate). This lost-gas calculation assumes buddy breathing is possible, however with deco gasses this may not always be the case and you may have to take turns using the deco gas. No extra (bottom) gas is accounted for those situations! Also keep in mind that you need to plan your tanks carefully taking into account 'minimum functional pressure' of your regulators.", @@ -274,6 +144,158 @@ fun GasPlanCardComponent( } } +@Composable +fun CylindersTable( + modifier: Modifier = Modifier, + divePlanSet: DivePlanSet, +) { + Table( + modifier = modifier, + header = { + Text( + modifier = Modifier.weight(0.1f), + text = "No.", + ) + Text( + modifier = Modifier.weight(0.2f), + text = "Mix", + ) + Text( + modifier = Modifier.weight(0.2f), + text = "Size (ℓ)", + ) + Text( + modifier = Modifier.weight(0.3f), + text = "Usage (bar)" + ) + } + ) { + + val gasRequirements = divePlanSet.gasPlan + gasRequirements.total.forEachIndexed { index, (gas, volume) -> + row { + Text( + modifier = Modifier.weight(0.1f), + text = (index + 1).toString(), + ) + Text( + modifier = Modifier.weight(0.2f), + text = gas.gas.toString(), + ) + val size = DecimalFormat.format(1, gas.waterVolume) + Text( + modifier = Modifier.weight(0.2f), + text = size, + ) + + // TODO extract these values to a CylinderUsageModel? That is calculated as part of the gas plan? + val endPressureBase = + gas.pressureAfter(volumeUsage = gasRequirements.sortedBase[index].second) + val endPressure = gas.pressureAfter(volumeUsage = volume) + + val startPressure = DecimalFormat.format(0, gas.pressure) + + val alertSeverity: AlertSeverity + val pressureText = + if (endPressureBase == null || endPressure == null) { + alertSeverity = AlertSeverity.ERROR + "$startPressure > empty" + } else { + alertSeverity = AlertSeverity.NONE + "$startPressure > ${ + DecimalFormat.format( + 0, + endPressureBase + ) + } (${DecimalFormat.format(0, endPressure)})" + } + + TextAlert( + modifier = Modifier.weight(0.3f), + alertSeverity = alertSeverity, + text = pressureText + ) + } + } + } +} + +@Composable +fun GasLimitsTable( + modifier: Modifier = Modifier, + divePlanSet: DivePlanSet, +) { + Table( + modifier = modifier, + header = { + Text( + modifier = Modifier.weight(0.2f), + text = "Mix", + ) + Text( + modifier = Modifier.weight(0.3f), + text = "Depth (m)", + ) + Text( + modifier = Modifier.weight(0.3f), + text = "Density (g/ℓ)", + ) + Text( + modifier = Modifier.weight(0.2f), + text = "PPO2" + ) + } + ) { + + (divePlanSet.deeperAndLonger.maximumGasDensities + divePlanSet.base.maximumGasDensities) + .distinct().sortedBy { it.gas.oxygenFraction }.forEach { + + row { + Text( + modifier = Modifier.weight(0.2f), + text = it.gas.toString(), + ) + + Text( + modifier = Modifier.weight(0.3f), + text = "${it.depth.toInt()}m", + ) + + val density = DecimalFormat.format(2, it.density) + + val alertSeverityDensity = + if (it.density.higherThenDelta(Gas.MAX_GAS_DENSITY, 0.01)) { + AlertSeverity.ERROR + } else if (it.density.higherThenDelta(Gas.MAX_RECOMMENDED_GAS_DENSITY, 0.01)) { + AlertSeverity.WARNING + } else { + AlertSeverity.NONE + } + + TextAlert( + alertSeverity = alertSeverityDensity, + modifier = Modifier.weight(0.3f), + text = density, + ) + + val ppo2 = DecimalFormat.format(2, it.ppo2) + val alertSeverityPPO2 = + if (it.ppo2.higherThenDelta(Gas.MAX_PPO2, 0.01)) { + AlertSeverity.ERROR + } else { + AlertSeverity.NONE + } + + TextAlert( + alertSeverity = alertSeverityPPO2, + modifier = Modifier.weight(0.2f), + text = ppo2, + ) + } + } + } +} + @Preview @Composable private fun GasPlanCardComponentPreview() { diff --git a/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/planner/plan/PlanPickerBottomSheet.kt b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/planner/plan/PlanPickerBottomSheet.kt index b6880f5..3d33da5 100644 --- a/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/planner/plan/PlanPickerBottomSheet.kt +++ b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/planner/plan/PlanPickerBottomSheet.kt @@ -139,14 +139,6 @@ fun PlanPickerBottomSheet( showTopRow = false, ) - val suffixGas = if(cylinder != null) { - val liters = DecimalFormat.format(1, cylinder!!.waterVolume) - val pressure = DecimalFormat.format(0, cylinder!!.pressure) - " (${liters}l @ ${pressure}bar)" - } else { - "" - } - val bigBodyStyle = MaterialTheme.typography.bodyLarge.copy( fontSize = 24.sp ) @@ -189,7 +181,7 @@ fun PlanPickerBottomSheet( minValue = 1, maxValue = 150, visualTransformation = SuffixVisualTransformation(" m"), - initialValue = initialValue?.depth, + initialValue = initialValue?.depth ?: 10, errorMessage = errorMessageDepth, isValid = isDepthValid, onNumberChanged = { @@ -206,7 +198,7 @@ fun PlanPickerBottomSheet( minValue = 1, maxValue = 999, visualTransformation = SuffixVisualTransformation(" min"), - initialValue = initialValue?.duration, + initialValue = initialValue?.duration ?: 15, errorMessage = errorMessageTime, isValid = isTimeValid, onNumberChanged = { diff --git a/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/theme/Icons.kt b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/theme/Icons.kt new file mode 100644 index 0000000..4eafaa6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/theme/Icons.kt @@ -0,0 +1,31 @@ +/* + * Abysner - Dive planner + * Copyright (C) 2024 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.theme + +import abysner.composeapp.generated.resources.Res +import abysner.composeapp.generated.resources.ic_outline_share_24_android +import abysner.composeapp.generated.resources.ic_outline_share_24_ios +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector +import org.jetbrains.compose.resources.vectorResource + +object IconSet { + + val share: ImageVector + @Composable + get() = if(platform() == Platform.IOS) { + vectorResource(Res.drawable.ic_outline_share_24_ios) + } else { + vectorResource(Res.drawable.ic_outline_share_24_android) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/theme/Theme.kt b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/theme/Theme.kt index 2cabbde..3085fe6 100644 --- a/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/theme/Theme.kt +++ b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/theme/Theme.kt @@ -102,7 +102,7 @@ internal val DarkColorScheme = darkColorScheme( fun AbysnerTheme( darkTheme: Boolean = isSystemInDarkTheme(), // Dynamic color is available on Android 12+ - dynamicColor: Boolean = true, + dynamicColor: Boolean = false, content: @Composable () -> Unit ) { val colorScheme = getColorScheme(dynamicColor, darkTheme) @@ -143,3 +143,13 @@ expect fun getColorScheme(dynamicColor: Boolean, isDarkMode: Boolean): ColorSche @Composable expect fun applyPlatformSpecificThemeConfiguration(colorScheme: ColorScheme, isDarkMode: Boolean) + +enum class Platform( + val humanReadable: String +) { + ANDROID("Android"), + DESKTOP("Desktop"), + IOS("iOS"); +} + +expect fun platform(): Platform diff --git a/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/utilities/ComposeImageExtensions.kt b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/utilities/ComposeImageExtensions.kt new file mode 100644 index 0000000..98b502e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/utilities/ComposeImageExtensions.kt @@ -0,0 +1,169 @@ +/* + * Abysner - Dive planner + * Copyright (C) 2024 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.utilities + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.rememberGraphicsLayer +import androidx.compose.ui.layout.layout +import androidx.compose.ui.unit.Constraints +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.withContext + +internal data class RenderTask( + val composable: @Composable () -> Unit, + val width: Int?, + val height: Int?, + val onComplete: (ImageBitmap) -> Unit +) + +class BitmapRenderController { + + private val _channel = Channel() + internal val events = _channel.receiveAsFlow() + + suspend fun renderBitmap( + width: Int?, + height: Int?, + onRendered: (ImageBitmap) -> Unit, + composable: @Composable () -> Unit, + ) { + _channel.send( + RenderTask( + width = width, + height = height, + onComplete = onRendered, + composable = composable + ) + ) + } +} + +val LocalBitmapRenderController = staticCompositionLocalOf { + BitmapRenderController() +} + +@Composable +fun BitmapRenderRoot(content: @Composable () -> Unit) { + val lifecycleOwner = LocalLifecycleOwner.current + val render = remember { BitmapRenderController() } + + val actionState: MutableState = remember { mutableStateOf(null) } + + LaunchedEffect(lifecycleOwner) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + withContext(Dispatchers.Main) { + render.events.collect { + actionState.value = it + } + } + } + } + + val action = actionState.value + if (action != null) { + RenderBitmap( + width = action.width, + height = action.height, + onRendered = { + actionState.value = null + action.onComplete(it) + } + ) { + action.composable.invoke() + } + } + + CompositionLocalProvider(LocalBitmapRenderController provides render) { + content() + } +} + +@Composable +private fun RenderBitmap( + width: Int?, + height: Int?, + onRendered: (ImageBitmap) -> Unit, + composable: @Composable () -> Unit, +) { + val graphicsLayer = rememberGraphicsLayer() + + Box( + modifier = Modifier + .layout { measurable, constraints -> + + val newConstraints = if (width != null && height != null) { + constraints.copy( + minWidth = 0, + maxWidth = width, + minHeight = 0, + maxHeight = height + ) + } else if (width != null) { + constraints.copy( + minWidth = 0, + maxWidth = width, + minHeight = 0, + maxHeight = Constraints.Infinity + ) + } else if (height != null) { + constraints.copy( + minHeight = 0, + maxHeight = height, + minWidth = 0, + maxWidth = Constraints.Infinity + ) + } else { + constraints + } + + val placeable = measurable.measure(newConstraints) + + val layoutWidth = width ?: placeable.width + val layoutHeight = height ?: placeable.height + + layout(layoutWidth, layoutHeight) { + placeable.place(0, 0) + } + } + .background(Color.Transparent) + .drawWithContent { + graphicsLayer.record { + this@drawWithContent.drawContent() + } + } + ) { + composable() + } + + LaunchedEffect(true) { + val bitmap = graphicsLayer.toImageBitmap() + onRendered(bitmap) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/utilities/ShareExtensions.kt b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/utilities/ShareExtensions.kt new file mode 100644 index 0000000..488438f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/utilities/ShareExtensions.kt @@ -0,0 +1,17 @@ +/* + * Abysner - Dive planner + * Copyright (C) 2024 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.utilities + +import androidx.compose.ui.graphics.ImageBitmap + +expect fun shareImageBitmap(image: ImageBitmap) diff --git a/composeApp/src/desktopMain/kotlin/org/neotech/app/abysner/presentation/theme/Theme.jvm.kt b/composeApp/src/desktopMain/kotlin/org/neotech/app/abysner/presentation/theme/Theme.desktop.kt similarity index 82% rename from composeApp/src/desktopMain/kotlin/org/neotech/app/abysner/presentation/theme/Theme.jvm.kt rename to composeApp/src/desktopMain/kotlin/org/neotech/app/abysner/presentation/theme/Theme.desktop.kt index 6cc6843..11e61cf 100644 --- a/composeApp/src/desktopMain/kotlin/org/neotech/app/abysner/presentation/theme/Theme.jvm.kt +++ b/composeApp/src/desktopMain/kotlin/org/neotech/app/abysner/presentation/theme/Theme.desktop.kt @@ -14,8 +14,6 @@ package org.neotech.app.abysner.presentation.theme import androidx.compose.material3.ColorScheme import androidx.compose.runtime.Composable -import org.neotech.app.abysner.presentation.theme.DarkColorScheme -import org.neotech.app.abysner.presentation.theme.LightColorScheme @Composable actual fun getColorScheme(dynamicColor: Boolean, isDarkMode: Boolean): ColorScheme = when(isDarkMode) { @@ -24,4 +22,6 @@ actual fun getColorScheme(dynamicColor: Boolean, isDarkMode: Boolean): ColorSche } @Composable -actual fun applyPlatformSpecificThemeConfiguration(colorScheme: ColorScheme, isDarkMode: Boolean) = Unit \ No newline at end of file +actual fun applyPlatformSpecificThemeConfiguration(colorScheme: ColorScheme, isDarkMode: Boolean) = Unit + +actual fun platform(): Platform = Platform.DESKTOP diff --git a/composeApp/src/desktopMain/kotlin/org/neotech/app/abysner/presentation/utilities/ShareExtensions.desktop.kt b/composeApp/src/desktopMain/kotlin/org/neotech/app/abysner/presentation/utilities/ShareExtensions.desktop.kt new file mode 100644 index 0000000..ddc7dec --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/org/neotech/app/abysner/presentation/utilities/ShareExtensions.desktop.kt @@ -0,0 +1,19 @@ +/* + * Abysner - Dive planner + * Copyright (C) 2024 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.utilities + +import androidx.compose.ui.graphics.ImageBitmap + +actual fun shareImageBitmap(image: ImageBitmap) { + TODO("Not yet implemented") +} diff --git a/composeApp/src/iosMain/kotlin/org/neotech/app/abysner/presentation/theme/Theme.ios.kt b/composeApp/src/iosMain/kotlin/org/neotech/app/abysner/presentation/theme/Theme.ios.kt index 78d9858..d6c70a1 100644 --- a/composeApp/src/iosMain/kotlin/org/neotech/app/abysner/presentation/theme/Theme.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/neotech/app/abysner/presentation/theme/Theme.ios.kt @@ -22,4 +22,6 @@ actual fun getColorScheme(dynamicColor: Boolean, isDarkMode: Boolean): ColorSche } @Composable -actual fun applyPlatformSpecificThemeConfiguration(colorScheme: ColorScheme, isDarkMode: Boolean) = Unit \ No newline at end of file +actual fun applyPlatformSpecificThemeConfiguration(colorScheme: ColorScheme, isDarkMode: Boolean) = Unit + +actual fun platform(): Platform = Platform.IOS diff --git a/composeApp/src/iosMain/kotlin/org/neotech/app/abysner/presentation/utilities/ShareExtensions.ios.kt b/composeApp/src/iosMain/kotlin/org/neotech/app/abysner/presentation/utilities/ShareExtensions.ios.kt new file mode 100644 index 0000000..a09adb7 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/neotech/app/abysner/presentation/utilities/ShareExtensions.ios.kt @@ -0,0 +1,148 @@ +/* + * Abysner - Dive planner + * Copyright (C) 2024 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.utilities + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asSkiaBitmap +import kotlinx.cinterop.ByteVar +import kotlinx.cinterop.CValuesRef +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.refTo +import org.jetbrains.skia.ColorAlphaType +import org.jetbrains.skia.ColorSpace +import org.jetbrains.skia.ColorType +import org.jetbrains.skia.ImageInfo +import platform.CoreGraphics.CGBitmapContextCreate +import platform.CoreGraphics.CGBitmapContextCreateImage +import platform.CoreGraphics.CGColorRenderingIntent +import platform.CoreGraphics.CGColorSpaceCreateDeviceRGB +import platform.CoreGraphics.CGColorSpaceRelease +import platform.CoreGraphics.CGContextDrawImage +import platform.CoreGraphics.CGContextRelease +import platform.CoreGraphics.CGDataProviderCreateWithData +import platform.CoreGraphics.CGDataProviderRelease +import platform.CoreGraphics.CGImageAlphaInfo +import platform.CoreGraphics.CGImageCreate +import platform.CoreGraphics.CGImageRelease +import platform.CoreGraphics.CGRectMake +import platform.CoreGraphics.kCGBitmapByteOrderDefault +import platform.Foundation.NSCachesDirectory +import platform.Foundation.NSData +import platform.Foundation.NSSearchPathForDirectoriesInDomains +import platform.Foundation.NSURL +import platform.Foundation.NSUserDomainMask +import platform.Foundation.writeToFile +import platform.UIKit.UIActivityViewController +import platform.UIKit.UIApplication +import platform.UIKit.UIImage +import platform.UIKit.UIImageOrientation +import platform.UIKit.UIImagePNGRepresentation +import platform.UIKit.UIScreen +import platform.posix.free +import platform.posix.malloc + +/** + * Taken from: https://github.com/adessoTurkey/compose-multiplatform-sampleapp/blob/2dbbd48654ee482258c37f0e51e52eb6af15ec3c/shared/src/iosMain/kotlin/com/example/moveeapp_compose_kmm/utils/Image.ios.kt#L42 + * Which is licensed under Apache 2.0 + */ +@OptIn(ExperimentalForeignApi::class) +fun ImageBitmap.toUiImage(): UIImage { + val pixels = asSkiaBitmap().readPixels( + ImageInfo(width, height, ColorType.RGBA_8888, ColorAlphaType.PREMUL, ColorSpace.sRGB) + )!! + + return createWithCGImage(pixels.refTo(0), width.toULong(), height.toULong()) +} + +/** + * Taken from: https://github.com/adessoTurkey/compose-multiplatform-sampleapp/blob/2dbbd48654ee482258c37f0e51e52eb6af15ec3c/shared/src/iosMain/kotlin/com/example/moveeapp_compose_kmm/utils/Image.ios.kt#L42 + * Which is licensed under Apache 2.0 + */ +@OptIn(ExperimentalForeignApi::class) +private fun createWithCGImage(buffer: CValuesRef, width: ULong, height: ULong): UIImage { + val bufferLength = width * height * 4u + val bitsPerComponent = 8uL + val bitsPerPixel = 32uL + val bytesPerRow = 4u * width + + val colorSpaceRef = CGColorSpaceCreateDeviceRGB() + val bitmapInfo = + kCGBitmapByteOrderDefault or CGImageAlphaInfo.kCGImageAlphaPremultipliedLast.value + val provider = CGDataProviderCreateWithData(null, buffer, bufferLength, null) + + val iref = CGImageCreate( + width, + height, + bitsPerComponent, + bitsPerPixel, + bytesPerRow, + colorSpaceRef, + bitmapInfo, + provider, + null, + true, + CGColorRenderingIntent.kCGRenderingIntentDefault + ) + + val pixels = malloc(bufferLength) + + val context = CGBitmapContextCreate( + pixels, + width, + height, + bitsPerComponent, + bytesPerRow, + colorSpaceRef, + bitmapInfo + )!! + + CGContextDrawImage(context, CGRectMake(0.0, 0.0, width.toDouble(), height.toDouble()), iref) + + val imageRef = CGBitmapContextCreateImage(context) + + val scale = UIScreen.mainScreen.scale + val image = UIImage.imageWithCGImage(imageRef, scale, UIImageOrientation.UIImageOrientationUp) + + CGImageRelease(imageRef) + CGContextRelease(context) + CGColorSpaceRelease(colorSpaceRef) + CGImageRelease(iref) + CGDataProviderRelease(provider) + free(pixels) + return image +} + +actual fun shareImageBitmap(image: ImageBitmap) { + val uiImage: UIImage = image.toUiImage() + + val pngData: NSData? = UIImagePNGRepresentation(uiImage) + + val cachesDirectory = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, true).first() as String + val filePath = "$cachesDirectory/temp.png" + + if(pngData?.writeToFile(filePath, true) == true) { + + val fileURL = NSURL.fileURLWithPath(filePath) + + val activityViewController = UIActivityViewController(listOf(fileURL), null) + + val application = UIApplication.sharedApplication + application.keyWindow?.rootViewController?.presentViewController( + activityViewController, + true, + null + ) + } else { + println("Error: Could not share image file because file could not be written.") + } +} diff --git a/domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/decompression/DecompressionPlanner.kt b/domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/decompression/DecompressionPlanner.kt index e4dce8a..262e65f 100644 --- a/domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/decompression/DecompressionPlanner.kt +++ b/domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/decompression/DecompressionPlanner.kt @@ -77,8 +77,6 @@ class DecompressionPlanner( } fun addDepthChangePerMinute(startDepth: Double, endDepth: Double, gas: Cylinder, timeInMinutes: Int, isDecompression: Boolean) { - println("from $startDepth, to $endDepth") - if(calculateTissueChangesPerMinute && !isCalculatingTts) { val diff = startDepth - endDepth repeat(timeInMinutes) { diff --git a/domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/utilities/Polyfills.kt b/domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/utilities/Polyfills.kt index b467691..ca3669a 100644 --- a/domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/utilities/Polyfills.kt +++ b/domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/utilities/Polyfills.kt @@ -12,6 +12,9 @@ package org.neotech.app.abysner.domain.utilities + +fun Number.format(fractionDigits: Int) = DecimalFormat.format(fractionDigits, this) + expect object DecimalFormat { fun format(fractionDigits: Int, number: Number): String } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2309f4b..056ec50 100755 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -36,8 +36,12 @@ kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines- navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigationCompose" } # ViewModel +jetbrains-lifecycle-runtime = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version = "2.8.0" } jetbrains-lifecycle-viewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version = "2.8.0" } +# Date & Time +jetbrains-kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version = "0.6.1"} + ####### START: Android target specific ####### androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } androidx-startup-runtime = { group = "androidx.startup", name = "startup-runtime", version.ref = "startupRuntime" } @@ -45,12 +49,12 @@ androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", vers # This exists mainly to replace the default dependency added by Jetbrains compose 1.6.11 and replace it with a beta version to # fix: https://issuetracker.google.com/issues/274872542 -androidx-compose-material3 = { module = "androidx.compose.material3:material3", version = "1.3.0-beta05" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3", version = "1.3.0-rc01" } ####### END: Android target specific ####### # This is here to make sure the material version on Android and other platforms is equal, and to fix an iOS bug: # Fix: https://github.com/JetBrains/compose-multiplatform-core/commit/d9c3ce5f2900c1ad27f72b5089082790aa8d9fa4 -jetbrains-compose-material3 = { module = "org.jetbrains.compose.material3:material3", version = "1.7.0-alpha02" } +jetbrains-compose-material3 = { module = "org.jetbrains.compose.material3:material3", version = "1.7.0-alpha03" } # Graphs & Plots koalaplot-core = { module = "io.github.koalaplot:koalaplot-core", version = "0.6.0" }