Compare commits

..

2 Commits

Author SHA1 Message Date
9257da046a Fix library batteries showing 0.0V
PocketBase stores battery nominal voltage in voltage_out while
voltage_in is 0.0 (not null). The nil-coalescing operator only
skips null, so 0.0 was returned instead of the real voltage.
Skip zero values when resolving displayVoltage on both platforms.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 15:26:52 +02:00
ff955b35fe Preview diagram and PDF before sharing (iOS + Android)
iOS: route exports through QLPreviewController so users can inspect
before sharing. Android: add a zoomable full-screen diagram preview
with a share button; PDF still goes straight to the share sheet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 15:21:07 +02:00
6 changed files with 144 additions and 28 deletions

View File

@@ -24,7 +24,7 @@ struct ComponentLibraryItem: Identifiable, Equatable {
let iconURL: URL?
var displayVoltage: Double? {
voltageIn ?? voltageOut
[voltageIn, voltageOut].compactMap({ $0 }).first(where: { $0 > 0 })
}
var current: Double? {

View File

@@ -29,7 +29,8 @@ struct LoadsView: View {
@State private var overviewExportRequested = false
@State private var diagramExportRequested = false
@State private var isExportingOverview = false
@State private var overviewShareItem: OverviewShareItem?
@State private var previewURL: URL?
@State private var previewTempURL: URL?
@State private var overviewExportError: OverviewExportError?
let system: ElectricalSystem
@@ -219,11 +220,12 @@ struct LoadsView: View {
}
)
}
.sheet(item: $overviewShareItem, onDismiss: {
cleanupOverviewShareItem()
ReviewPrompt.registerSuccessfulExport()
}) { item in
ShareSheet(items: item.shareItems)
.quickLookPreview($previewURL)
.onChange(of: previewURL) { _, newValue in
if newValue == nil {
cleanupPreview()
ReviewPrompt.registerSuccessfulExport()
}
}
.alert(
String(localized: "overview.share.error.title", defaultValue: "Export Failed"),
@@ -1106,12 +1108,6 @@ struct LoadsView: View {
// MARK: - PDF Export
private struct OverviewShareItem: Identifiable {
let id = UUID()
let shareItems: [Any]
let tempURL: URL?
}
private struct OverviewExportError: Identifiable {
let message: String
var id: String { message }
@@ -1134,7 +1130,8 @@ struct LoadsView: View {
"system": snapshot.systemName,
])
await MainActor.run {
overviewShareItem = OverviewShareItem(shareItems: [url], tempURL: url)
previewURL = url
previewTempURL = url
isExportingOverview = false
}
} catch {
@@ -1164,7 +1161,8 @@ struct LoadsView: View {
AnalyticsTracker.log("Diagram Image Shared", properties: [
"system": snapshot.systemName,
])
overviewShareItem = OverviewShareItem(shareItems: [url], tempURL: url)
previewURL = url
previewTempURL = url
} else {
overviewExportError = OverviewExportError(
message: String(localized: "overview.share.diagram.error", defaultValue: "Could not generate diagram. Check your internet connection.")
@@ -1284,11 +1282,10 @@ struct LoadsView: View {
)
}
private func cleanupOverviewShareItem() {
guard let item = overviewShareItem else { return }
overviewShareItem = nil
if let url = item.tempURL {
private func cleanupPreview() {
if let url = previewTempURL {
try? FileManager.default.removeItem(at: url)
previewTempURL = nil
}
}
}

View File

@@ -24,7 +24,7 @@ data class ComponentLibraryItem(
val iconURL: String?,
val affiliateLinks: List<AffiliateLink>,
) {
val displayVoltage: Double? get() = voltageIn ?: voltageOut
val displayVoltage: Double? get() = voltageIn?.takeIf { it > 0 } ?: voltageOut?.takeIf { it > 0 }
val current: Double?
get() {

View File

@@ -87,16 +87,20 @@ object SystemDiagram {
onError: () -> Unit,
) {
val bitmap = fetchOrFallback(context, state, unit)
share(context, bitmap, state.system?.name ?: "System")
}
suspend fun share(context: Context, bitmap: Bitmap, systemName: String) {
val file = withContext(Dispatchers.IO) {
val opaque = flattenOnWhite(bitmap)
val name = state.system?.name?.takeIf { it.isNotBlank() } ?: "System"
val name = systemName.takeIf { it.isNotBlank() } ?: "System"
val dir = File(context.cacheDir, "exports").apply { mkdirs() }
val out = File(dir, "${name.replace(Regex("[^A-Za-z0-9-_]"), "_")}-Diagram.png")
out.outputStream().use { opaque.compress(Bitmap.CompressFormat.PNG, 100, it) }
out
}
withContext(Dispatchers.Main) {
Analytics.log("Diagram Image Shared", mapOf("system" to (state.system?.name ?: "")))
Analytics.log("Diagram Image Shared", mapOf("system" to systemName))
PdfShare.shareFile(context, file, "image/png")
}
}

View File

@@ -0,0 +1,103 @@
package app.voltplan.cable.ui.system
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.gestures.rememberTransformableState
import androidx.compose.foundation.gestures.transformable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.IosShare
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import app.voltplan.cable.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DiagramPreviewDialog(
bitmap: Bitmap,
onShare: () -> Unit,
onDismiss: () -> Unit,
) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.overview_share_diagram)) },
navigationIcon = {
IconButton(onClick = onDismiss) {
Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = stringResource(R.string.action_back))
}
},
actions = {
IconButton(onClick = onShare) {
Icon(Icons.Outlined.IosShare, contentDescription = stringResource(R.string.overview_share_diagram))
}
},
)
},
) { padding ->
var scale by remember { mutableFloatStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
val transformState = rememberTransformableState { zoomChange, panChange, _ ->
scale = (scale * zoomChange).coerceIn(1f, 8f)
offset = if (scale == 1f) Offset.Zero else offset + panChange
}
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.transformable(transformState),
contentAlignment = Alignment.Center,
) {
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = stringResource(R.string.overview_share_diagram),
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = offset.x,
translationY = offset.y,
),
)
}
}
}
}
}

