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