diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Presentation.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Presentation.swift index 8e3f402..feb5fde 100644 --- a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Presentation.swift +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Presentation.swift @@ -163,3 +163,89 @@ public extension View { _AlertView(title: String(title), isPresented: isPresented, message: message, buttons: buttons, content: self) } } + +// MARK: - Confirmation dialog + +/// Whether a chrome element is shown. Mirrors SwiftUI's `Visibility`; here it +/// only governs a confirmation dialog's title. +public enum Visibility: Sendable { + case automatic, visible, hidden +} + +/// An action sheet of choices presented from the bottom of the screen while +/// `isPresented` is true. Like `alert`, each button writes the bound flag back +/// to false, but any number of buttons are laid out vertically. +public struct _ConfirmationDialogView: View { + internal let title: String + internal let titleVisibility: Visibility + internal let isPresented: Binding + internal let message: String? + internal let buttons: [AlertButton] + internal let content: Content + public typealias Body = Never +} + +extension _ConfirmationDialogView: PrimitiveView { + + public func _render(in context: ResolveContext) -> RenderNode { + var node = Evaluator.resolve(content, context.descending("content")) + guard isPresented.wrappedValue else { return node } + + let binding = isPresented + var buttonNodes: [PropValue] = [] + for button in buttons { + let action = button.action + let id = context.callbacks.register(.void { + action() + binding.wrappedValue = false + }) + buttonNodes.append(.array([ + .string(button.title), + .string(role(button.role)), + .int(Int(id)), + ])) + } + let dismissID = context.callbacks.register(.void { binding.wrappedValue = false }) + + // The title only shows when explicitly made visible (matching iOS, where + // a confirmation dialog hides its title by default). + var props: [String: PropValue] = [ + "title": .string(title), + "showsTitle": .bool(titleVisibility == .visible), + "buttons": .array(buttonNodes), + "onDismiss": .int(Int(dismissID)), + ] + if let message { props["message"] = .string(message) } + + node.children.append(RenderNode(type: "ConfirmationDialog", id: context.path + "/confirmationDialog", props: props)) + node.props["hasConfirmationDialog"] = .bool(true) + return node + } + + private func role(_ role: AlertButton.Role) -> String { + switch role { + case .normal: return "normal" + case .cancel: return "cancel" + case .destructive: return "destructive" + } + } +} + +public extension View { + func confirmationDialog( + _ title: S, + isPresented: Binding, + titleVisibility: Visibility = .automatic, + message: String? = nil, + buttons: [AlertButton] + ) -> _ConfirmationDialogView { + _ConfirmationDialogView( + title: String(title), + titleVisibility: titleVisibility, + isPresented: isPresented, + message: message, + buttons: buttons, + content: self + ) + } +} diff --git a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/NavigationTests.swift b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/NavigationTests.swift index ec48a28..4d84483 100644 --- a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/NavigationTests.swift +++ b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/NavigationTests.swift @@ -116,6 +116,41 @@ struct NavigationTests { #expect(node.children.contains { $0.type == "Sheet" }) } + @Test("Confirmation dialog presents its buttons and a chosen action dismisses it") + func confirmationDialog() { + struct Screen: View { + @State var shown = false + @State var picked = "" + var body: some View { + Button("Show") { shown = true } + .confirmationDialog("Manage", isPresented: $shown, titleVisibility: .visible, buttons: [ + AlertButton("Duplicate") { picked = "dup" }, + AlertButton("Delete", role: .destructive) { picked = "del" }, + ]) + } + } + let host = ViewHost(Screen()) + var node = host.evaluate() + #expect(node.props["hasConfirmationDialog"] == nil) + host.callbacks.invokeVoid(findOnTap(node)!) + node = host.evaluate() + #expect(node.props["hasConfirmationDialog"] == .bool(true)) + let dialog = node.children.first { $0.type == "ConfirmationDialog" } + #expect(dialog?.props["showsTitle"] == .bool(true)) + guard case .array(let buttons)? = dialog?.props["buttons"], buttons.count == 2 else { + Issue.record("expected two buttons"); return + } + // second button (destructive) fires its action and dismisses the dialog + guard case .array(let second) = buttons[1], case .string(let role) = second[1], + case .int(let id) = second[2] else { + Issue.record("malformed button"); return + } + #expect(role == "destructive") + host.callbacks.invokeVoid(Int64(id)) + node = host.evaluate() + #expect(node.props["hasConfirmationDialog"] == nil) // dismissed + } + @Test("TabView emits tabs with their item labels and selection") func tabs() { struct Screen: View { diff --git a/Demo/App.swiftpm/Sources/PresentationPlaygrounds.swift b/Demo/App.swiftpm/Sources/PresentationPlaygrounds.swift index 5fe8b88..6c0d9bd 100644 --- a/Demo/App.swiftpm/Sources/PresentationPlaygrounds.swift +++ b/Demo/App.swiftpm/Sources/PresentationPlaygrounds.swift @@ -89,12 +89,14 @@ struct SheetBody: View { struct AlertPlayground: View { @State private var simple = false @State private var confirm = false + @State private var sheet = false @State private var result = "No choice yet" var body: some View { ScrollView { VStack(spacing: 16) { Button("Show simple alert") { simple = true } Button("Show confirmation") { confirm = true } + Button("Show confirmation dialog") { sheet = true } Text(result) } .padding() @@ -104,5 +106,13 @@ struct AlertPlayground: View { AlertButton("Cancel", role: .cancel) { result = "Cancelled" }, AlertButton("Delete", role: .destructive) { result = "Deleted" }, ]) + .confirmationDialog("Manage item", isPresented: $sheet, + titleVisibility: .visible, + message: "Choose an action", buttons: [ + AlertButton("Duplicate") { result = "Duplicated" }, + AlertButton("Share") { result = "Shared" }, + AlertButton("Delete", role: .destructive) { result = "Deleted from dialog" }, + AlertButton("Cancel", role: .cancel) { result = "Dialog cancelled" }, + ]) } } diff --git a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt index 265122e..8528dd3 100644 --- a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt +++ b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt @@ -409,7 +409,8 @@ private fun RenderResolved(node: ViewNode) { // Sheets and alerts ride as hidden children; the presentation layer shows // them, so the normal child loop skips them. -private fun ViewNode.isPresentation(): Boolean = type == "Sheet" || type == "Alert" +private fun ViewNode.isPresentation(): Boolean = + type == "Sheet" || type == "Alert" || type == "ConfirmationDialog" @Composable private fun RenderChildren(node: ViewNode) { @@ -1297,6 +1298,53 @@ private fun RenderSheetsAndAlerts(screen: ViewNode?) { } } "Alert" -> RenderAlert(child) + "ConfirmationDialog" -> RenderConfirmationDialog(child) + } + } +} + +// An action sheet: a title/message header and one vertical button per choice, +// destructive choices in the error color, presented from the bottom edge. +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RenderConfirmationDialog(node: ViewNode) { + val onDismiss = node.long("onDismiss") + val buttons = (node.props["buttons"] as? kotlinx.serialization.json.JsonArray) ?: kotlinx.serialization.json.JsonArray(emptyList()) + val parsed = buttons.mapNotNull { entry -> + val arr = entry as? kotlinx.serialization.json.JsonArray ?: return@mapNotNull null + val title = (arr[0] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: return@mapNotNull null + val role = (arr[1] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: "normal" + val id = (arr[2] as? kotlinx.serialization.json.JsonPrimitive)?.content?.toLongOrNull() ?: return@mapNotNull null + Triple(title, role, id) + } + ModalBottomSheet(onDismissRequest = { onDismiss?.let { SwiftBridge.sink.invokeVoid(it) } }) { + Column(modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp)) { + val header = node.string("message") + if (node.string("showsTitle") == "true") { + Text( + node.string("title") ?: "", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + ) + } + if (header != null) { + Text( + header, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + ) + } + for ((title, role, id) in parsed) { + TextButton( + onClick = { SwiftBridge.sink.invokeVoid(id) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + title, + color = if (role == "destructive") MaterialTheme.colorScheme.error else Color.Unspecified, + ) + } + } } } }