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:
@@ -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
|
||||||
ReviewPrompt.registerSuccessfulExport()
|
if newValue == nil {
|
||||||
}) { item in
|
cleanupPreview()
|
||||||
ShareSheet(items: item.shareItems)
|
ReviewPrompt.registerSuccessfulExport()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user