From 0849f75afab7a7b6125e047c4aa1d9198707e128 Mon Sep 17 00:00:00 2001 From: Victory Efekpogua Date: Sat, 29 Aug 2026 18:12:13 +0100 Subject: [PATCH 1/5] feat: add unique key support for toasts - Introduced `ToastKit.UpdateUnique` and `ToastKit.DismissUnique` methods for updating and dismissing toasts by unique keys. - Updated Android and iOS implementations to handle unique keys in toast management. - Enhanced JavaScript API to support unique key operations in `PendingToastUpdate` and `Toast` classes. - Added tests for unique toast functionality, ensuring correct behavior for showing, updating, and dismissing toasts with unique identifiers. --- CHANGELOG.md | 2 + README.md | 70 ++++++- nativephp.json | 12 ++ .../plugins/toastkit/ToastKitFunctions.kt | 22 ++- .../bridge/ToastKitBridgeNormalizer.kt | 9 + .../toastkit/manager/ToastKitManager.kt | 50 ++++- .../plugins/toastkit/model/ToastKitModels.kt | 1 + .../Bridge/ToastKitBridgeNormalizer.swift | 10 + .../ios/Sources/Manager/ToastKitManager.swift | 17 ++ .../Manager/ToastKitUniqueRegistry.swift | 46 +++++ .../ios/Sources/Model/ToastKitModels.swift | 1 + resources/ios/Sources/ToastKitFunctions.swift | 29 ++- resources/js/PendingToast.js | 8 +- resources/js/PendingToastUpdate.js | 17 +- resources/js/Toast.js | 5 + resources/js/bridge.js | 3 +- resources/js/index.js | 18 +- resources/js/tests/toastkit.test.js | 52 +++++- resources/js/toastkit.js | 15 +- src/Concerns/ConfiguresToast.php | 2 +- src/Facades/Toast.php | 7 +- src/PendingToast.php | 12 +- src/PendingToastUpdate.php | 18 +- src/ToastKit.php | 56 +++++- tests/Unit/BridgeTest.php | 16 ++ tests/Unit/UniqueToastTest.php | 172 ++++++++++++++++++ 26 files changed, 634 insertions(+), 36 deletions(-) create mode 100644 resources/ios/Sources/Manager/ToastKitUniqueRegistry.swift create mode 100644 tests/Unit/UniqueToastTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 18da044..34c2464 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Unique toasts through `unique()`, `updateUnique()`, and `dismissUnique()`, with native lifecycle-aware deduplication across visible, queued, stacked, and persistent states on Android and iOS. +- JavaScript parity for unique toast creation, updates, dismissal, and bridge-resolved UUIDs. - Native `snap`, `pop`, `reveal`, and `bounce` animations with independent `auto`, `left`, `right`, `top`, and `bottom` direction control. - Determinate `progress()` and indeterminate `loading()` APIs with smooth sparse updates on Android and iOS. - Laravel-container-backed reusable toast presets through `Toast::definePreset()` and `Toast::preset()`. diff --git a/README.md b/README.md index ad2f342..a493b06 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ ToastKit renders toasts as native overlays — Jetpack Compose on Android, Swift - **Queue strategy** — FIFO, one toast at a time. - **Stack strategy** — up to `maxVisible` toasts on screen with FIFO overflow. - **Live updates** — change a visible or queued toast's message, variant, icon, style, or timer without creating a new toast. +- **Unique toasts** — suppress duplicate work by a stable semantic key across visible, queued, stacked, and persistent toasts. - **Idempotent dismissal** — dismiss by ID or dismiss everything. - **Events** — `ToastShown`, `ToastDismissed`, and `ToastActionPressed`. - **JavaScript API** — a fluent `Toast` API for web-facing apps. @@ -223,6 +224,49 @@ Toast::update($id) ->show(); ``` +## Unique Toasts + +Use `unique()` when one logical operation should have at most one active toast, even if the toast is currently visible, queued, stacked, or persistent: + +```php +$id = Toast::info('Syncing contacts...') + ->unique('contacts-sync') + ->persistent() + ->show(); +``` + +The key identifies the operation, not the presentation. ToastKit does not deduplicate ordinary toasts by message. Showing the same unique key again is ignored: it does not replace or update the original toast, reset its timer, move it in a queue, or emit another `ToastShown` event. `show()` returns the original toast's UUID, so callers can safely keep using the result: + +```php +$originalId = Toast::info('Syncing...')->unique('contacts-sync')->show(); +$sameId = Toast::success('Duplicate request')->unique('contacts-sync')->show(); + +// $sameId === $originalId; the original toast is unchanged. +``` + +Update or dismiss the active toast without retaining its UUID: + +```php +Toast::updateUnique('contacts-sync') + ->success() + ->message('Contacts synced') + ->duration(2000) + ->show(); + +Toast::dismissUnique('contacts-sync'); +``` + +A key remains reserved until the toast has fully left the screen, including its exit animation. It is then reusable after every terminal path: timeout, swipe, action, close/programmatic dismissal, replacement, queue overflow, or `dismissAll()`. Updating a toast never changes its unique key. Calling `updateUnique()` or `dismissUnique()` for a key that is not active throws `ToastKitException`. + +Unique keys also work with presets. Put the operation-specific key on each fresh builder so the preset itself remains reusable: + +```php +Toast::preset('syncing') + ->unique('contacts-sync') + ->message('Syncing contacts...') + ->show(); +``` + Updates are sparse: - The toast keeps its original ID. @@ -557,14 +601,33 @@ await Toast.dismiss(id); await Toast.dismissAll(); ``` +Unique toasts have the same semantics in JavaScript: + +```js +const id = await Toast.info('Syncing contacts...') + .unique('contacts-sync') + .persistent() + .show(); + +await Toast.updateUnique('contacts-sync') + .success() + .message('Contacts synced') + .duration(2000) + .show(); + +await Toast.dismissUnique('contacts-sync'); +``` + Raw bridge functions are also exported: ```js -import { Show, Update, Dismiss, DismissAll } from './resources/js'; +import { Show, Update, UpdateUnique, Dismiss, DismissUnique, DismissAll } from './resources/js'; await Show({ id: 'one', message: 'Hello' }); await Update('one', { message: 'Done' }); +await UpdateUnique('contacts-sync', { message: 'Done' }); await Dismiss('one'); +await DismissUnique('contacts-sync'); await DismissAll(); ``` @@ -814,7 +877,9 @@ Native::test(ProfileScreen::class) | `Toast::definePreset(string $name, Closure $preset)` | Define or replace a reusable PHP preset. | | `Toast::preset(string $name)` | Create a fresh builder from a preset. | | `Toast::update(string $id)` | Start building an update for an existing toast. | +| `Toast::updateUnique(string $key)` | Start building an update resolved by an active unique key. | | `Toast::dismiss(string $id)` | Dismiss a toast by ID. | +| `Toast::dismissUnique(string $key)` | Dismiss the active toast resolved by a unique key. | | `Toast::dismissAll()` | Dismiss all active and queued toasts. | ### `PendingToast` builder @@ -824,6 +889,7 @@ Native::test(ProfileScreen::class) | Method | Description | | --- | --- | | `id(string $id)` | Set a custom ID instead of the generated UUID. | +| `unique(string $key)` | Reserve a semantic key and suppress duplicates until the toast fully exits. | | `message(string $message)` | Set the message text (required). | | `title(?string $title)` | Set or clear an optional title. | | `text(?string $font = null, $size = null, $weight = null, $align = null, ?bool $italic = null)` | Configure message typography. See [Typography](#typography). | @@ -856,7 +922,7 @@ Native::test(ProfileScreen::class) ### `PendingToastUpdate` builder -`Toast::update($id)` returns a `PendingToastUpdate`. It exposes the same configuration methods as `PendingToast` — except `id()`, since the ID is fixed — plus a `show()` method that applies the update. Only properties you explicitly set are sent; everything else is preserved. +`Toast::update($id)` and `Toast::updateUnique($key)` return a `PendingToastUpdate`. It exposes the same configuration methods as `PendingToast` — except `id()` and `unique()`, since identity is fixed — plus a `show()` method that applies the update. Only properties you explicitly set are sent; everything else is preserved. ### Enums diff --git a/nativephp.json b/nativephp.json index 28ecaf3..237441d 100644 --- a/nativephp.json +++ b/nativephp.json @@ -38,12 +38,24 @@ "ios": "ToastKitFunctions.Update", "description": "Updates an existing toast by ID." }, + { + "name": "ToastKit.UpdateUnique", + "android": "com.victorycodedev.plugins.toastkit.ToastKitFunctions.UpdateUnique", + "ios": "ToastKitFunctions.UpdateUnique", + "description": "Updates an existing toast by its unique key." + }, { "name": "ToastKit.Dismiss", "android": "com.victorycodedev.plugins.toastkit.ToastKitFunctions.Dismiss", "ios": "ToastKitFunctions.Dismiss", "description": "Dismisses an existing toast by ID." }, + { + "name": "ToastKit.DismissUnique", + "android": "com.victorycodedev.plugins.toastkit.ToastKitFunctions.DismissUnique", + "ios": "ToastKitFunctions.DismissUnique", + "description": "Dismisses an existing toast by its unique key." + }, { "name": "ToastKit.DismissAll", "android": "com.victorycodedev.plugins.toastkit.ToastKitFunctions.DismissAll", diff --git a/resources/android/src/com/victorycodedev/plugins/toastkit/ToastKitFunctions.kt b/resources/android/src/com/victorycodedev/plugins/toastkit/ToastKitFunctions.kt index c8b6b94..c15452c 100644 --- a/resources/android/src/com/victorycodedev/plugins/toastkit/ToastKitFunctions.kt +++ b/resources/android/src/com/victorycodedev/plugins/toastkit/ToastKitFunctions.kt @@ -14,8 +14,17 @@ object ToastKitFunctions { ToastKitEventDispatcher.bind(activity) ToastKitHostInstaller.ensureAttached(activity) val toast = ToastKitBridgeNormalizer.show(parameters) - ToastKitManager.show(toast) - mapOf("id" to toast.id, "accepted" to true) + val result = ToastKitManager.show(toast) + mapOf("id" to result.id, "accepted" to result.accepted) + } + } + + class UpdateUnique(private val activity: FragmentActivity) : BridgeFunction { + override fun execute(parameters: Map): Map = respond { + ToastKitEventDispatcher.bind(activity) + val (key, changes) = ToastKitBridgeNormalizer.updateUnique(parameters) + val id = ToastKitManager.updateUnique(key, changes) + mapOf("id" to id, "unique_key" to key, "accepted" to true) } } @@ -28,6 +37,15 @@ object ToastKitFunctions { } } + class DismissUnique(private val activity: FragmentActivity) : BridgeFunction { + override fun execute(parameters: Map): Map = respond { + ToastKitEventDispatcher.bind(activity) + val key = ToastKitBridgeNormalizer.uniqueKey(parameters) + val id = ToastKitManager.dismissUnique(key) + mapOf("id" to id, "unique_key" to key, "accepted" to true) + } + } + class Dismiss(private val activity: FragmentActivity) : BridgeFunction { override fun execute(parameters: Map): Map = respond { ToastKitEventDispatcher.bind(activity) diff --git a/resources/android/src/com/victorycodedev/plugins/toastkit/bridge/ToastKitBridgeNormalizer.kt b/resources/android/src/com/victorycodedev/plugins/toastkit/bridge/ToastKitBridgeNormalizer.kt index 756770b..e409548 100644 --- a/resources/android/src/com/victorycodedev/plugins/toastkit/bridge/ToastKitBridgeNormalizer.kt +++ b/resources/android/src/com/victorycodedev/plugins/toastkit/bridge/ToastKitBridgeNormalizer.kt @@ -20,7 +20,15 @@ internal object ToastKitBridgeNormalizer { return id to changes } + fun updateUnique(parameters: Map): Pair> { + val key = requiredString(parameters, "unique_key") + val changes = stringMap(parameters["changes"]) ?: throw ToastKitInputException("changes must be an object") + requireInput(changes.isNotEmpty(), "changes must not be empty") + return key to changes + } + fun id(parameters: Map): String = requiredString(parameters, "id") + fun uniqueKey(parameters: Map): String = requiredString(parameters, "unique_key") fun applyChanges(current: ToastKitConfiguration, changes: Map): ToastKitConfiguration { var next = current @@ -59,6 +67,7 @@ internal object ToastKitBridgeNormalizer { val persistent = values["persistent"]?.let { boolean(it, "persistent") } ?: false return ToastKitConfiguration( id = requiredString(values, "id"), + uniqueKey = nullableString(values["unique_key"], "unique_key"), message = requiredString(values, "message"), title = nullableString(values["title"], "title"), variant = variant(values["variant"] ?: "neutral"), diff --git a/resources/android/src/com/victorycodedev/plugins/toastkit/manager/ToastKitManager.kt b/resources/android/src/com/victorycodedev/plugins/toastkit/manager/ToastKitManager.kt index ba8fda5..31143b8 100644 --- a/resources/android/src/com/victorycodedev/plugins/toastkit/manager/ToastKitManager.kt +++ b/resources/android/src/com/victorycodedev/plugins/toastkit/manager/ToastKitManager.kt @@ -7,6 +7,8 @@ import com.victorycodedev.plugins.toastkit.bridge.ToastKitBridgeNormalizer import com.victorycodedev.plugins.toastkit.bridge.ToastKitEventDispatcher import com.victorycodedev.plugins.toastkit.model.* +internal data class ToastKitShowResult(val id: String, val accepted: Boolean) + internal object ToastKitManager { val visible = mutableStateListOf() val rendered = mutableStateListOf() @@ -15,11 +17,25 @@ internal object ToastKitManager { private val waiting = ArrayDeque() private val timers = mutableMapOf() private val main = Handler(Looper.getMainLooper()) + private val identityLock = Any() + private val activeIds = mutableSetOf() + private val uniqueToId = mutableMapOf() - fun show(configuration: ToastKitConfiguration) = onMain { - if (states.containsKey(configuration.id)) return@onMain - states[configuration.id] = ToastKitState(configuration) - if (canAdmit(configuration)) admit(configuration.id) else waiting.addLast(configuration.id) + fun show(configuration: ToastKitConfiguration): ToastKitShowResult { + val result = synchronized(identityLock) { + configuration.uniqueKey?.let { key -> + uniqueToId[key]?.let { return@synchronized ToastKitShowResult(it, false) } + } + if (!activeIds.add(configuration.id)) return@synchronized ToastKitShowResult(configuration.id, false) + configuration.uniqueKey?.let { uniqueToId[it] = configuration.id } + ToastKitShowResult(configuration.id, true) + } + if (!result.accepted) return result + onMain { + states[configuration.id] = ToastKitState(configuration) + if (canAdmit(configuration)) admit(configuration.id) else waiting.addLast(configuration.id) + } + return result } fun update(id: String, changes: Map) = onMain { @@ -33,8 +49,20 @@ internal object ToastKitManager { } } + fun updateUnique(key: String, changes: Map): String { + val id = resolveUnique(key) + update(id, changes) + return id + } + fun dismiss(id: String, reason: String = "programmatic") = onMain { terminate(id, reason) } + fun dismissUnique(key: String): String { + val id = resolveUnique(key) + dismiss(id) + return id + } + fun dismissAll() = onMain { states.keys.toList().forEach { terminate(it, "programmatic", promote = false) } waiting.clear() @@ -84,7 +112,7 @@ internal object ToastKitManager { waiting.remove(id) visible.removeAll { it.id == id } exiting.add(id) - val exitDuration = when (state.configuration.animation) { + val exitDuration = if (!state.visible) 0L else when (state.configuration.animation) { ToastKitAnimation.SPRING, ToastKitAnimation.BOUNCE -> 450L ToastKitAnimation.REVEAL -> 280L else -> 260L @@ -92,12 +120,24 @@ internal object ToastKitManager { main.postDelayed({ rendered.removeAll { it.id == id } exiting.remove(id) + releaseIdentity(state.configuration) if (promote) promote() }, exitDuration) states.remove(id) ToastKitEventDispatcher.dismissed(id, reason) } + private fun resolveUnique(key: String): String = synchronized(identityLock) { + uniqueToId[key] ?: throw IllegalArgumentException("Unique toast [$key] is not active.") + } + + private fun releaseIdentity(configuration: ToastKitConfiguration) = synchronized(identityLock) { + activeIds.remove(configuration.id) + configuration.uniqueKey?.let { key -> + if (uniqueToId[key] == configuration.id) uniqueToId.remove(key) + } + } + private fun promote() { var progressed: Boolean do { diff --git a/resources/android/src/com/victorycodedev/plugins/toastkit/model/ToastKitModels.kt b/resources/android/src/com/victorycodedev/plugins/toastkit/model/ToastKitModels.kt index 96de19b..9fdd3bb 100644 --- a/resources/android/src/com/victorycodedev/plugins/toastkit/model/ToastKitModels.kt +++ b/resources/android/src/com/victorycodedev/plugins/toastkit/model/ToastKitModels.kt @@ -31,6 +31,7 @@ internal data class ToastKitStyle( internal data class ToastKitConfiguration( val id: String, + val uniqueKey: String?, val message: String, val title: String?, val variant: String, diff --git a/resources/ios/Sources/Bridge/ToastKitBridgeNormalizer.swift b/resources/ios/Sources/Bridge/ToastKitBridgeNormalizer.swift index 1e797d5..6965dff 100644 --- a/resources/ios/Sources/Bridge/ToastKitBridgeNormalizer.swift +++ b/resources/ios/Sources/Bridge/ToastKitBridgeNormalizer.swift @@ -20,7 +20,16 @@ enum ToastKitBridgeNormalizer { return (id, changes) } + static func updateUnique(_ values: [String: Any]) throws -> (String, [String: Any]) { + let key = try requiredString(values, "unique_key") + guard let changes = values["changes"] as? [String: Any], !changes.isEmpty else { + throw ToastKitInputError.invalid("changes must be a non-empty object") + } + return (key, changes) + } + static func id(_ values: [String: Any]) throws -> String { try requiredString(values, "id") } + static func uniqueKey(_ values: [String: Any]) throws -> String { try requiredString(values, "unique_key") } static func applying(_ changes: [String: Any], to current: ToastKitConfiguration) throws -> ToastKitConfiguration { var next = current @@ -59,6 +68,7 @@ enum ToastKitBridgeNormalizer { let persistent = try values["persistent"].map { try boolean($0, "persistent") } ?? false return ToastKitConfiguration( id: try requiredString(values, "id"), + uniqueKey: try nullableString(values["unique_key"], "unique_key"), message: try requiredString(values, "message"), title: try nullableString(values["title"], "title"), variant: variantName, diff --git a/resources/ios/Sources/Manager/ToastKitManager.swift b/resources/ios/Sources/Manager/ToastKitManager.swift index b668105..504a62f 100644 --- a/resources/ios/Sources/Manager/ToastKitManager.swift +++ b/resources/ios/Sources/Manager/ToastKitManager.swift @@ -90,9 +90,26 @@ final class ToastKitManager: ObservableObject { withAnimation(animation(for: state.configuration)) { visible.removeAll { $0.id == id } } states.removeValue(forKey: id) ToastKitEventDispatcher.dismissed(id, reason: reason) + releaseIdentity(for: state) if shouldPromote { promote() } } + private func releaseIdentity(for state: ToastKitState) { + guard state.visible else { + ToastKitUniqueRegistry.shared.release(state.configuration) + return + } + let milliseconds = switch state.configuration.animation { + case .spring, .bounce: 450 + case .reveal: 280 + default: 260 + } + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(milliseconds)) + ToastKitUniqueRegistry.shared.release(state.configuration) + } + } + private func promote() { while let id = waiting.first { guard let state = states[id], !state.terminated else { waiting.removeFirst(); continue } diff --git a/resources/ios/Sources/Manager/ToastKitUniqueRegistry.swift b/resources/ios/Sources/Manager/ToastKitUniqueRegistry.swift new file mode 100644 index 0000000..3d4b1c4 --- /dev/null +++ b/resources/ios/Sources/Manager/ToastKitUniqueRegistry.swift @@ -0,0 +1,46 @@ +import Foundation + +struct ToastKitShowResult { + let id: String + let accepted: Bool +} + +final class ToastKitUniqueRegistry: @unchecked Sendable { + static let shared = ToastKitUniqueRegistry() + + private let lock = NSLock() + private var activeIds: Set = [] + private var uniqueToId: [String: String] = [:] + + func reserve(_ configuration: ToastKitConfiguration) -> ToastKitShowResult { + lock.lock() + defer { lock.unlock() } + + if let key = configuration.uniqueKey, let existing = uniqueToId[key] { + return ToastKitShowResult(id: existing, accepted: false) + } + guard activeIds.insert(configuration.id).inserted else { + return ToastKitShowResult(id: configuration.id, accepted: false) + } + if let key = configuration.uniqueKey { uniqueToId[key] = configuration.id } + return ToastKitShowResult(id: configuration.id, accepted: true) + } + + func resolve(_ key: String) throws -> String { + lock.lock() + defer { lock.unlock() } + guard let id = uniqueToId[key] else { + throw ToastKitInputError.invalid("Unique toast [\(key)] is not active.") + } + return id + } + + func release(_ configuration: ToastKitConfiguration) { + lock.lock() + defer { lock.unlock() } + activeIds.remove(configuration.id) + if let key = configuration.uniqueKey, uniqueToId[key] == configuration.id { + uniqueToId.removeValue(forKey: key) + } + } +} diff --git a/resources/ios/Sources/Model/ToastKitModels.swift b/resources/ios/Sources/Model/ToastKitModels.swift index 70e0b15..bb2cf61 100644 --- a/resources/ios/Sources/Model/ToastKitModels.swift +++ b/resources/ios/Sources/Model/ToastKitModels.swift @@ -38,6 +38,7 @@ struct ToastKitStyleConfiguration { struct ToastKitConfiguration: Identifiable { let id: String + let uniqueKey: String? var message: String var title: String? var variant: String diff --git a/resources/ios/Sources/ToastKitFunctions.swift b/resources/ios/Sources/ToastKitFunctions.swift index 9bfe955..43d5966 100644 --- a/resources/ios/Sources/ToastKitFunctions.swift +++ b/resources/ios/Sources/ToastKitFunctions.swift @@ -5,8 +5,22 @@ enum ToastKitFunctions { func execute(parameters: [String: Any]) throws -> [String: Any] { do { let toast = try ToastKitBridgeNormalizer.show(parameters) - Task { @MainActor in ToastKitManager.shared.show(toast) } - return BridgeResponse.success(data: ["id": toast.id, "accepted": true]) + let result = ToastKitUniqueRegistry.shared.reserve(toast) + if result.accepted { + Task { @MainActor in ToastKitManager.shared.show(toast) } + } + return BridgeResponse.success(data: ["id": result.id, "accepted": result.accepted]) + } catch { return BridgeResponse.error(code: "TOASTKIT_INVALID_ARGUMENT", message: ToastKitFunctions.message(for: error)) } + } + } + + final class UpdateUnique: BridgeFunction { + func execute(parameters: [String: Any]) throws -> [String: Any] { + do { + let (key, changes) = try ToastKitBridgeNormalizer.updateUnique(parameters) + let id = try ToastKitUniqueRegistry.shared.resolve(key) + Task { @MainActor in try? ToastKitManager.shared.update(id, changes: changes) } + return BridgeResponse.success(data: ["id": id, "unique_key": key, "accepted": true]) } catch { return BridgeResponse.error(code: "TOASTKIT_INVALID_ARGUMENT", message: ToastKitFunctions.message(for: error)) } } } @@ -21,6 +35,17 @@ enum ToastKitFunctions { } } + final class DismissUnique: BridgeFunction { + func execute(parameters: [String: Any]) throws -> [String: Any] { + do { + let key = try ToastKitBridgeNormalizer.uniqueKey(parameters) + let id = try ToastKitUniqueRegistry.shared.resolve(key) + Task { @MainActor in ToastKitManager.shared.dismiss(id) } + return BridgeResponse.success(data: ["id": id, "unique_key": key, "accepted": true]) + } catch { return BridgeResponse.error(code: "TOASTKIT_INVALID_ARGUMENT", message: ToastKitFunctions.message(for: error)) } + } + } + final class Dismiss: BridgeFunction { func execute(parameters: [String: Any]) throws -> [String: Any] { do { diff --git a/resources/js/PendingToast.js b/resources/js/PendingToast.js index c71fe5a..a49a178 100644 --- a/resources/js/PendingToast.js +++ b/resources/js/PendingToast.js @@ -48,6 +48,10 @@ export class PendingToast extends PendingToastBase { this.toastId = nonEmpty(value, "toast ID"); return this; } + unique(key) { + this.values.unique_key = nonEmpty(key, "unique key"); + return this; + } payload() { if (!this.values.message) throw new TypeError("A toast message is required before show()"); @@ -83,7 +87,7 @@ export class PendingToast extends PendingToastBase { }; } async show() { - await bridgeCall("ToastKit.Show", this.payload()); - return this.toastId; + const result = await bridgeCall("ToastKit.Show", this.payload()); + return result?.id ?? this.toastId; } } diff --git a/resources/js/PendingToastUpdate.js b/resources/js/PendingToastUpdate.js index 50ccab5..1071f54 100644 --- a/resources/js/PendingToastUpdate.js +++ b/resources/js/PendingToastUpdate.js @@ -2,19 +2,26 @@ import { bridgeCall } from "./bridge.js"; import { PendingToastBase } from "./PendingToastBase.js"; import { nonEmpty } from "./validation.js"; export class PendingToastUpdate extends PendingToastBase { - constructor(id) { + constructor(id, { unique = false } = {}) { super({}); - this.toastId = nonEmpty(id, "toast ID"); + this.target = nonEmpty(id, unique ? "unique key" : "toast ID"); + this.usesUniqueKey = unique; } payload() { if (!Object.keys(this.values).length) throw new TypeError( "At least one toast change is required before show()", ); - return { id: this.toastId, changes: structuredClone(this.values) }; + return { + [this.usesUniqueKey ? "unique_key" : "id"]: this.target, + changes: structuredClone(this.values), + }; } async show() { - await bridgeCall("ToastKit.Update", this.payload()); - return this.toastId; + const result = await bridgeCall( + this.usesUniqueKey ? "ToastKit.UpdateUnique" : "ToastKit.Update", + this.payload(), + ); + return result?.id ?? this.target; } } diff --git a/resources/js/Toast.js b/resources/js/Toast.js index aca24a8..1c3fde1 100644 --- a/resources/js/Toast.js +++ b/resources/js/Toast.js @@ -10,7 +10,12 @@ export const Toast = { info: (message) => new PendingToast(message).info(), neutral: (message) => new PendingToast(message).neutral(), update: (id) => new PendingToastUpdate(id), + updateUnique: (key) => new PendingToastUpdate(key, { unique: true }), dismiss: (id) => bridgeCall("ToastKit.Dismiss", { id: nonEmpty(id, "toast ID") }), + dismissUnique: (key) => + bridgeCall("ToastKit.DismissUnique", { + unique_key: nonEmpty(key, "unique key"), + }), dismissAll: () => bridgeCall("ToastKit.DismissAll"), }; diff --git a/resources/js/bridge.js b/resources/js/bridge.js index 0ae593b..c4a42fb 100644 --- a/resources/js/bridge.js +++ b/resources/js/bridge.js @@ -10,8 +10,9 @@ export async function bridgeCall(method, params = {}) { }, body: JSON.stringify({ method, params }), }); - if (!response.ok) throw new Error(`Native bridge HTTP ${response.status}`); const result = await response.json(); + if (!response.ok) + throw new Error(result.message || `Native bridge HTTP ${response.status}`); if (result.status === "error") throw new Error(result.message || "Native call failed"); return result.data?.data ?? result.data; diff --git a/resources/js/index.js b/resources/js/index.js index f10ef5a..2ab7786 100644 --- a/resources/js/index.js +++ b/resources/js/index.js @@ -1,6 +1,20 @@ export { Toast } from "./Toast.js"; export { PendingToast } from "./PendingToast.js"; export { PendingToastUpdate } from "./PendingToastUpdate.js"; -export { Show, Update, Dismiss, DismissAll } from "./toastkit.js"; -export { show, update, dismiss, dismissAll } from "./toastkit.js"; +export { + Show, + Update, + UpdateUnique, + Dismiss, + DismissUnique, + DismissAll, +} from "./toastkit.js"; +export { + show, + update, + updateUnique, + dismiss, + dismissUnique, + dismissAll, +} from "./toastkit.js"; export { Toast as default } from "./Toast.js"; diff --git a/resources/js/tests/toastkit.test.js b/resources/js/tests/toastkit.test.js index aaa52c2..afee5a2 100644 --- a/resources/js/tests/toastkit.test.js +++ b/resources/js/tests/toastkit.test.js @@ -7,11 +7,15 @@ import { PendingToastUpdate, Show, Update, + UpdateUnique, Dismiss, + DismissUnique, DismissAll, show, update, + updateUnique, dismiss, + dismissUnique, dismissAll, } from "../index.js"; @@ -151,6 +155,21 @@ test("show() sends the full payload to the bridge", async () => { resetFetch(); }); +test("unique() sends explicit identity and returns the native resolved UUID", async () => { + const calls = []; + globalThis.fetch = async (_url, options) => { + calls.push(JSON.parse(options.body)); + return { + ok: true, + json: async () => ({ status: "success", data: { id: "existing-id", accepted: false } }), + }; + }; + const id = await Toast.error("Offline").unique("network-status").show(); + assert.equal(id, "existing-id"); + assert.equal(calls[0].params.unique_key, "network-status"); + resetFetch(); +}); + test("update is sparse and retains the ID", async () => { const calls = []; fakeBridge(calls); @@ -175,6 +194,24 @@ test("dismiss and dismissAll call the native bridge", async () => { resetFetch(); }); +test("semantic update and dismiss use the unique bridge contracts", async () => { + const calls = []; + fakeBridge(calls); + await Toast.updateUnique("network-status").message("Online").show(); + await Toast.dismissUnique("network-status"); + assert.deepEqual(calls, [ + { + method: "ToastKit.UpdateUnique", + params: { unique_key: "network-status", changes: { message: "Online" } }, + }, + { + method: "ToastKit.DismissUnique", + params: { unique_key: "network-status" }, + }, + ]); + resetFetch(); +}); + test("builders isolate their state", () => { const a = Toast.success("A").position("top"); const b = Toast.error("B"); @@ -188,27 +225,38 @@ test("every bridge function has a named export", async () => { fakeBridge(calls); await Show({ id: "one", message: "Hi" }); await Update("two", { message: "Bye" }); + await UpdateUnique("status", { message: "Ready" }); await Dismiss("three"); + await DismissUnique("status"); await DismissAll(); assert.deepEqual( calls.map((x) => x.method), [ "ToastKit.Show", "ToastKit.Update", + "ToastKit.UpdateUnique", "ToastKit.Dismiss", + "ToastKit.DismissUnique", "ToastKit.DismissAll", ], ); assert.deepEqual(calls[0].params, { id: "one", message: "Hi" }); assert.deepEqual(calls[1].params, { id: "two", changes: { message: "Bye" } }); - assert.deepEqual(calls[2].params, { id: "three" }); + assert.deepEqual(calls[2].params, { + unique_key: "status", + changes: { message: "Ready" }, + }); + assert.deepEqual(calls[3].params, { id: "three" }); + assert.deepEqual(calls[4].params, { unique_key: "status" }); resetFetch(); }); test("lowercase aliases match the PascalCase exports", () => { assert.equal(show, Show); assert.equal(update, Update); + assert.equal(updateUnique, UpdateUnique); assert.equal(dismiss, Dismiss); + assert.equal(dismissUnique, DismissUnique); assert.equal(dismissAll, DismissAll); }); @@ -228,7 +276,9 @@ test("validation rejects bad input", () => { assert.throws(() => Toast.make("x").action("", "retry"), /action label/); assert.throws(() => Toast.make("x").icon(), /icon name or platform override/); assert.throws(() => Toast.make("x").id(""), /toast ID/); + assert.throws(() => Toast.make("x").unique(""), /unique key/); assert.throws(() => Toast.update(""), /toast ID/); + assert.throws(() => Toast.updateUnique(""), /unique key/); assert.throws(() => new PendingToastUpdate("one").payload(), /change/); }); diff --git a/resources/js/toastkit.js b/resources/js/toastkit.js index 784d222..75d82d2 100644 --- a/resources/js/toastkit.js +++ b/resources/js/toastkit.js @@ -3,13 +3,26 @@ import { bridgeCall } from "./bridge.js"; export const Show = (payload = {}) => bridgeCall("ToastKit.Show", payload); export const Update = (id, changes = {}) => bridgeCall("ToastKit.Update", { id, changes }); +export const UpdateUnique = (key, changes = {}) => + bridgeCall("ToastKit.UpdateUnique", { unique_key: key, changes }); export const Dismiss = (id) => bridgeCall("ToastKit.Dismiss", { id }); +export const DismissUnique = (key) => + bridgeCall("ToastKit.DismissUnique", { unique_key: key }); export const DismissAll = () => bridgeCall("ToastKit.DismissAll"); export const show = Show; export const update = Update; +export const updateUnique = UpdateUnique; export const dismiss = Dismiss; +export const dismissUnique = DismissUnique; export const dismissAll = DismissAll; -export const toastKit = { Show, Update, Dismiss, DismissAll }; +export const toastKit = { + Show, + Update, + UpdateUnique, + Dismiss, + DismissUnique, + DismissAll, +}; export default toastKit; diff --git a/src/Concerns/ConfiguresToast.php b/src/Concerns/ConfiguresToast.php index e79a768..0885714 100644 --- a/src/Concerns/ConfiguresToast.php +++ b/src/Concerns/ConfiguresToast.php @@ -236,4 +236,4 @@ private function nullableNonEmpty(?string $value, string $label): ?string if ($value !== null && trim($value) === '') throw new InvalidToastConfigurationException("{$label} must not be empty."); return $value; } -} \ No newline at end of file +} diff --git a/src/Facades/Toast.php b/src/Facades/Toast.php index d3d6ab1..8178f96 100644 --- a/src/Facades/Toast.php +++ b/src/Facades/Toast.php @@ -16,10 +16,15 @@ * @method static void definePreset(string $name, \Closure $preset) * @method static PendingToast preset(string $name) * @method static PendingToastUpdate update(string $id) + * @method static PendingToastUpdate updateUnique(string $key) * @method static void dismiss(string $id) + * @method static void dismissUnique(string $key) * @method static void dismissAll() */ class Toast extends Facade { - protected static function getFacadeAccessor(): string { return 'toastkit'; } + protected static function getFacadeAccessor(): string + { + return 'toastkit'; + } } diff --git a/src/PendingToast.php b/src/PendingToast.php index e7080f7..1db9e67 100644 --- a/src/PendingToast.php +++ b/src/PendingToast.php @@ -25,6 +25,13 @@ public function id(string $id): static return $this; } + public function unique(string $key): static + { + $this->toastKit->assertUniqueKey($key); + $this->options['unique_key'] = $key; + return $this; + } + public function show(): string { if (! isset($this->options['message'])) throw new InvalidToastConfigurationException('A toast message is required before show().'); @@ -36,8 +43,7 @@ public function show(): string $this->options, ['id' => $this->id, 'contract_version' => 1] ); - $this->toastKit->show($payload); - return $this->id; + return $this->toastKit->show($payload) ?? $this->id; } /** @internal */ @@ -116,4 +122,4 @@ private static function uuid(): string substr($hex, 20), ); } -} \ No newline at end of file +} diff --git a/src/PendingToastUpdate.php b/src/PendingToastUpdate.php index 732e81f..c124479 100644 --- a/src/PendingToastUpdate.php +++ b/src/PendingToastUpdate.php @@ -11,19 +11,29 @@ class PendingToastUpdate private array $changes = []; - public function __construct(private readonly ToastKit $toastKit, private readonly string $id) {} + public function __construct( + private readonly ToastKit $toastKit, + private readonly string $target, + private readonly bool $usesUniqueKey = false, + ) {} public function show(): string { if ($this->changes === []) throw new InvalidToastConfigurationException('At least one toast change is required before show().'); - $this->toastKit->applyUpdate($this->id, $this->changes); - return $this->id; + if ($this->usesUniqueKey) { + return $this->toastKit->applyUniqueUpdate($this->target, $this->changes) ?? $this->target; + } + $this->toastKit->applyUpdate($this->target, $this->changes); + return $this->target; } /** @internal */ public function payload(): array { - return ['id' => $this->id, 'changes' => $this->changes]; + return [ + $this->usesUniqueKey ? 'unique_key' : 'id' => $this->target, + 'changes' => $this->changes, + ]; } protected function put(string $key, mixed $value): static diff --git a/src/ToastKit.php b/src/ToastKit.php index afaa707..4fbab85 100644 --- a/src/ToastKit.php +++ b/src/ToastKit.php @@ -4,6 +4,7 @@ use Closure; use Victorycodedev\ToastKit\Exceptions\InvalidToastConfigurationException; +use Victorycodedev\ToastKit\Exceptions\ToastKitException; class ToastKit { @@ -53,6 +54,12 @@ public function update(string $id): PendingToastUpdate return new PendingToastUpdate($this, $id); } + public function updateUnique(string $key): PendingToastUpdate + { + $this->assertUniqueKey($key); + return new PendingToastUpdate($this, $key, usesUniqueKey: true); + } + public function dismiss(string $id): void { $this->assertIdentifier($id); @@ -64,10 +71,16 @@ public function dismissAll(): void $this->call('ToastKit.DismissAll', []); } + public function dismissUnique(string $key): void + { + $this->assertUniqueKey($key); + $this->call('ToastKit.DismissUnique', ['unique_key' => $key]); + } + /** @internal */ - public function show(array $payload): void + public function show(array $payload): ?string { - $this->call('ToastKit.Show', $payload); + return $this->responseId($this->call('ToastKit.Show', $payload)); } /** @internal */ @@ -76,18 +89,53 @@ public function applyUpdate(string $id, array $changes): void $this->call('ToastKit.Update', ['id' => $id, 'changes' => $changes]); } + /** @internal */ + public function applyUniqueUpdate(string $key, array $changes): ?string + { + return $this->responseId($this->call('ToastKit.UpdateUnique', [ + 'unique_key' => $key, + 'changes' => $changes, + ])); + } + private function call(string $method, array $parameters): mixed { - if ($this->bridge !== null) return ($this->bridge)($method, $parameters); + if ($this->bridge !== null) return $this->unwrapResponse(($this->bridge)($method, $parameters)); if (function_exists('nativephp_call')) { - return nativephp_call($method, json_encode($parameters, JSON_THROW_ON_ERROR)); + return $this->unwrapResponse(nativephp_call($method, json_encode($parameters, JSON_THROW_ON_ERROR))); } return null; } + private function unwrapResponse(mixed $response): mixed + { + if (is_string($response)) { + $decoded = json_decode($response, true); + if (json_last_error() === JSON_ERROR_NONE) $response = $decoded; + } + if (! is_array($response)) return $response; + if (($response['status'] ?? null) === 'error') { + throw new ToastKitException($response['message'] ?? 'The native ToastKit operation failed.'); + } + return $response['data'] ?? $response; + } + + private function responseId(mixed $response): ?string + { + if (! is_array($response)) return null; + $data = isset($response['data']) && is_array($response['data']) ? $response['data'] : $response; + return isset($data['id']) && is_string($data['id']) ? $data['id'] : null; + } + /** @internal */ public function assertIdentifier(string $id): void { if (trim($id) === '') throw new InvalidToastConfigurationException('Toast IDs must not be empty.'); } + + /** @internal */ + public function assertUniqueKey(string $key): void + { + if (trim($key) === '') throw new InvalidToastConfigurationException('Toast unique keys must not be empty.'); + } } diff --git a/tests/Unit/BridgeTest.php b/tests/Unit/BridgeTest.php index 9fc0bd7..3221683 100644 --- a/tests/Unit/BridgeTest.php +++ b/tests/Unit/BridgeTest.php @@ -91,3 +91,19 @@ expect($this->calls[0])->toBe(['ToastKit.DismissAll', []]); }); + +it('sends unique show, update, and dismiss contracts', function () { + $this->kit->error('Offline')->unique('network-status')->show(); + $this->kit->updateUnique('network-status')->message('Online')->show(); + $this->kit->dismissUnique('network-status'); + + expect($this->calls[0][1]['unique_key'])->toBe('network-status') + ->and($this->calls[1])->toBe([ + 'ToastKit.UpdateUnique', + ['unique_key' => 'network-status', 'changes' => ['message' => 'Online']], + ]) + ->and($this->calls[2])->toBe([ + 'ToastKit.DismissUnique', + ['unique_key' => 'network-status'], + ]); +}); diff --git a/tests/Unit/UniqueToastTest.php b/tests/Unit/UniqueToastTest.php new file mode 100644 index 0000000..51ec02f --- /dev/null +++ b/tests/Unit/UniqueToastTest.php @@ -0,0 +1,172 @@ +native = new class + { + public array $active = []; + public array $unique = []; + public array $shown = []; + public array $updates = []; + + public function call(string $method, array $payload): array + { + return match ($method) { + 'ToastKit.Show' => $this->show($payload), + 'ToastKit.UpdateUnique' => $this->updateUnique($payload), + 'ToastKit.DismissUnique' => $this->dismissUnique($payload), + 'ToastKit.Dismiss' => $this->dismiss($payload['id']), + 'ToastKit.DismissAll' => $this->dismissAll(), + default => ['accepted' => true], + }; + } + + public function terminate(string $id, string $reason): void + { + if (! isset($this->active[$id])) return; + $key = $this->active[$id]['unique_key'] ?? null; + unset($this->active[$id]); + if ($key !== null && ($this->unique[$key] ?? null) === $id) unset($this->unique[$key]); + } + + private function show(array $payload): array + { + $key = $payload['unique_key'] ?? null; + if ($key !== null && isset($this->unique[$key])) { + return ['id' => $this->unique[$key], 'accepted' => false]; + } + $this->active[$payload['id']] = $payload; + if ($key !== null) $this->unique[$key] = $payload['id']; + $this->shown[] = $payload['id']; + return ['id' => $payload['id'], 'accepted' => true]; + } + + private function updateUnique(array $payload): array + { + $key = $payload['unique_key']; + if (! isset($this->unique[$key])) return $this->missing($key); + $id = $this->unique[$key]; + $this->active[$id] = array_replace_recursive($this->active[$id], $payload['changes']); + $this->updates[] = ['id' => $id, 'changes' => $payload['changes']]; + return ['id' => $id, 'unique_key' => $key, 'accepted' => true]; + } + + private function dismissUnique(array $payload): array + { + $key = $payload['unique_key']; + if (! isset($this->unique[$key])) return $this->missing($key); + $id = $this->unique[$key]; + $this->terminate($id, 'programmatic'); + return ['id' => $id, 'unique_key' => $key, 'accepted' => true]; + } + + private function dismiss(string $id): array + { + $this->terminate($id, 'programmatic'); + return ['id' => $id, 'accepted' => true]; + } + + private function dismissAll(): array + { + foreach (array_keys($this->active) as $id) $this->terminate($id, 'programmatic'); + return ['accepted' => true]; + } + + private function missing(string $key): array + { + return ['status' => 'error', 'message' => "Unique toast [{$key}] is not active."]; + } + }; + + $native = $this->native; + $this->uniqueKit = new ToastKit(fn(string $method, array $payload) => $native->call($method, $payload)); +}); + +it('allows repeated ordinary toasts', function () { + $this->uniqueKit->success('Saved')->show(); + $this->uniqueKit->success('Saved')->show(); + expect($this->native->active)->toHaveCount(2); +}); + +it('ignores an active duplicate and returns the original UUID', function () { + $first = $this->uniqueKit->error('Offline')->unique('network-status')->show(); + $second = $this->uniqueKit->error('Still offline')->unique('network-status')->show(); + + expect($second)->toBe($first) + ->and($this->native->active)->toHaveCount(1) + ->and($this->native->shown)->toBe([$first]) + ->and($this->native->active[$first]['message'])->toBe('Offline'); +}); + +it('allows different unique keys in a stack', function () { + $this->uniqueKit->error('Offline')->unique('network')->stack()->show(); + $this->uniqueKit->warning('Reconnecting')->unique('websocket')->stack()->show(); + expect($this->native->active)->toHaveCount(2); +}); + +it('reserves unique keys for queued and stacked toasts', function (string $strategy) { + $this->uniqueKit->info('First')->unique('status')->strategy($strategy)->show(); + $this->uniqueKit->info('Second')->unique('status')->strategy($strategy)->show(); + expect($this->native->active)->toHaveCount(1); +})->with(['queue', 'stack']); + +it('releases a unique key after every native terminal path', function (string $reason) { + $first = $this->uniqueKit->info('Active')->unique('status')->show(); + $this->native->terminate($first, $reason); + $second = $this->uniqueKit->info('Active again')->unique('status')->show(); + expect($second)->not->toBe($first)->and($this->native->active)->toHaveCount(1); +})->with(['timeout', 'swipe', 'action', 'programmatic', 'replaced', 'overflow']); + +it('releases unique keys through dismiss and dismissAll', function () { + $first = $this->uniqueKit->info('One')->unique('one')->show(); + $this->uniqueKit->dismiss($first); + $second = $this->uniqueKit->info('One again')->unique('one')->show(); + $this->uniqueKit->info('Two')->unique('two')->show(); + $this->uniqueKit->dismissAll(); + + expect($second)->not->toBe($first)->and($this->native->unique)->toBe([]); + expect($this->uniqueKit->info('After all')->unique('two')->show())->toBeString(); +}); + +it('updates and dismisses the same toast through its semantic key', function () { + $id = $this->uniqueKit->error('Offline')->unique('network')->persistent()->show(); + $updatedId = $this->uniqueKit->updateUnique('network')->success()->message('Online')->duration(2000)->show(); + + expect($updatedId)->toBe($id) + ->and($this->native->active)->toHaveCount(1) + ->and($this->native->active[$id]['unique_key'])->toBe('network') + ->and($this->native->active[$id]['message'])->toBe('Online'); + + expect($this->uniqueKit->error('Duplicate')->unique('network')->show())->toBe($id); + $this->uniqueKit->dismissUnique('network'); + expect($this->native->active)->toBe([]); +}); + +it('throws a package exception when a semantic target is missing', function () { + expect(fn() => $this->uniqueKit->updateUnique('missing')->message('x')->show())->toThrow(ToastKitException::class) + ->and(fn() => $this->uniqueKit->dismissUnique('missing'))->toThrow(ToastKitException::class); +}); + +it('supports unique presets without leaking builder state', function () { + $this->uniqueKit->definePreset('offline', fn(PendingToast $toast) => $toast + ->error()->persistent()->unique('network')); + $this->uniqueKit->definePreset('syncing', fn(PendingToast $toast) => $toast + ->info()->persistent()->unique('sync')); + + $first = $this->uniqueKit->preset('offline')->message('Offline')->show(); + $duplicate = $this->uniqueKit->preset('offline')->message('Offline again')->show(); + $other = $this->uniqueKit->preset('syncing')->message('Syncing')->show(); + + expect($duplicate)->toBe($first)->and($other)->not->toBe($first) + ->and($this->native->active)->toHaveCount(2); +}); + +it('validates unique keys across all PHP entry points', function () { + expect(fn() => $this->uniqueKit->make('x')->unique(' '))->toThrow(InvalidToastConfigurationException::class) + ->and(fn() => $this->uniqueKit->updateUnique(''))->toThrow(InvalidToastConfigurationException::class) + ->and(fn() => $this->uniqueKit->dismissUnique("\t"))->toThrow(InvalidToastConfigurationException::class); +}); From 04ec63a9297467d6ac6e6ed45b14b7c4c41e8132 Mon Sep 17 00:00:00 2001 From: Victory Efekpogua Date: Sat, 29 Aug 2026 18:18:10 +0100 Subject: [PATCH 2/5] docs: expand README with detailed Table of Contents and unique toasts section --- README.md | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a493b06..736044c 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,45 @@ Rich, customizable native toast notifications for [NativePHP Mobile](https://nat ToastKit renders toasts as native overlays — Jetpack Compose on Android, SwiftUI on iOS — so they look and feel like part of the operating system. No Blade toast component is required. +## Table of Contents + +- [ToastKit](#toastkit) + - [Table of Contents](#table-of-contents) + - [Feature Highlights](#feature-highlights) + - [Installation](#installation) + - [Quick Start](#quick-start) + - [Basic Toasts](#basic-toasts) + - [Custom Toasts](#custom-toasts) + - [Icons](#icons) + - [Platform overrides](#platform-overrides) + - [Typography](#typography) + - [Font resolution](#font-resolution) + - [Updating Toasts](#updating-toasts) + - [Unique Toasts](#unique-toasts) + - [Progress \& Loading](#progress--loading) + - [Animations \& Direction](#animations--direction) + - [Presets](#presets) + - [Exceptions](#exceptions) + - [Migrating exception catches](#migrating-exception-catches) + - [Dismissing Toasts](#dismissing-toasts) + - [Actions \& Events](#actions--events) + - [Queue \& Stack](#queue--stack) + - [JavaScript Usage](#javascript-usage) + - [Complete NativeComponent Example](#complete-nativecomponent-example) + - [Real-world Example with NativePHP Fetch](#real-world-example-with-nativephp-fetch) + - [Events](#events) + - [Testing](#testing) + - [API Reference](#api-reference) + - [`Toast` facade](#toast-facade) + - [`PendingToast` builder](#pendingtoast-builder) + - [`PendingToastUpdate` builder](#pendingtoastupdate-builder) + - [Enums](#enums) + - [Defaults](#defaults) + - [Compatibility](#compatibility) + - [Permissions \& Dependencies](#permissions--dependencies) + - [Contributing](#contributing) + - [License](#license) + ## Feature Highlights - **Five variants** — `success`, `error`, `warning`, `info`, and `neutral`. @@ -258,11 +297,32 @@ Toast::dismissUnique('contacts-sync'); A key remains reserved until the toast has fully left the screen, including its exit animation. It is then reusable after every terminal path: timeout, swipe, action, close/programmatic dismissal, replacement, queue overflow, or `dismissAll()`. Updating a toast never changes its unique key. Calling `updateUnique()` or `dismissUnique()` for a key that is not active throws `ToastKitException`. -Unique keys also work with presets. Put the operation-specific key on each fresh builder so the preset itself remains reusable: +Unique keys also work with presets. Define the key as part of the preset during application boot when that preset represents one specific operation: ```php -Toast::preset('syncing') - ->unique('contacts-sync') +namespace App\Providers; + +use Illuminate\Support\ServiceProvider; +use Victorycodedev\ToastKit\PendingToast; +use Victorycodedev\ToastKit\Facades\Toast; + +final class AppServiceProvider extends ServiceProvider +{ + public function boot(): void + { + Toast::definePreset('contacts-sync', fn (PendingToast $toast) => $toast + ->info() + ->unique('contacts-sync') + ->persistent() + ->loading()); + } +} +``` + +The unique key is already included, so callers only use the preset and add any per-toast configuration they need: + +```php +Toast::preset('contacts-sync') ->message('Syncing contacts...') ->show(); ``` From 54d0ff7ce2746c68e1e83d97e5ae6163ef0729b5 Mon Sep 17 00:00:00 2001 From: Victory Efekpogua Date: Sat, 29 Aug 2026 18:24:59 +0100 Subject: [PATCH 3/5] docs: fix wording in README for clarity on Blade component usage --- README.md | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 736044c..d3c7277 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Rich, customizable native toast notifications for [NativePHP Mobile](https://nativephp.com/docs/mobile). -ToastKit renders toasts as native overlays — Jetpack Compose on Android, SwiftUI on iOS — so they look and feel like part of the operating system. No Blade toast component is required. +ToastKit renders toasts as native overlays — Jetpack Compose on Android, SwiftUI on iOS — so they look and feel like part of the operating system. No Blade component is required. ## Table of Contents @@ -106,7 +106,7 @@ Toast::make('Profile updated') ->show(); ``` -ToastKit is controlled from PHP and renders as a native overlay — you never add a Blade toast component. +ToastKit is controlled from PHP and renders as a native overlay — you never add a Blade component. ## Basic Toasts @@ -745,14 +745,6 @@ class ToastDemoScreen extends NativeComponent Toast::dismiss($this->toastId); } } - - #[On(ToastActionPressed::class)] - public function handleToastAction(string $toastId, string $actionId): void - { - if ($actionId === 'retry') { - $this->startUpload(); - } - } } ``` From f0e3d17d0aed65d8eb8bfe2b57418800a8b00b9b Mon Sep 17 00:00:00 2001 From: Victory Efekpogua Date: Sat, 29 Aug 2026 20:04:25 +0100 Subject: [PATCH 4/5] feat: implement platform-selective native rendering for toasts with customizable appearance --- CHANGELOG.md | 1 + README.md | 46 ++++++++ .../bridge/ToastKitBridgeNormalizer.kt | 12 +++ .../toastkit/manager/ToastKitManager.kt | 2 +- .../plugins/toastkit/model/ToastKitModels.kt | 2 + .../toastkit/presentation/ToastKitHost.kt | 61 ++++++++++- .../presentation/ToastKitHostInstaller.kt | 11 +- .../Bridge/ToastKitBridgeNormalizer.swift | 15 +++ .../ios/Sources/Manager/ToastKitManager.swift | 4 +- .../ios/Sources/Model/ToastKitModels.swift | 2 + .../Sources/Presentation/ToastKitHost.swift | 82 +++++++++++--- resources/js/PendingToast.js | 1 + resources/js/PendingToastBase.js | 3 + resources/js/tests/toastkit.test.js | 21 ++++ src/Concerns/ConfiguresToast.php | 5 + src/PendingToast.php | 1 + tests/Unit/NativeAppearanceTest.php | 102 ++++++++++++++++++ tests/Unit/UniqueToastTest.php | 3 +- 18 files changed, 350 insertions(+), 24 deletions(-) create mode 100644 tests/Unit/NativeAppearanceTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 34c2464..93644db 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 +- 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. - JavaScript parity for unique toast creation, updates, dismissal, and bridge-resolved UUIDs. - Native `snap`, `pop`, `reveal`, and `bounce` animations with independent `auto`, `left`, `right`, `top`, and `bottom` direction control. diff --git a/README.md b/README.md index d3c7277..e3cf442 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ ToastKit renders toasts as native overlays — Jetpack Compose on Android, Swift - [Font resolution](#font-resolution) - [Updating Toasts](#updating-toasts) - [Unique Toasts](#unique-toasts) + - [Native Appearance](#native-appearance) - [Progress \& Loading](#progress--loading) - [Animations \& Direction](#animations--direction) - [Presets](#presets) @@ -341,6 +342,50 @@ Toast::update($id) ->show(); ``` +## Native Appearance + +ToastKit uses its fully customizable renderer on both platforms by default. Call `native()` when you want each operating system's current design language instead: + +```php +// Native appearance on iOS and Android. +Toast::success('Saved') + ->native() + ->show(); + +// Native iOS, custom ToastKit Android. +Toast::success('Saved') + ->native(ios: true, android: false) + ->show(); + +// Custom ToastKit iOS, native Android. +Toast::success('Saved') + ->native(ios: false, android: true) + ->show(); +``` + +Native mode prioritizes platform appearance. Message, title, variant, icon, action, position, duration, persistence, unique identity, queue/stack behavior, progress, loading, dismissal controls, and swipe behavior remain available. ToastKit-specific colors, typography, corner radius, padding, shadow, animation, and direction are ignored only on a platform where native mode is enabled. + +Custom styles are retained in the configuration; `native()` is not a reset. This lets one platform remain fully customized: + +```php +Toast::make('Saved') + ->background('#000000') + ->native(ios: true, android: false) + ->show(); +``` + +Here iOS uses the system appearance while Android uses ToastKit's custom black appearance. Calling `background()` before or after `native()` produces the same result. Use `native(ios: false, android: false)` to explicitly select the custom renderer on both platforms. + +Renderer selection is preserved by sparse updates and can also be changed safely on an existing toast: + +```php +Toast::update($id) + ->native(ios: false, android: true) + ->show(); +``` + +On iOS 26 and later, ToastKit uses Apple's system Liquid Glass effect. Earlier supported iOS versions use SwiftUI's native regular material. Android uses the official Material 3 `Snackbar` component inside ToastKit's existing overlay, retaining the established lifecycle and event pipeline. + ## Progress & Loading Use determinate progress for work with a known percentage. Values are clamped to `0`–`100`, and updates animate without replacing the toast: @@ -950,6 +995,7 @@ Native::test(ProfileScreen::class) | `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). | | `position(ToastPosition\|string $position)` | `top`, `center`, or `bottom`. | +| `native(bool $ios = true, bool $android = true)` | Use platform-native appearance selectively; custom rendering remains the default. | | `duration(int $milliseconds)` | Set the visible duration (makes the toast timed). | | `persistent(bool $persistent = true)` | Make the toast persistent (no timeout). | | `animation(ToastAnimation\|string $animation)` | `fade`, `slide`, `scale`, `spring`, `snap`, `pop`, `reveal`, or `bounce`. | diff --git a/resources/android/src/com/victorycodedev/plugins/toastkit/bridge/ToastKitBridgeNormalizer.kt b/resources/android/src/com/victorycodedev/plugins/toastkit/bridge/ToastKitBridgeNormalizer.kt index e409548..871de1d 100644 --- a/resources/android/src/com/victorycodedev/plugins/toastkit/bridge/ToastKitBridgeNormalizer.kt +++ b/resources/android/src/com/victorycodedev/plugins/toastkit/bridge/ToastKitBridgeNormalizer.kt @@ -42,6 +42,10 @@ internal object ToastKitBridgeNormalizer { changes["position"]?.let { next = next.copy(position = position(it)) } changes["animation"]?.let { next = next.copy(animation = animation(it)) } changes["direction"]?.let { next = next.copy(direction = direction(it)) } + changes["native"]?.let { + val mode = nativeMode(it) + next = next.copy(nativeIos = mode.first, nativeAndroid = mode.second) + } if (changes.containsKey("progress")) next = next.copy(progress = optionalValue(changes["progress"])?.let(::progress)) changes["loading"]?.let { next = next.copy(loading = boolean(it, "loading")) } changes["swipe_to_dismiss"]?.let { next = next.copy(swipeToDismiss = boolean(it, "swipe_to_dismiss")) } @@ -68,6 +72,8 @@ internal object ToastKitBridgeNormalizer { return ToastKitConfiguration( id = requiredString(values, "id"), uniqueKey = nullableString(values["unique_key"], "unique_key"), + nativeIos = nativeMode(values["native"] ?: emptyMap()).first, + nativeAndroid = nativeMode(values["native"] ?: emptyMap()).second, message = requiredString(values, "message"), title = nullableString(values["title"], "title"), variant = variant(values["variant"] ?: "neutral"), @@ -102,6 +108,12 @@ internal object ToastKitBridgeNormalizer { return ToastKitAction(requiredString(map, "id"), requiredString(map, "label")) } + private fun nativeMode(value: Any): Pair { + val map = stringMap(value) ?: throw ToastKitInputException("native must be an object") + return (map["ios"]?.let { boolean(it, "native.ios") } ?: false) to + (map["android"]?.let { boolean(it, "native.android") } ?: false) + } + private fun style(value: Any, fallback: ToastKitStyle): ToastKitStyle { val map = stringMap(value) ?: throw ToastKitInputException("style must be an object") return fallback.copy( diff --git a/resources/android/src/com/victorycodedev/plugins/toastkit/manager/ToastKitManager.kt b/resources/android/src/com/victorycodedev/plugins/toastkit/manager/ToastKitManager.kt index 31143b8..f33dc7c 100644 --- a/resources/android/src/com/victorycodedev/plugins/toastkit/manager/ToastKitManager.kt +++ b/resources/android/src/com/victorycodedev/plugins/toastkit/manager/ToastKitManager.kt @@ -112,7 +112,7 @@ internal object ToastKitManager { waiting.remove(id) visible.removeAll { it.id == id } exiting.add(id) - val exitDuration = if (!state.visible) 0L else when (state.configuration.animation) { + val exitDuration = if (!state.visible) 0L else if (state.configuration.nativeAndroid) 160L else when (state.configuration.animation) { ToastKitAnimation.SPRING, ToastKitAnimation.BOUNCE -> 450L ToastKitAnimation.REVEAL -> 280L else -> 260L diff --git a/resources/android/src/com/victorycodedev/plugins/toastkit/model/ToastKitModels.kt b/resources/android/src/com/victorycodedev/plugins/toastkit/model/ToastKitModels.kt index 9fdd3bb..02a39b6 100644 --- a/resources/android/src/com/victorycodedev/plugins/toastkit/model/ToastKitModels.kt +++ b/resources/android/src/com/victorycodedev/plugins/toastkit/model/ToastKitModels.kt @@ -32,6 +32,8 @@ internal data class ToastKitStyle( internal data class ToastKitConfiguration( val id: String, val uniqueKey: String?, + val nativeIos: Boolean, + val nativeAndroid: Boolean, val message: String, val title: String?, val variant: String, diff --git a/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHost.kt b/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHost.kt index f5d1596..0286fbf 100644 --- a/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHost.kt +++ b/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHost.kt @@ -11,8 +11,14 @@ import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarDefaults +import androidx.compose.material3.TextButton import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -94,7 +100,7 @@ private fun ToastKitCard(toast: ToastKitConfiguration) { } } } - val enter = if (reduceMotion) fadeIn(tween(120)) else when (toast.animation) { + val enter = if (reduceMotion || toast.nativeAndroid) fadeIn(tween(120)) else when (toast.animation) { ToastKitAnimation.FADE -> fadeIn(tween(180)) ToastKitAnimation.SLIDE -> directionalEnter(toast, tween(220)) + fadeIn() ToastKitAnimation.SCALE -> scaleIn(tween(180), initialScale = .9f) + fadeIn() @@ -104,7 +110,7 @@ private fun ToastKitCard(toast: ToastKitConfiguration) { ToastKitAnimation.REVEAL -> expandHorizontally(tween(240), expandFrom = revealAlignment(toast)) + fadeIn(tween(160)) ToastKitAnimation.BOUNCE -> directionalEnter(toast, spring(dampingRatio = .45f, stiffness = 360f)) + fadeIn(tween(120)) } - val exit = if (reduceMotion) fadeOut(tween(100)) else when (toast.animation) { + val exit = if (reduceMotion || toast.nativeAndroid) fadeOut(tween(100)) else when (toast.animation) { ToastKitAnimation.FADE -> fadeOut(tween(160)) ToastKitAnimation.SLIDE -> directionalExit(toast, tween(220)) + fadeOut() ToastKitAnimation.SCALE -> scaleOut(tween(160), targetScale = .9f) + fadeOut() @@ -118,12 +124,17 @@ private fun ToastKitCard(toast: ToastKitConfiguration) { val shouldBeVisible = toast.id !in ToastKitManager.exiting LaunchedEffect(shouldBeVisible) { visibility.targetState = shouldBeVisible } AnimatedVisibility(visibleState = visibility, enter = enter, exit = exit) { - Column( - modifier = Modifier.padding(vertical = 4.dp).widthIn(max = 560.dp).fillMaxWidth() + val interaction = Modifier.padding(vertical = 4.dp).widthIn(max = 560.dp).fillMaxWidth() .graphicsLayer { translationX = displayedX; translationY = displayedY; alpha = (1f - (abs(displayedX) + abs(displayedY)) / 700f).coerceIn(.35f, 1f) } + .then(gesture) + if (toast.nativeAndroid) { + NativeToastKitCard(toast, displayedProgress, interaction) + } else { + Column( + modifier = interaction .then(if (toast.style.shadow) Modifier.shadow(8.dp, RoundedCornerShape(toast.style.cornerRadius.dp)) else Modifier) .clip(RoundedCornerShape(toast.style.cornerRadius.dp)).background(toast.style.background) - .padding(toast.style.padding.dp).then(gesture), + .padding(toast.style.padding.dp), ) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { if (toast.progress == null && toast.loading) { @@ -175,6 +186,46 @@ private fun ToastKitCard(toast: ToastKitConfiguration) { ) } } + } + } +} + +@Composable +private fun NativeToastKitCard(toast: ToastKitConfiguration, displayedProgress: Float, modifier: Modifier) { + Snackbar( + modifier = modifier, + action = toast.action?.let { action -> + { + TextButton( + onClick = { ToastKitManager.action(toast.id, action.id) }, + colors = ButtonDefaults.textButtonColors(contentColor = SnackbarDefaults.actionColor), + ) { Text(action.label) } + } + }, + dismissAction = if (toast.dismissible) { + { + IconButton(onClick = { ToastKitManager.dismiss(toast.id) }) { + MaterialIcon("close", "Dismiss", size = 20.dp, tint = LocalContentColor.current) + } + } + } else null, + ) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + if (toast.progress == null && toast.loading) { + CircularProgressIndicator(Modifier.size(22.dp), color = LocalContentColor.current, strokeWidth = 2.5.dp) + } else toast.icon?.let { + MaterialIcon(it.android ?: it.name ?: "circle", "Toast icon", size = 24.dp, tint = LocalContentColor.current) + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + toast.title?.let { Text(it, style = androidx.compose.material3.MaterialTheme.typography.labelLarge) } + Text(toast.message, style = androidx.compose.material3.MaterialTheme.typography.bodyMedium) + } + } + toast.progress?.let { + LinearProgressIndicator(progress = { displayedProgress }, modifier = Modifier.fillMaxWidth()) + } + } } } diff --git a/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHostInstaller.kt b/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHostInstaller.kt index 07a05c3..3070ecf 100644 --- a/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHostInstaller.kt +++ b/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHostInstaller.kt @@ -9,6 +9,8 @@ import android.view.ViewGroup import android.widget.FrameLayout import androidx.activity.ComponentActivity import androidx.compose.ui.platform.ComposeView +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme import androidx.lifecycle.setViewTreeLifecycleOwner import androidx.lifecycle.setViewTreeViewModelStoreOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner @@ -18,6 +20,7 @@ import androidx.fragment.app.FragmentActivity import java.util.WeakHashMap import android.os.Handler import android.os.Looper +import com.nativephp.mobile.ui.NativeUIThemeProvider internal object ToastKitHostInstaller : Application.ActivityLifecycleCallbacks { private val attached = WeakHashMap>() @@ -48,7 +51,13 @@ internal object ToastKitHostInstaller : Application.ActivityLifecycleCallbacks { setViewTreeLifecycleOwner(activity) setViewTreeViewModelStoreOwner(activity) setViewTreeSavedStateRegistryOwner(activity) - setContent { ToastKitHost(position) } + setContent { + val isDark = isSystemInDarkTheme() + MaterialTheme( + colorScheme = NativeUIThemeProvider.resolve(isDark), + typography = NativeUIThemeProvider.resolveTypography(), + ) { ToastKitHost(position) } + } decor.addView(this, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { gravity = when (position) { ToastKitPosition.TOP -> Gravity.TOP; ToastKitPosition.CENTER -> Gravity.CENTER; ToastKitPosition.BOTTOM -> Gravity.BOTTOM } }) diff --git a/resources/ios/Sources/Bridge/ToastKitBridgeNormalizer.swift b/resources/ios/Sources/Bridge/ToastKitBridgeNormalizer.swift index 6965dff..295349a 100644 --- a/resources/ios/Sources/Bridge/ToastKitBridgeNormalizer.swift +++ b/resources/ios/Sources/Bridge/ToastKitBridgeNormalizer.swift @@ -45,6 +45,11 @@ enum ToastKitBridgeNormalizer { if let value = changes["position"] { next.position = try enumValue(value, "position") } if let value = changes["animation"] { next.animation = try enumValue(value, "animation") } if let value = changes["direction"] { next.direction = try enumValue(value, "direction") } + if let value = changes["native"] { + let mode = try nativeMode(value) + next.nativeIos = mode.ios + next.nativeAndroid = mode.android + } if changes.keys.contains("progress") { next.progress = try changes["progress"].map(progress) } if let value = changes["loading"] { next.loading = try boolean(value, "loading") } if let value = changes["swipe_to_dismiss"] { next.swipeToDismiss = try boolean(value, "swipe_to_dismiss") } @@ -69,6 +74,8 @@ enum ToastKitBridgeNormalizer { return ToastKitConfiguration( id: try requiredString(values, "id"), uniqueKey: try nullableString(values["unique_key"], "unique_key"), + nativeIos: try nativeMode(values["native"] ?? [String: Any]()).ios, + nativeAndroid: try nativeMode(values["native"] ?? [String: Any]()).android, message: try requiredString(values, "message"), title: try nullableString(values["title"], "title"), variant: variantName, @@ -103,6 +110,14 @@ enum ToastKitBridgeNormalizer { return ToastKitActionConfiguration(id: try requiredString(map, "id"), label: try requiredString(map, "label")) } + private static func nativeMode(_ value: Any) throws -> (ios: Bool, android: Bool) { + guard let map = value as? [String: Any] else { throw ToastKitInputError.invalid("native must be an object") } + return ( + try map["ios"].map { try boolean($0, "native.ios") } ?? false, + try map["android"].map { try boolean($0, "native.android") } ?? false + ) + } + private static func typography(_ value: Any) throws -> ToastKitTypographyConfiguration { guard let map = value as? [String: Any] else { throw ToastKitInputError.invalid("typography must be an object") } guard !map.isEmpty else { throw ToastKitInputError.invalid("typography must not be empty") } diff --git a/resources/ios/Sources/Manager/ToastKitManager.swift b/resources/ios/Sources/Manager/ToastKitManager.swift index 504a62f..71d4e46 100644 --- a/resources/ios/Sources/Manager/ToastKitManager.swift +++ b/resources/ios/Sources/Manager/ToastKitManager.swift @@ -99,11 +99,11 @@ final class ToastKitManager: ObservableObject { ToastKitUniqueRegistry.shared.release(state.configuration) return } - let milliseconds = switch state.configuration.animation { + let milliseconds = if state.configuration.nativeIos { 180 } else { switch state.configuration.animation { case .spring, .bounce: 450 case .reveal: 280 default: 260 - } + } } Task { @MainActor in try? await Task.sleep(for: .milliseconds(milliseconds)) ToastKitUniqueRegistry.shared.release(state.configuration) diff --git a/resources/ios/Sources/Model/ToastKitModels.swift b/resources/ios/Sources/Model/ToastKitModels.swift index bb2cf61..fd88646 100644 --- a/resources/ios/Sources/Model/ToastKitModels.swift +++ b/resources/ios/Sources/Model/ToastKitModels.swift @@ -39,6 +39,8 @@ struct ToastKitStyleConfiguration { struct ToastKitConfiguration: Identifiable { let id: String let uniqueKey: String? + var nativeIos: Bool + var nativeAndroid: Bool var message: String var title: String? var variant: String diff --git a/resources/ios/Sources/Presentation/ToastKitHost.swift b/resources/ios/Sources/Presentation/ToastKitHost.swift index e199b9f..67d8d84 100644 --- a/resources/ios/Sources/Presentation/ToastKitHost.swift +++ b/resources/ios/Sources/Presentation/ToastKitHost.swift @@ -35,7 +35,7 @@ struct ToastKitHost: View { } private func transition(for toast: ToastKitConfiguration) -> AnyTransition { - if reduceMotion { return .opacity } + if reduceMotion || toast.nativeIos { return .opacity } switch toast.animation { case .fade: return .opacity case .slide: return .move(edge: edge(for: toast)).combined(with: .opacity) @@ -94,6 +94,25 @@ private struct ToastKitView: View { @State private var drag: CGSize = .zero var body: some View { + Group { + if toast.nativeIos { nativeContent } else { customContent } + } + .offset(drag) + .opacity(Double(max(CGFloat(0.35), CGFloat(1) - (abs(drag.width) + abs(drag.height)) / CGFloat(700)))) + .contentShape(Rectangle()) + .background { + GeometryReader { proxy in + Color.clear.preference( + key: ToastKitFramePreferenceKey.self, + value: [toast.id: proxy.frame(in: .global)] + ) + } + } + .toastKitConditional(toast.swipeToDismiss) { $0.gesture(dragGesture) } + .animation(reduceMotion ? .linear(duration: 0.1) : .spring(response: 0.3, dampingFraction: 0.78), value: drag) + } + + private var customContent: some View { HStack(spacing: 12) { if toast.progress == nil && toast.loading { ProgressView().tint(toast.style.iconColor).controlSize(.small) @@ -128,18 +147,6 @@ private struct ToastKitView: View { .padding(toast.style.padding) .background(toast.style.background, in: RoundedRectangle(cornerRadius: toast.style.cornerRadius, style: .continuous)) .shadow(color: toast.style.shadow ? .black.opacity(0.22) : .clear, radius: 10, y: 4) - .offset(drag) - .opacity(Double(max(CGFloat(0.35), CGFloat(1) - (abs(drag.width) + abs(drag.height)) / CGFloat(700)))) - .contentShape(Rectangle()) - .background { - GeometryReader { proxy in - Color.clear.preference( - key: ToastKitFramePreferenceKey.self, - value: [toast.id: proxy.frame(in: .global)] - ) - } - } - .toastKitConditional(toast.swipeToDismiss) { $0.gesture(dragGesture) } .overlay(alignment: .bottom) { if let progress = toast.progress { ProgressView(value: progress, total: 100) @@ -149,7 +156,45 @@ private struct ToastKitView: View { .animation(reduceMotion ? .linear(duration: 0.1) : .easeInOut(duration: 0.2), value: progress) } } - .animation(reduceMotion ? .linear(duration: 0.1) : .spring(response: 0.3, dampingFraction: 0.78), value: drag) + } + + private var nativeContent: some View { + HStack(spacing: 12) { + 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)) + .accessibilityHidden(true) + } + VStack(alignment: .leading, spacing: 2) { + if let title = toast.title { Text(title).font(.headline) } + Text(toast.message).font(.subheadline).fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + if let action = toast.action { + Button(action.label) { ToastKitManager.shared.pressAction(toastId: toast.id, actionId: action.id) } + .buttonStyle(.borderless) + .font(.subheadline.weight(.semibold)) + } + if toast.dismissible { + Button { ToastKitManager.shared.dismiss(toast.id) } label: { + Image(systemName: "xmark").frame(width: 28, height: 28) + } + .buttonStyle(.plain) + .accessibilityLabel("Dismiss") + } + } + .padding() + .toastKitNativeAppearance() + .overlay(alignment: .bottom) { + if let progress = toast.progress { + ProgressView(value: progress, total: 100) + .padding(.horizontal) + .padding(.bottom, 4) + .animation(reduceMotion ? .linear(duration: 0.1) : .easeInOut(duration: 0.2), value: progress) + } + } } private var dragGesture: some Gesture { @@ -194,6 +239,15 @@ private extension View { self } } + + @ViewBuilder + func toastKitNativeAppearance() -> some View { + if #available(iOS 26.0, *) { + self.glassEffect(.regular, in: .rect(cornerRadius: 16)) + } else { + self.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + } } private func toastKitFont(_ typography: ToastKitTypographyConfiguration?, defaultSize: CGFloat, defaultWeight: Font.Weight) -> Font { diff --git a/resources/js/PendingToast.js b/resources/js/PendingToast.js index a49a178..c1976da 100644 --- a/resources/js/PendingToast.js +++ b/resources/js/PendingToast.js @@ -62,6 +62,7 @@ export class PendingToast extends PendingToastBase { title: null, variant: "neutral", position: "bottom", + native: { ios: false, android: false }, duration: 3000, persistent: false, animation: "scale", diff --git a/resources/js/PendingToastBase.js b/resources/js/PendingToastBase.js index 22a083c..2f03180 100644 --- a/resources/js/PendingToastBase.js +++ b/resources/js/PendingToastBase.js @@ -55,6 +55,9 @@ export class PendingToastBase { position(value) { return this.set("position", oneOf(value, "position")); } + native({ ios = true, android = true } = {}) { + return this.set("native", { ios: Boolean(ios), android: Boolean(android) }); + } duration(value) { this.set("persistent", false); return this.set("duration", positiveInteger(value, "duration")); diff --git a/resources/js/tests/toastkit.test.js b/resources/js/tests/toastkit.test.js index afee5a2..0e3a94d 100644 --- a/resources/js/tests/toastkit.test.js +++ b/resources/js/tests/toastkit.test.js @@ -85,6 +85,27 @@ test("position, duration, persistent and animation are set", () => { assert.equal(Toast.make("x").persistent().payload().duration, null); }); +test("native appearance is custom by default and selectable per platform", () => { + assert.deepEqual(Toast.success("Default").payload().native, { ios: false, android: false }); + assert.deepEqual(Toast.success("Both").native().payload().native, { ios: true, android: true }); + assert.deepEqual(Toast.success("iOS").native({ ios: true, android: false }).payload().native, { ios: true, android: false }); + assert.deepEqual(Toast.success("Android").native({ ios: false, android: true }).payload().native, { ios: false, android: true }); + assert.deepEqual(Toast.success("Neither").native({ ios: false, android: false }).payload().native, { ios: false, android: false }); +}); + +test("native appearance preserves custom styles and is chain-order independent", () => { + const before = Toast.make("Before").background("#000000").native({ ios: true, android: false }).payload(); + const after = Toast.make("After").native({ ios: true, android: false }).background("#000000").payload(); + assert.deepEqual(before.native, after.native); + assert.deepEqual(before.style, after.style); + assert.equal(before.style.background, "#000000"); +}); + +test("native appearance can be changed through a sparse update", () => { + const payload = Toast.update("toast").native({ ios: false, android: true }).payload(); + assert.deepEqual(payload.changes, { native: { ios: false, android: true } }); +}); + test("new animations, direction, progress and loading are set", () => { for (const animation of ["snap", "pop", "reveal", "bounce"]) assert.equal(Toast.make("x").animation(animation).payload().animation, animation); diff --git a/src/Concerns/ConfiguresToast.php b/src/Concerns/ConfiguresToast.php index 0885714..66fdb39 100644 --- a/src/Concerns/ConfiguresToast.php +++ b/src/Concerns/ConfiguresToast.php @@ -92,6 +92,11 @@ public function position(ToastPosition|string $position): static return $this->put('position', $this->enumValue(ToastPosition::class, $position, 'position')); } + public function native(bool $ios = true, bool $android = true): static + { + return $this->put('native', ['ios' => $ios, 'android' => $android]); + } + public function duration(int $milliseconds): static { if ($milliseconds <= 0) throw new InvalidToastConfigurationException('Toast duration must be greater than zero milliseconds.'); diff --git a/src/PendingToast.php b/src/PendingToast.php index 1db9e67..adce055 100644 --- a/src/PendingToast.php +++ b/src/PendingToast.php @@ -81,6 +81,7 @@ private function defaults(): array 'title' => null, 'variant' => 'neutral', 'position' => 'bottom', + 'native' => ['ios' => false, 'android' => false], 'duration' => 3000, 'persistent' => false, 'animation' => 'scale', diff --git a/tests/Unit/NativeAppearanceTest.php b/tests/Unit/NativeAppearanceTest.php new file mode 100644 index 0000000..03d913b --- /dev/null +++ b/tests/Unit/NativeAppearanceTest.php @@ -0,0 +1,102 @@ +kit->success('Saved')->show(); + + expect($this->calls[0][1]['native'])->toBe(['ios' => false, 'android' => false]); +}); + +it('selects native appearance per platform', function (bool $ios, bool $android) { + $this->kit->success('Saved')->native(ios: $ios, android: $android)->show(); + + expect($this->calls[0][1]['native'])->toBe(['ios' => $ios, 'android' => $android]); +})->with([ + 'both platforms' => [true, true], + 'iOS only' => [true, false], + 'Android only' => [false, true], + 'neither platform' => [false, false], +]); + +it('keeps both default arguments enabled when only ios is named', function () { + $this->kit->success('Saved')->native(ios: true)->show(); + + expect($this->calls[0][1]['native'])->toBe(['ios' => true, 'android' => true]); +}); + +it('preserves custom styling regardless of chain order', function () { + $before = $this->kit->make('Before')->background('#000000')->native(ios: true, android: false)->payload(); + $after = $this->kit->make('After')->native(ios: true, android: false)->background('#000000')->payload(); + + expect($before['native'])->toBe($after['native']) + ->and($before['style'])->toBe($after['style']) + ->and($before['style']['background'])->toBe('#000000'); +}); + +it('retains content behavior and visual configuration in native mode', function () { + $payload = $this->kit->make('Payment complete') + ->title('Success') + ->success() + ->icon(ios: 'checkmark.seal', android: 'verified') + ->action('View', 'view') + ->position('top') + ->persistent() + ->loading() + ->dismissible() + ->swipeToDismiss(false) + ->stack() + ->background('#000000') + ->text(size: 'sm') + ->animation('bounce') + ->direction('top') + ->native(ios: true, android: false) + ->payload(); + + expect($payload)->toMatchArray([ + 'message' => 'Payment complete', + 'title' => 'Success', + 'native' => ['ios' => true, 'android' => false], + 'action' => ['id' => 'view', 'label' => 'View'], + 'position' => 'top', + 'persistent' => true, + 'loading' => true, + 'dismissible' => true, + 'swipe_to_dismiss' => false, + 'strategy' => 'stack', + 'animation' => 'bounce', + 'direction' => 'top', + ])->and($payload['icon']['ios'])->toBe('checkmark.seal') + ->and($payload['icon']['android'])->toBe('verified') + ->and($payload['style']['background'])->toBe('#000000') + ->and($payload['text'])->toBe(['size' => 'sm']); +}); + +it('sends native mode as a sparse update', function () { + $this->kit->update('toast')->native(ios: false, android: true)->show(); + + expect($this->calls[0][1]['changes'])->toBe([ + 'native' => ['ios' => false, 'android' => true], + ]); +}); + +it('preserves native configuration in fresh presets without leaking state', function () { + $this->kit->definePreset('system-feedback', fn (PendingToast $toast) => $toast + ->native(ios: true, android: false) + ->position('top')); + + $this->kit->preset('system-feedback')->message('One')->show(); + $this->kit->preset('system-feedback')->message('Two')->native(false, false)->show(); + $this->kit->preset('system-feedback')->message('Three')->show(); + + expect($this->calls[0][1]['native'])->toBe(['ios' => true, 'android' => false]) + ->and($this->calls[1][1]['native'])->toBe(['ios' => false, 'android' => false]) + ->and($this->calls[2][1]['native'])->toBe(['ios' => true, 'android' => false]); +}); + +it('keeps unique identity orthogonal to native appearance', function () { + $payload = $this->kit->warning('Offline')->unique('network')->native(ios: true, android: false)->payload(); + + expect($payload['unique_key'])->toBe('network') + ->and($payload['native'])->toBe(['ios' => true, 'android' => false]); +}); diff --git a/tests/Unit/UniqueToastTest.php b/tests/Unit/UniqueToastTest.php index 51ec02f..dc520ae 100644 --- a/tests/Unit/UniqueToastTest.php +++ b/tests/Unit/UniqueToastTest.php @@ -133,12 +133,13 @@ private function missing(string $key): array }); it('updates and dismisses the same toast through its semantic key', function () { - $id = $this->uniqueKit->error('Offline')->unique('network')->persistent()->show(); + $id = $this->uniqueKit->error('Offline')->unique('network')->native(ios: true, android: false)->persistent()->show(); $updatedId = $this->uniqueKit->updateUnique('network')->success()->message('Online')->duration(2000)->show(); expect($updatedId)->toBe($id) ->and($this->native->active)->toHaveCount(1) ->and($this->native->active[$id]['unique_key'])->toBe('network') + ->and($this->native->active[$id]['native'])->toBe(['ios' => true, 'android' => false]) ->and($this->native->active[$id]['message'])->toBe('Online'); expect($this->uniqueKit->error('Duplicate')->unique('network')->show())->toBe($id); From 7266007dde9c47872d8b7a85545ae4a5567c7088 Mon Sep 17 00:00:00 2001 From: Victory Efekpogua Date: Sat, 29 Aug 2026 20:22:07 +0100 Subject: [PATCH 5/5] refactor: restructure Snackbar implementation in NativeToastKitCard for improved readability and maintainability --- .../toastkit/presentation/ToastKitHost.kt | 32 ++++++++----------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHost.kt b/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHost.kt index 0286fbf..02b6cf9 100644 --- a/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHost.kt +++ b/resources/android/src/com/victorycodedev/plugins/toastkit/presentation/ToastKitHost.kt @@ -192,26 +192,9 @@ private fun ToastKitCard(toast: ToastKitConfiguration) { @Composable private fun NativeToastKitCard(toast: ToastKitConfiguration, displayedProgress: Float, modifier: Modifier) { - Snackbar( - modifier = modifier, - action = toast.action?.let { action -> - { - TextButton( - onClick = { ToastKitManager.action(toast.id, action.id) }, - colors = ButtonDefaults.textButtonColors(contentColor = SnackbarDefaults.actionColor), - ) { Text(action.label) } - } - }, - dismissAction = if (toast.dismissible) { - { - IconButton(onClick = { ToastKitManager.dismiss(toast.id) }) { - MaterialIcon("close", "Dismiss", size = 20.dp, tint = LocalContentColor.current) - } - } - } else null, - ) { + Snackbar(modifier = modifier) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { if (toast.progress == null && toast.loading) { CircularProgressIndicator(Modifier.size(22.dp), color = LocalContentColor.current, strokeWidth = 2.5.dp) } else toast.icon?.let { @@ -221,6 +204,17 @@ private fun NativeToastKitCard(toast: ToastKitConfiguration, displayedProgress: toast.title?.let { Text(it, style = androidx.compose.material3.MaterialTheme.typography.labelLarge) } Text(toast.message, style = androidx.compose.material3.MaterialTheme.typography.bodyMedium) } + toast.action?.let { action -> + TextButton( + onClick = { ToastKitManager.action(toast.id, action.id) }, + colors = ButtonDefaults.textButtonColors(contentColor = SnackbarDefaults.actionColor), + ) { Text(action.label) } + } + if (toast.dismissible) { + IconButton(onClick = { ToastKitManager.dismiss(toast.id) }) { + MaterialIcon("close", "Dismiss", size = 20.dp, tint = LocalContentColor.current) + } + } } toast.progress?.let { LinearProgressIndicator(progress = { displayedProgress }, modifier = Modifier.fillMaxWidth())