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? let iconURL: URL?
var displayVoltage: Double? { var displayVoltage: Double? {
voltageIn ?? voltageOut [voltageIn, voltageOut].compactMap({ $0 }).first(where: { $0 > 0 })
} }
var current: Double? { var current: Double? {

View File

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

View File

@@ -24,7 +24,7 @@ data class ComponentLibraryItem(
val iconURL: String?, val iconURL: String?,
val affiliateLinks: List<AffiliateLink>, 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? val current: Double?
get() { get() {

View File

@@ -87,16 +87,20 @@ object SystemDiagram {
onError: () -> Unit, onError: () -> Unit,
) { ) {
val bitmap = fetchOrFallback(context, state, 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 file = withContext(Dispatchers.IO) {
val opaque = flattenOnWhite(bitmap) 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 dir = File(context.cacheDir, "exports").apply { mkdirs() }
val out = File(dir, "${name.replace(Regex("[^A-Za-z0-9-_]"), "_")}-Diagram.png") val out = File(dir, "${name.replace(Regex("[^A-Za-z0-9-_]"), "_")}-Diagram.png")
out.outputStream().use { opaque.compress(Bitmap.CompressFormat.PNG, 100, it) } out.outputStream().use { opaque.compress(Bitmap.CompressFormat.PNG, 100, it) }
out out
} }
withContext(Dispatchers.Main) { 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") 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.data.ReviewPrompt
import app.voltplan.cable.pdf.SystemDiagram import app.voltplan.cable.pdf.SystemDiagram
import app.voltplan.cable.pdf.SystemOverviewPdf import app.voltplan.cable.pdf.SystemOverviewPdf
import android.graphics.Bitmap
import android.widget.Toast import android.widget.Toast
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
@@ -106,6 +107,7 @@ fun SystemDetailScreen(
var showSystemEditor by remember { mutableStateOf(false) } var showSystemEditor by remember { mutableStateOf(false) }
var showOverviewMenu by remember { mutableStateOf(false) } var showOverviewMenu by remember { mutableStateOf(false) }
var exporting by remember { mutableStateOf(false) } var exporting by remember { mutableStateOf(false) }
var diagramBitmapPreview by remember { mutableStateOf<Bitmap?>(null) }
val system = state.system val system = state.system
// Switch to the matching tab before opening an editor, so returning from the // Switch to the matching tab before opening an editor, so returning from the
@@ -167,13 +169,9 @@ fun SystemDetailScreen(
showOverviewMenu = false showOverviewMenu = false
scope.launch { scope.launch {
exporting = true exporting = true
var failed = false val bitmap = SystemDiagram.fetchOrFallback(context, state, unitSystem)
SystemDiagram.exportAndShare(context, state, unitSystem) {
failed = true
Toast.makeText(context, R.string.overview_share_diagram_error, Toast.LENGTH_LONG).show()
}
exporting = false 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) { if (showSystemEditor && system != null) {
var location by remember { mutableStateOf(system.location) } var location by remember { mutableStateOf(system.location) }
AppearanceEditorSheet( AppearanceEditorSheet(