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

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