View File

@@ -54,6 +54,7 @@ import app.voltplan.cable.ui.theme.componentColor
import app.voltplan.cable.data.ReviewPrompt
import app.voltplan.cable.pdf.SystemDiagram
import app.voltplan.cable.pdf.SystemOverviewPdf
import android.graphics.Bitmap
import android.widget.Toast
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
@@ -106,6 +107,7 @@ fun SystemDetailScreen(
var showSystemEditor by remember { mutableStateOf(false) }
var showOverviewMenu by remember { mutableStateOf(false) }
var exporting by remember { mutableStateOf(false) }
var diagramBitmapPreview by remember { mutableStateOf<Bitmap?>(null) }
val system = state.system
// Switch to the matching tab before opening an editor, so returning from the
@@ -167,13 +169,9 @@ fun SystemDetailScreen(
showOverviewMenu = false
scope.launch {
exporting = true
var failed = false
SystemDiagram.exportAndShare(context, state, unitSystem) {
failed = true
Toast.makeText(context, R.string.overview_share_diagram_error, Toast.LENGTH_LONG).show()
}
val bitmap = SystemDiagram.fetchOrFallback(context, state, unitSystem)
exporting = false
if (!failed) ReviewPrompt.registerSuccessfulExport(context)
diagramBitmapPreview = bitmap
}
},
)
@@ -256,6 +254,20 @@ fun SystemDetailScreen(
}
}
diagramBitmapPreview?.let { bmp ->
DiagramPreviewDialog(
bitmap = bmp,
onShare = {
scope.launch {
SystemDiagram.share(context, bmp, state.system?.name ?: "System")
diagramBitmapPreview = null
ReviewPrompt.registerSuccessfulExport(context)
}
},
onDismiss = { diagramBitmapPreview = null },
)
}
if (showSystemEditor && system != null) {
var location by remember { mutableStateOf(system.location) }
AppearanceEditorSheet(