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>
This commit is contained in:
2026-06-29 15:21:07 +02:00
parent 022e309873
commit ff955b35fe
4 changed files with 142 additions and 26 deletions

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()
.quickLookPreview($previewURL)
.onChange(of: previewURL) { _, newValue in
if newValue == nil {
cleanupPreview()
ReviewPrompt.registerSuccessfulExport()
}) { item in
ShareSheet(items: item.shareItems)
}
}
.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

@@ -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(