diff --git a/nativephp.json b/nativephp.json
index 72ff62c..7530299 100644
--- a/nativephp.json
+++ b/nativephp.json
@@ -237,6 +237,14 @@
"ios_renderer": "NativeUIButtonRenderer",
"self_closing": true
},
+ {
+ "type": "sign_in_with_apple_button",
+ "element": "Native\\Mobile\\UI\\Elements\\SignInWithAppleButton",
+ "blade": "Native\\Mobile\\UI\\Components\\SignInWithAppleButton",
+ "android_renderer": "com.nativephp.plugins.native_ui.ui.SignInWithAppleButtonRenderer",
+ "ios_renderer": "NativeUISignInWithAppleButtonRenderer",
+ "self_closing": false
+ },
{
"type": "progress_bar",
"element": "Native\\Mobile\\UI\\Elements\\ProgressBar",
diff --git a/resources/android/ListItemRenderer.kt b/resources/android/ListItemRenderer.kt
index 0cf7841..1fa6b8c 100644
--- a/resources/android/ListItemRenderer.kt
+++ b/resources/android/ListItemRenderer.kt
@@ -400,7 +400,7 @@ object ListItemRenderer {
// `trailing_a11y_label` wins, else humanize the machine icon name
// ("more_vert" -> "more vert") so it's never unlabeled.
val iconButtonDescription = a11yLabel.ifEmpty {
- effectiveValue.replace('_', ' ').replace('-', ' ')
+ effectiveValue.removePrefix(com.nativephp.mobile.ui.CUSTOM_ICON_PREFIX).replace('_', ' ').replace('-', ' ')
}
return {
diff --git a/resources/android/NavRenderers.kt b/resources/android/NavRenderers.kt
index 41c880b..a5d1bbe 100644
--- a/resources/android/NavRenderers.kt
+++ b/resources/android/NavRenderers.kt
@@ -37,6 +37,8 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.nativephp.mobile.R
+import com.nativephp.mobile.ui.CUSTOM_ICON_PREFIX
+import com.nativephp.mobile.ui.MaterialIcon
import com.nativephp.mobile.ui.getIconName
import com.nativephp.mobile.ui.nativerender.LocalSafeAreaBottom
import com.nativephp.mobile.ui.nativerender.LocalSafeAreaTop
@@ -112,21 +114,28 @@ object TopBarRenderer {
val icon = action.props.getString("icon", "more_vert")
val url = action.props.getString("url")
val actionLabel = action.props.getString("label")
- Text(
- text = getIconName(icon), fontFamily = iconFont, fontSize = 24.sp, color = textColor,
- modifier = Modifier
- .minimumInteractiveComponentSize()
- .clickable(role = Role.Button) {
- if (url.isNotEmpty()) {
- try { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) } catch (_: Exception) {}
- } else if (action.onPress != 0) {
- NativeElementBridge.sendPressEvent(action.onPress, action.id)
- }
- }
- .let { m ->
- if (actionLabel.isNotEmpty()) m.semantics { contentDescription = actionLabel } else m
+ val actionModifier = Modifier
+ .minimumInteractiveComponentSize()
+ .clickable(role = Role.Button) {
+ if (url.isNotEmpty()) {
+ try { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) } catch (_: Exception) {}
+ } else if (action.onPress != 0) {
+ NativeElementBridge.sendPressEvent(action.onPress, action.id)
}
- )
+ }
+ .let { m ->
+ if (actionLabel.isNotEmpty()) m.semantics { contentDescription = actionLabel } else m
+ }
+ // Custom SVG icons are drawables, not font ligatures, so they
+ // go through core's MaterialIcon. Font glyphs stay as they were.
+ if (icon.startsWith(CUSTOM_ICON_PREFIX)) {
+ MaterialIcon(name = icon, contentDescription = null, modifier = actionModifier, size = 24.dp, tint = textColor)
+ } else {
+ Text(
+ text = getIconName(icon), fontFamily = iconFont, fontSize = 24.sp, color = textColor,
+ modifier = actionModifier
+ )
+ }
}
}
}
@@ -211,7 +220,11 @@ object BottomNavRenderer {
// Box wraps the icon at center; badge / news dot anchored
// to the icon's TopEnd corner via `.align(TopEnd)`.
Box(contentAlignment = Alignment.Center) {
- Text(text = getIconName(icon), fontFamily = iconFont, fontSize = 24.sp, color = itemColor, textAlign = TextAlign.Center)
+ if (icon.startsWith(CUSTOM_ICON_PREFIX)) {
+ MaterialIcon(name = icon, contentDescription = null, size = 24.dp, tint = itemColor)
+ } else {
+ Text(text = getIconName(icon), fontFamily = iconFont, fontSize = 24.sp, color = itemColor, textAlign = TextAlign.Center)
+ }
if (badge.isNotEmpty()) {
Box(
modifier = Modifier
diff --git a/resources/android/SignInWithAppleButtonRenderer.kt b/resources/android/SignInWithAppleButtonRenderer.kt
new file mode 100644
index 0000000..4efdc89
--- /dev/null
+++ b/resources/android/SignInWithAppleButtonRenderer.kt
@@ -0,0 +1,20 @@
+package com.nativephp.plugins.native_ui.ui
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.nativephp.mobile.ui.nativerender.DefaultContainerNode
+import com.nativephp.mobile.ui.nativerender.NativeUINode
+
+/**
+ * Sign in with Apple has no Android button, so this draws nothing unless the
+ * tag has children, which render as an Android-only fallback (laid out like a
+ * column). The iOS renderer draws Apple's button and ignores the children.
+ */
+object SignInWithAppleButtonRenderer {
+ @Composable
+ fun Render(node: NativeUINode, modifier: Modifier) {
+ if (node.children.isNotEmpty()) {
+ DefaultContainerNode(node, modifier)
+ }
+ }
+}
diff --git a/resources/boost/guidelines/core.blade.php b/resources/boost/guidelines/core.blade.php
index 299e903..45cda17 100644
--- a/resources/boost/guidelines/core.blade.php
+++ b/resources/boost/guidelines/core.blade.php
@@ -58,6 +58,20 @@
- In tests, drive pickers with the `pickDate()` / `pickTime()` /
`pickDateTime()` / `clearPicker()` macros and assert with
`assertPicker()` / `assertPickerValue()` / `assertPickerEmpty()`.
+- For Sign in with Apple, use ``, never a `` with an Apple logo: iOS draws
+ SwiftUI's `SignInWithAppleButton`, which is what App Review expects. Tapping
+ it runs the Apple ID request itself, so don't also start one from a plugin.
+ Listen for the result with `#[On(SignInWithAppleCompleted::class)]` and
+ `#[On(SignInWithAppleFailed::class)]` (`Native\Mobile\UI\Events`). Pass a
+ fresh `nonce` per attempt, the SHA-256 hex of a raw nonce you keep in memory
+ (never the raw value): it goes to Apple as given and the completed event
+ echoes it back. `scopes` is any of `email` / `full-name`. `label`
+ is `sign-in` (default) / `continue` / `sign-up` (NOT free text), and
+ `button-style` is `auto` (default, black in light mode and white in dark) /
+ `black` / `white` / `white-outline`. The app still needs the Sign in with
+ Apple entitlement from a plugin. Apple has no button on Android, so nothing
+ renders there unless you give the tag children as a fallback.
@verbatim
diff --git a/resources/ios/NativeUIButtonRenderer.swift b/resources/ios/NativeUIButtonRenderer.swift
index 312cab5..20f071a 100644
--- a/resources/ios/NativeUIButtonRenderer.swift
+++ b/resources/ios/NativeUIButtonRenderer.swift
@@ -396,7 +396,7 @@ private struct ButtonContent: View {
}
} else {
if !icon.isEmpty {
- Image(systemName: getIconForName(icon))
+ NativeIconView(icon, pointSize: iconSize, relativeTo: .body)
.nuiScaledFont(size: iconSize)
// Pin the layout height to iconSize. SF Symbols report
// per-symbol intrinsic heights at the same point size,
@@ -411,7 +411,7 @@ private struct ButtonContent: View {
Text(label).nuiScaledFont(size: textSize, weight: .medium, fontName: fontName).lineSpacing(lineSpacing)
}
if !iconTrailing.isEmpty {
- Image(systemName: getIconForName(iconTrailing))
+ NativeIconView(iconTrailing, pointSize: iconSize, relativeTo: .body)
.nuiScaledFont(size: iconSize)
.frame(height: iconSize)
}
@@ -465,7 +465,7 @@ private func buttonMenuItem(_ item: NativeUINode) -> some View {
}
} label: {
if !icon.isEmpty {
- Label(label, systemImage: getIconForName(icon))
+ Label { Text(label) } icon: { NativeIcon.image(icon) }
} else {
Text(label)
}
diff --git a/resources/ios/NativeUIChipRenderer.swift b/resources/ios/NativeUIChipRenderer.swift
index c073b8a..fad99e5 100644
--- a/resources/ios/NativeUIChipRenderer.swift
+++ b/resources/ios/NativeUIChipRenderer.swift
@@ -66,7 +66,7 @@ struct NativeUIChipRenderer: View {
}) {
HStack(spacing: 6) {
if !iconName.isEmpty {
- Image(systemName: getIconForName(iconName))
+ NativeIconView(iconName, pointSize: 14, relativeTo: .body)
.nuiScaledFont(size: 14)
}
Text(label).nuiScaledFont(size: theme.fontSm, weight: .medium)
diff --git a/resources/ios/NativeUIFilledTextInputRenderer.swift b/resources/ios/NativeUIFilledTextInputRenderer.swift
index f8c6f51..385e920 100644
--- a/resources/ios/NativeUIFilledTextInputRenderer.swift
+++ b/resources/ios/NativeUIFilledTextInputRenderer.swift
@@ -68,7 +68,7 @@ struct NativeUIFilledTextInputRenderer: View {
VStack(spacing: 0) {
HStack(spacing: 8) {
if !leadingIcon.isEmpty {
- Image(systemName: getIconForName(leadingIcon))
+ NativeIconView(leadingIcon, pointSize: metrics.iconSize, relativeTo: .body)
.nuiScaledFont(size: metrics.iconSize)
.foregroundStyle(theme.onSurfaceVariant)
}
@@ -95,7 +95,7 @@ struct NativeUIFilledTextInputRenderer: View {
if loading {
ProgressView().controlSize(.small)
} else if !trailingIcon.isEmpty {
- Image(systemName: getIconForName(trailingIcon))
+ NativeIconView(trailingIcon, pointSize: metrics.iconSize, relativeTo: .body)
.nuiScaledFont(size: metrics.iconSize)
.foregroundStyle(theme.onSurfaceVariant)
}
diff --git a/resources/ios/NativeUIIconRenderer.swift b/resources/ios/NativeUIIconRenderer.swift
index 5ed78a5..62dd317 100644
--- a/resources/ios/NativeUIIconRenderer.swift
+++ b/resources/ios/NativeUIIconRenderer.swift
@@ -27,7 +27,7 @@ struct NativeUIIconRenderer: View {
? darkArgb
: lightArgb
- Image(systemName: getIconForName(name))
+ NativeIcon.image(name)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: size, height: size)
diff --git a/resources/ios/NativeUIListItemRenderer.swift b/resources/ios/NativeUIListItemRenderer.swift
index 534cf7f..649a376 100644
--- a/resources/ios/NativeUIListItemRenderer.swift
+++ b/resources/ios/NativeUIListItemRenderer.swift
@@ -130,13 +130,13 @@ struct NativeUIListItemRenderer: View {
if iconBgColor != 0 {
ZStack {
Circle().fill(Color(argb: iconBgColor))
- Image(systemName: getIconForName(value))
+ NativeIconView(value, pointSize: 18, relativeTo: .body)
.nuiScaledFont(size: 18, weight: .medium)
.foregroundColor(.white)
}
.frame(width: 40, height: 40)
} else {
- Image(systemName: getIconForName(value))
+ NativeIcon.image(value)
.frame(width: 24, height: 24)
.foregroundColor(.secondary)
}
@@ -225,7 +225,7 @@ struct NativeUIListItemRenderer: View {
if !badges.isEmpty {
HStack(spacing: 6) {
ForEach(badges) { b in
- Image(systemName: getIconForName(b.icon))
+ NativeIconView(b.icon, pointSize: 15, relativeTo: .body)
.nuiScaledFont(size: 15)
.foregroundColor(parseListItemHex(b.color) ?? .secondary)
}
@@ -239,7 +239,7 @@ struct NativeUIListItemRenderer: View {
private func buildTrailingContent(type: String, value: String, iconColor: Int, textColor: Int, checked: Bool = false, changeCb: Int = 0) -> some View {
switch type {
case "icon":
- Image(systemName: getIconForName(value))
+ NativeIcon.image(value)
.frame(width: 24, height: 24)
.foregroundColor(iconColor != 0 ? Color(argb: iconColor) : .secondary)
case "text":
@@ -252,7 +252,7 @@ struct NativeUIListItemRenderer: View {
let trailingA11y = node.props.getString("trailing_a11y_label")
let effectiveTrailingA11y = !trailingA11y.isEmpty
? trailingA11y
- : value.replacingOccurrences(of: "_", with: " ")
+ : (NativeIcon.customName(value) ?? value).replacingOccurrences(of: "_", with: " ")
.replacingOccurrences(of: "-", with: " ")
// When the row has `:trailing-menu` attached, the trailing
// icon button becomes a Menu trigger instead of a plain
@@ -271,7 +271,7 @@ struct NativeUIListItemRenderer: View {
// without it only the symbol's opaque pixels respond,
// which makes the ellipsis glyph (mostly empty space)
// almost un-tappable.
- Image(systemName: getIconForName(value))
+ NativeIcon.image(value)
.frame(width: 24, height: 24)
.foregroundColor(iconColor != 0 ? Color(argb: iconColor) : .secondary)
.nuiMinTapTarget()
@@ -289,7 +289,7 @@ struct NativeUIListItemRenderer: View {
}
}
}) {
- Image(systemName: getIconForName(value))
+ NativeIcon.image(value)
.frame(width: 24, height: 24)
.foregroundColor(iconColor != 0 ? Color(argb: iconColor) : .secondary)
.nuiMinTapTarget()
@@ -331,7 +331,7 @@ private func listItemMenuItem(_ item: NativeUINode) -> some View {
}
} label: {
if !icon.isEmpty {
- Label(label, systemImage: getIconForName(icon))
+ Label { Text(label) } icon: { NativeIcon.image(icon) }
} else {
Text(label)
}
diff --git a/resources/ios/NativeUIListRenderer.swift b/resources/ios/NativeUIListRenderer.swift
index 647f034..8a9e3b1 100644
--- a/resources/ios/NativeUIListRenderer.swift
+++ b/resources/ios/NativeUIListRenderer.swift
@@ -191,7 +191,11 @@ struct NativeUIListRenderer: View {
let button = Button(role: role) {
NativeElementBridge.sendPressEvent(spec.cb, nodeId: nodeId)
} label: {
- if !spec.icon.isEmpty {
+ if spec.icon.hasPrefix(NativeIcon.customPrefix) {
+ // Swipe icons are raw SF Symbol names (no getIconForName
+ // mapping), so only custom icons take the NativeIcon path.
+ Label { Text(spec.label.isEmpty ? " " : spec.label) } icon: { NativeIcon.image(spec.icon) }
+ } else if !spec.icon.isEmpty {
Label(spec.label.isEmpty ? " " : spec.label, systemImage: spec.icon)
} else {
Text(spec.label.isEmpty ? " " : spec.label)
diff --git a/resources/ios/NativeUINavRenderers.swift b/resources/ios/NativeUINavRenderers.swift
index 092417a..7f012d7 100644
--- a/resources/ios/NativeUINavRenderers.swift
+++ b/resources/ios/NativeUINavRenderers.swift
@@ -62,14 +62,14 @@ struct NativeUITopBarRenderer: View {
? a11y
: (!actionLabel.isEmpty
? actionLabel
- : icon.replacingOccurrences(of: "_", with: " ")
+ : (NativeIcon.customName(icon) ?? icon).replacingOccurrences(of: "_", with: " ")
.replacingOccurrences(of: "-", with: " "))
Button {
if action.onPress != 0 {
NativeElementBridge.sendPressEvent(action.onPress, nodeId: action.id)
}
} label: {
- Image(systemName: getIconForName(icon))
+ NativeIconView(icon, pointSize: 17, relativeTo: .body)
.nuiScaledFont(size: 17, weight: .semibold)
.foregroundColor(textColor)
.frame(width: 32, height: 32)
@@ -166,7 +166,7 @@ struct NativeUIBottomNavRenderer: View {
} label: {
VStack(spacing: 4) {
ZStack(alignment: .topTrailing) {
- Image(systemName: getIconForName(icon))
+ NativeIconView(icon, pointSize: 24, relativeTo: .body)
.nuiScaledFont(size: 24)
if !badge.isEmpty {
Text(badge)
diff --git a/resources/ios/NativeUIOutlinedTextInputRenderer.swift b/resources/ios/NativeUIOutlinedTextInputRenderer.swift
index 67c69e6..bc1c1c2 100644
--- a/resources/ios/NativeUIOutlinedTextInputRenderer.swift
+++ b/resources/ios/NativeUIOutlinedTextInputRenderer.swift
@@ -97,7 +97,7 @@ struct NativeUIOutlinedTextInputRenderer: View {
HStack(spacing: 8) {
if !leadingIcon.isEmpty {
- Image(systemName: getIconForName(leadingIcon))
+ NativeIconView(leadingIcon, pointSize: metrics.iconSize, relativeTo: .body)
.nuiScaledFont(size: metrics.iconSize)
.foregroundStyle(fieldDecorationColor)
}
@@ -128,7 +128,7 @@ struct NativeUIOutlinedTextInputRenderer: View {
if loading {
ProgressView().controlSize(.small)
} else if !trailingIcon.isEmpty {
- Image(systemName: getIconForName(trailingIcon))
+ NativeIconView(trailingIcon, pointSize: metrics.iconSize, relativeTo: .body)
.nuiScaledFont(size: metrics.iconSize)
.foregroundStyle(fieldDecorationColor)
}
diff --git a/resources/ios/NativeUISignInWithAppleButtonRenderer.swift b/resources/ios/NativeUISignInWithAppleButtonRenderer.swift
new file mode 100644
index 0000000..68f07a8
--- /dev/null
+++ b/resources/ios/NativeUISignInWithAppleButtonRenderer.swift
@@ -0,0 +1,224 @@
+import AuthenticationServices
+import SwiftUI
+
+/// SwiftUI's own `SignInWithAppleButton`. Tapping it runs the Apple ID
+/// request, as the system button always does, and the result goes to PHP as
+/// a native event: `Native\Mobile\UI\Events\SignInWithAppleCompleted` or
+/// `SignInWithAppleFailed`. `on_press` fires as the request starts.
+///
+/// Props:
+/// - `label`: sign_in (default) | continue | sign_up
+/// - `button_style`: auto (default) | black | white | white_outline.
+/// `auto` follows the colour scheme: black in light mode, white in dark.
+/// - `corner_radius`: points, clamped to half the height (a capsule).
+/// The system default when absent.
+/// - `scopes`: any of `email`, `full_name`. None by default.
+/// - `nonce`: sent to Apple as given, and echoed on the completed event.
+/// Apps pass the SHA-256 of a raw nonce they keep, so the raw value
+/// never sits in the UI tree; Apple's identity token carries the hash.
+/// - `state`: echoed back on both events.
+/// - `disabled`
+/// - `a11y_label`, `a11y_hint`: override the system's VoiceOver text.
+///
+/// Height comes from the node's layout (`height`, `h-*`), 44pt when unset.
+/// Width follows the parent's proposal like any other control (`w-full`
+/// to fill). Children are an Android-only fallback and are ignored here.
+struct NativeUISignInWithAppleButtonRenderer: View {
+ let node: NativeUINode
+
+ @Environment(\.colorScheme) private var colorScheme
+
+ static let defaultHeight: CGFloat = 44
+ static let completedEvent = "Native\\Mobile\\UI\\Events\\SignInWithAppleCompleted"
+ static let failedEvent = "Native\\Mobile\\UI\\Events\\SignInWithAppleFailed"
+
+ var body: some View {
+ let p = node.props
+ let pressCb = p.getCallbackId("on_press")
+ let nodeId = node.id
+ let scopes = p.getStringList("scopes")
+ let nonce = p.getString("nonce")
+ let state = p.getString("state")
+ let disabled = p.getBool("disabled")
+ let a11yLabel = p.getString("a11y_label")
+ let a11yHint = p.getString("a11y_hint")
+ let styleName = Self.styleName(p.getString("button_style", default: "auto"), colorScheme: colorScheme)
+ let explicitHeight = (node.layout?.heightMode ?? SizeMode.wrap) != SizeMode.wrap
+
+ SignInWithAppleButton(Self.label(p.getString("label", default: "sign_in"))) { request in
+ request.requestedScopes = scopes.compactMap(Self.scope)
+ if !nonce.isEmpty {
+ request.nonce = nonce
+ }
+ if !state.isEmpty {
+ request.state = state
+ }
+ if pressCb != 0 {
+ // Through the focus policy so a focused field's pending
+ // change reaches PHP before the press does, as for