diff --git a/CHANGELOG.md b/CHANGELOG.md index 47870bc..1673367 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- NativePHP custom SVG icon support through `custom:`, including shared icons, mixed SF Symbol/Material/custom platform overrides, tinting, sizing, validation, and missing-asset fallback behavior. - Optional whole-toast `opacity()` styling with clamping, sparse updates, preset support, and PHP/JavaScript parity. - Platform-selective `native()` rendering with iOS Liquid Glass/native material fallback, Android Material 3 appearance, sparse update support, presets, and JavaScript parity. - Unique toasts through `unique()`, `updateUnique()`, and `dismissUnique()`, with native lifecycle-aware deduplication across visible, queued, stacked, and persistent states on Android and iOS. @@ -33,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- NativePHP Mobile minimum requirement raised from `^4.1` to `^4.7` for the custom icon build and runtime asset pipeline. - **Breaking:** ToastKit's public exceptions now extend the package-owned `ToastKitException` base, which extends `RuntimeException`. Replace ToastKit-specific `InvalidArgumentException` catches with `ToastKitException`. - Composer requirement raised to PHP `^8.4` and NativePHP Mobile `^4.1`. - Pest upgraded to `^4.0`. diff --git a/README.md b/README.md index e41bfd6..e3358f7 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ ToastKit renders toasts as native overlays — Jetpack Compose on Android, Swift - [Custom Toasts](#custom-toasts) - [Icons](#icons) - [Platform overrides](#platform-overrides) + - [Custom SVG icons](#custom-svg-icons) - [Icon size](#icon-size) - [Typography](#typography) - [Font resolution](#font-resolution) @@ -50,7 +51,7 @@ ToastKit renders toasts as native overlays — Jetpack Compose on Android, Swift ## Feature Highlights - **Five variants** — `success`, `error`, `warning`, `info`, and `neutral`. -- **Rich content** — title, message, and icons with per-platform overrides. +- **Rich content** — title, message, system icons, custom SVG icons, and per-platform overrides. - **Full customization** — position, animation direction, progress, loading, swipe-to-dismiss, close control, colors, corner radius, padding, and shadow. - **Action buttons** — a native action button with its own ID and pressed event. - **Queue strategy** — FIFO, one toast at a time. @@ -182,6 +183,52 @@ You can pass either override on its own: Toast::make('Rated')->icon(ios: 'star.fill')->show(); ``` +### Custom SVG icons + +ToastKit supports the custom SVG icons built by NativePHP Mobile 4.7 and later. Put a single-color SVG directly in your application's `resources/icons` directory: + +```text +resources/icons/payment.svg +``` + +Then reference it with NativePHP's `custom:` prefix: + +```php +use Native\Mobile\Icon\Icon; + +Toast::make('Payment successful') + ->icon('custom:payment') + ->iconColor('#22C55E') + ->show(); + +// NativePHP's helper returns the same `custom:payment` string. +Toast::make('Payment successful')->icon(Icon::custom('payment'))->show(); +``` + +The shared name can be custom, or custom and platform icons can be mixed freely. A platform override wins on that platform and `name` remains the fallback: + +```php +Toast::make('Payment successful') + ->icon( + name: 'check', // shared fallback + ios: 'checkmark.circle.fill', // SF Symbol + android: 'custom:payment-android', // custom SVG + ) + ->show(); + +Toast::make('Payment successful') + ->icon( + name: 'custom:payment', // custom on both by default + ios: 'custom:payment-ios', // custom iOS override + android: 'verified', // Material Icon override + ) + ->show(); +``` + +Custom icon filenames must contain lowercase letters and numbers joined by single hyphens, such as `payment-card.svg`; subdirectories, underscores, uppercase letters, and consecutive hyphens are not supported. NativePHP compiles the SVG into an iOS template asset and Android `VectorDrawable`, so run a full native build after adding or changing an icon—hot reload is not enough. + +Custom icons are single-color and use `iconColor()`. `iconSize()` works with them in ToastKit's standard and dynamic renderers. If an asset is unavailable, including in Jump, NativePHP logs a warning and falls back to the system icon matching the bare name. See NativePHP's [custom icon requirements and supported SVG features](https://nativephp.com/docs/mobile/4/edge-components/icon#custom-icons). + ### Icon size Choose one of three icon sizes for the custom renderer: @@ -1098,7 +1145,7 @@ Native::test(ProfileScreen::class) | `titleText(?string $font = null, $size = null, $weight = null, $align = null, ?bool $italic = null)` | Configure title typography. See [Typography](#typography). | | `success()` / `error()` / `warning()` / `info()` / `neutral()` | Set the variant. | | `variant(ToastVariant\|string $variant)` | Set the variant by enum or string. | -| `icon(?string $name = null, ?string $ios = null, ?string $android = null)` | Set an icon using string names, with optional SF Symbol (`ios:`) / Material Icon (`android:`) overrides. See [Icons](#icons). | +| `icon(?string $name = null, ?string $ios = null, ?string $android = null)` | Set a shared, platform-specific, or `custom:` SVG icon. Custom and system icons can be mixed across platforms. See [Icons](#icons). | | `iconSize(string $size)` | Set custom-renderer icon size to `sm`, `md`, or `lg`; native appearance ignores it. | | `position(ToastPosition\|string $position)` | `top`, `center`, or `bottom`. | | `presentation(ToastPresentation\|string $presentation)` | Select `standard` or `dynamic` presentation. | @@ -1175,7 +1222,7 @@ Native::test(ProfileScreen::class) | Requirement | Version | | --- | --- | | PHP | 8.4+ | -| NativePHP Mobile | 4.1+ | +| NativePHP Mobile | 4.7+ | | Android | API 29+ (Android 10) | | iOS | 18.0+ | diff --git a/composer.json b/composer.json index 20bab28..d97bc9e 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,7 @@ ], "require": { "php": "^8.4", - "nativephp/mobile": "^4.1" + "nativephp/mobile": "^4.7" }, "autoload": { "psr-4": { diff --git a/resources/ios/Sources/Presentation/ToastKitHost.swift b/resources/ios/Sources/Presentation/ToastKitHost.swift index 26c6409..010c251 100644 --- a/resources/ios/Sources/Presentation/ToastKitHost.swift +++ b/resources/ios/Sources/Presentation/ToastKitHost.swift @@ -1,5 +1,23 @@ import SwiftUI +private struct ToastKitIconView: View { + let name: String + let pointSize: CGFloat + + var body: some View { + if let asset = NativeIcon.customAssetName(name) { + Image(asset) + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: pointSize, height: pointSize) + } else { + Image(systemName: getIconForName(name)) + .font(.system(size: pointSize, weight: .semibold)) + } + } +} + struct ToastKitHost: View { @ObservedObject private var manager = ToastKitManager.shared @Environment(\.accessibilityReduceMotion) private var reduceMotion @@ -166,8 +184,10 @@ private struct ToastKitView: View { .frame(width: 24, height: 24) } else { let icon = toast.icon - Image(systemName: icon?.ios ?? getIconForName(icon?.name ?? "bell.fill")) - .font(.system(size: toastKitIconSize(toast.iconSize, fallback: 20), weight: .semibold)) + ToastKitIconView( + name: icon?.ios ?? icon?.name ?? "bell.fill", + pointSize: toastKitIconSize(toast.iconSize, fallback: 20) + ) .foregroundStyle(toast.style.iconColor) .frame(width: 24, height: 24) .accessibilityHidden(true) @@ -244,8 +264,11 @@ private struct ToastKitView: View { if toast.progress == nil && toast.loading { ProgressView().tint(toast.style.iconColor).controlSize(.small) } else if let icon = toast.icon { - Image(systemName: icon.ios ?? getIconForName(icon.name ?? "circle")) - .font(.system(size: toastKitIconSize(toast.iconSize, fallback: 21), weight: .semibold)).foregroundStyle(toast.style.iconColor) + ToastKitIconView( + name: icon.ios ?? icon.name ?? "circle", + pointSize: toastKitIconSize(toast.iconSize, fallback: 21) + ) + .foregroundStyle(toast.style.iconColor) .accessibilityHidden(true) } VStack(alignment: .leading, spacing: 2) { @@ -297,8 +320,7 @@ private struct ToastKitView: View { if toast.progress == nil && toast.loading { ProgressView().controlSize(.small) } else if let icon = toast.icon { - Image(systemName: icon.ios ?? getIconForName(icon.name ?? "circle")) - .font(.title3.weight(.semibold)) + ToastKitIconView(name: icon.ios ?? icon.name ?? "circle", pointSize: 20) .accessibilityHidden(true) } VStack(alignment: .leading, spacing: 2) { diff --git a/resources/js/PendingToastBase.js b/resources/js/PendingToastBase.js index 963c87b..7e3b3b5 100644 --- a/resources/js/PendingToastBase.js +++ b/resources/js/PendingToastBase.js @@ -49,9 +49,10 @@ export class PendingToastBase { } icon(name = null, { ios = null, android = null } = {}) { const icon = {}; - if (name !== null) icon.name = nonEmpty(name, "icon name"); - if (ios !== null) icon.ios = nonEmpty(ios, "iOS icon"); - if (android !== null) icon.android = nonEmpty(android, "Android icon"); + if (name !== null) icon.name = customIconName(name, "icon name"); + if (ios !== null) icon.ios = customIconName(ios, "iOS icon"); + if (android !== null) + icon.android = customIconName(android, "Android icon"); if (!Object.keys(icon).length) throw new TypeError("An icon name or platform override is required"); return this.set("icon", icon); @@ -202,3 +203,10 @@ export class PendingToastBase { return this.set("max_visible", positiveInteger(value, "maxVisible")); } } + +function customIconName(value, label) { + const name = nonEmpty(value, label); + if (name.startsWith("custom:") && !/^custom:[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) + throw new TypeError(`${label} custom icon must use custom: followed by a lowercase kebab-case name`); + return name; +} diff --git a/resources/js/tests/toastkit.test.js b/resources/js/tests/toastkit.test.js index ebd8810..2963ccb 100644 --- a/resources/js/tests/toastkit.test.js +++ b/resources/js/tests/toastkit.test.js @@ -72,6 +72,84 @@ test("icon supports logical names and platform overrides", () => { ); }); +test("icon supports shared custom icons and mixed platform icons", () => { + assert.deepEqual(Toast.make("x").icon("custom:payment").payload().icon, { + name: "custom:payment", + }); + assert.deepEqual( + Toast.make("x") + .icon("check", { + ios: "checkmark.circle.fill", + android: "custom:payment-android", + }) + .payload().icon, + { + name: "check", + ios: "checkmark.circle.fill", + android: "custom:payment-android", + }, + ); + assert.deepEqual( + Toast.make("x") + .icon("custom:payment", { + ios: "custom:payment-ios", + android: "verified", + }) + .payload().icon, + { + name: "custom:payment", + ios: "custom:payment-ios", + android: "verified", + }, + ); +}); + +test("icon rejects invalid custom icon names", () => { + for (const icon of [ + "custom:", + "custom:Payment", + "custom:payment_icon", + "custom:icons/payment", + "custom:payment--icon", + ]) { + assert.throws(() => Toast.make("x").icon(icon), /lowercase kebab-case/); + } + assert.throws( + () => Toast.make("x").icon("check", { ios: "custom:Payment" }), + /lowercase kebab-case/, + ); + assert.throws( + () => Toast.make("x").icon("check", { android: "custom:payment_icon" }), + /lowercase kebab-case/, + ); +}); + +test("icon supports custom platform overrides without a shared name", () => { + assert.deepEqual( + Toast.make("x").icon(null, { ios: "custom:payment-ios" }).payload().icon, + { ios: "custom:payment-ios" }, + ); + assert.deepEqual( + Toast.make("x").icon(null, { android: "custom:payment-android" }).payload() + .icon, + { android: "custom:payment-android" }, + ); +}); + +test("update sends custom icons sparsely", () => { + assert.deepEqual( + Toast.update("one") + .icon("custom:payment", { android: "custom:payment-android" }) + .payload().changes, + { + icon: { + name: "custom:payment", + android: "custom:payment-android", + }, + }, + ); +}); + test("icon size accepts only predefined sizes", () => { assert.equal(Toast.make("x").iconSize("lg").payload().icon_size, "lg"); assert.deepEqual(Toast.update("one").iconSize("sm").payload().changes, { diff --git a/src/Concerns/ConfiguresToast.php b/src/Concerns/ConfiguresToast.php index 4524bbf..7e8ce7b 100644 --- a/src/Concerns/ConfiguresToast.php +++ b/src/Concerns/ConfiguresToast.php @@ -3,6 +3,7 @@ namespace Victorycodedev\ToastKit\Concerns; use BackedEnum; +use Native\Mobile\Icon\Icon; use Victorycodedev\ToastKit\Enums\ToastAnimation; use Victorycodedev\ToastKit\Enums\ToastDirection; use Victorycodedev\ToastKit\Enums\ToastPosition; @@ -81,9 +82,9 @@ public function variant(ToastVariant|string $variant): static public function icon(?string $name = null, ?string $ios = null, ?string $android = null): static { $icon = array_filter([ - 'name' => $this->nullableNonEmpty($name, 'Icon name'), - 'ios' => $this->nullableNonEmpty($ios, 'iOS icon name'), - 'android' => $this->nullableNonEmpty($android, 'Android icon name'), + 'name' => $this->iconName($name, 'Icon name'), + 'ios' => $this->iconName($ios, 'iOS icon name'), + 'android' => $this->iconName($android, 'Android icon name'), ], static fn($value) => $value !== null); if ($icon === []) throw new InvalidToastConfigurationException('An icon name or platform override is required.'); return $this->put('icon', $icon); @@ -282,6 +283,20 @@ private function color(string $color): string return strtoupper($color); } + private function iconName(?string $value, string $label): ?string + { + $value = $this->nullableNonEmpty($value, $label); + + if ($value !== null && Icon::isCustom($value) + && ! Icon::isValidName(substr($value, strlen(Icon::PREFIX)))) { + throw new InvalidToastConfigurationException( + "{$label} custom icon must use custom: followed by a lowercase kebab-case name." + ); + } + + return $value; + } + private function enumValue(string $enum, BackedEnum|string $value, string $label): string { if ($value instanceof $enum) return $value->value; diff --git a/tests/Unit/ConfigurationTest.php b/tests/Unit/ConfigurationTest.php index d156019..5616ccd 100644 --- a/tests/Unit/ConfigurationTest.php +++ b/tests/Unit/ConfigurationTest.php @@ -39,12 +39,50 @@ ]); }); +it('supports custom icons shared and mixed with platform icons', function () { + $shared = $this->kit->make('Shared')->icon('custom:payment')->payload(); + $mixed = $this->kit->make('Mixed') + ->icon('check', ios: 'checkmark.circle.fill', android: 'custom:payment-android') + ->payload(); + $reverse = $this->kit->make('Reverse') + ->icon('custom:payment', ios: 'custom:payment-ios', android: 'verified') + ->payload(); + + expect($shared['icon'])->toBe(['name' => 'custom:payment']) + ->and($mixed['icon'])->toBe([ + 'name' => 'check', + 'ios' => 'checkmark.circle.fill', + 'android' => 'custom:payment-android', + ]) + ->and($reverse['icon'])->toBe([ + 'name' => 'custom:payment', + 'ios' => 'custom:payment-ios', + 'android' => 'verified', + ]); +}); + +it('rejects invalid custom icon names in every icon slot', function () { + expect(fn () => $this->kit->make('x')->icon('custom:'))->toThrow(InvalidToastConfigurationException::class) + ->and(fn () => $this->kit->make('x')->icon(ios: 'custom:Payment'))->toThrow(InvalidToastConfigurationException::class) + ->and(fn () => $this->kit->make('x')->icon(android: 'custom:payment_icon'))->toThrow(InvalidToastConfigurationException::class) + ->and(fn () => $this->kit->make('x')->icon('custom:icons/payment'))->toThrow(InvalidToastConfigurationException::class) + ->and(fn () => $this->kit->make('x')->icon('custom:payment--icon'))->toThrow(InvalidToastConfigurationException::class); +}); + it('sets a platform override without a logical name', function () { $this->kit->make('Saved')->icon(ios: 'star.fill')->show(); expect($this->calls[0][1]['icon'])->toBe(['ios' => 'star.fill']); }); +it('sets custom platform overrides without a logical name', function () { + $ios = $this->kit->make('iOS')->icon(ios: 'custom:payment-ios')->payload(); + $android = $this->kit->make('Android')->icon(android: 'custom:payment-android')->payload(); + + expect($ios['icon'])->toBe(['ios' => 'custom:payment-ios']) + ->and($android['icon'])->toBe(['android' => 'custom:payment-android']); +}); + it('rejects an icon with no name or overrides', function () { expect(fn () => $this->kit->make('x')->icon())->toThrow(InvalidToastConfigurationException::class); }); diff --git a/tests/Unit/UpdateTest.php b/tests/Unit/UpdateTest.php index 7d7ed19..96a8f84 100644 --- a/tests/Unit/UpdateTest.php +++ b/tests/Unit/UpdateTest.php @@ -37,6 +37,19 @@ ]); }); +it('updates a custom icon sparsely', function () { + $this->kit->update('upload') + ->icon('custom:payment', android: 'custom:payment-android') + ->show(); + + expect($this->calls[0][1]['changes'])->toBe([ + 'icon' => [ + 'name' => 'custom:payment', + 'android' => 'custom:payment-android', + ], + ]); +}); + it('updates icon size sparsely', function () { $this->kit->update('upload')->iconSize('lg')->show();