Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ 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.
- 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()`.
Expand Down
188 changes: 176 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,47 @@

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

- [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)
- [Native Appearance](#native-appearance)
- [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

Expand All @@ -13,6 +53,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.
Expand Down Expand Up @@ -66,7 +107,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

Expand Down Expand Up @@ -223,6 +264,70 @@ 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. Define the key as part of the preset during application boot when that preset represents one specific operation:

```php
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();
```

Updates are sparse:

- The toast keeps its original ID.
Expand All @@ -237,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:
Expand Down Expand Up @@ -557,14 +706,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();
```

Expand Down Expand Up @@ -622,14 +790,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();
}
}
}
```

Expand Down Expand Up @@ -814,7 +974,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
Expand All @@ -824,6 +986,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). |
Expand All @@ -832,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`. |
Expand All @@ -856,7 +1020,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

Expand Down
12 changes: 12 additions & 0 deletions nativephp.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Any>): Map<String, Any> = 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)
}
}

Expand All @@ -28,6 +37,15 @@ object ToastKitFunctions {
}
}

class DismissUnique(private val activity: FragmentActivity) : BridgeFunction {
override fun execute(parameters: Map<String, Any>): Map<String, Any> = 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<String, Any>): Map<String, Any> = respond {
ToastKitEventDispatcher.bind(activity)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,15 @@ internal object ToastKitBridgeNormalizer {
return id to changes
}

fun updateUnique(parameters: Map<String, Any>): Pair<String, Map<String, Any>> {
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, Any>): String = requiredString(parameters, "id")
fun uniqueKey(parameters: Map<String, Any>): String = requiredString(parameters, "unique_key")

fun applyChanges(current: ToastKitConfiguration, changes: Map<String, Any>): ToastKitConfiguration {
var next = current
Expand All @@ -34,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")) }
Expand All @@ -59,6 +71,9 @@ 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"),
nativeIos = nativeMode(values["native"] ?: emptyMap<String, Any>()).first,
nativeAndroid = nativeMode(values["native"] ?: emptyMap<String, Any>()).second,
message = requiredString(values, "message"),
title = nullableString(values["title"], "title"),
variant = variant(values["variant"] ?: "neutral"),
Expand Down Expand Up @@ -93,6 +108,12 @@ internal object ToastKitBridgeNormalizer {
return ToastKitAction(requiredString(map, "id"), requiredString(map, "label"))
}

private fun nativeMode(value: Any): Pair<Boolean, Boolean> {
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(
Expand Down
Loading