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
107 changes: 101 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ ToastKit renders toasts as native overlays — Jetpack Compose on Android, Swift
- [Custom Toasts](#custom-toasts)
- [Icons](#icons)
- [Platform overrides](#platform-overrides)
- [Icon size](#icon-size)
- [Typography](#typography)
- [Font resolution](#font-resolution)
- [Updating Toasts](#updating-toasts)
- [Unique Toasts](#unique-toasts)
- [Native Appearance](#native-appearance)
- [Dynamic Presentation](#dynamic-presentation)
- [Opacity](#opacity)
- [Progress \& Loading](#progress--loading)
- [Animations \& Direction](#animations--direction)
Expand Down Expand Up @@ -54,9 +56,10 @@ 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.
- **Polished updates** — title and message changes crossfade in place instead of snapping.
- **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`.
- **Events** — `ToastShown`, `ToastDismissed`, `ToastPressed`, and `ToastActionPressed`.
- **JavaScript API** — a fluent `Toast` API for web-facing apps.

## Installation
Expand Down Expand Up @@ -142,7 +145,7 @@ Toast::make('Download complete')
->show();
```

Supported options include `title()`, `icon()`, `position()`, `duration()`, `persistent()`, `animation()`, `direction()`, `progress()`, `loading()`, `swipeToDismiss()`, `dismissible()`, `action()`, and the styling methods `background()`, `foreground()`, `iconColor()`, `actionColor()`, `cornerRadius()`, `padding()`, and `shadow()`. See the [API Reference](#api-reference) for the full list.
Supported options include `title()`, `icon()`, `iconSize()`, `position()`, `presentation()`, `duration()`, `persistent()`, `animation()`, `direction()`, `progress()`, `loading()`, `swipeToDismiss()`, `dismissible()`, `action()`, and the styling methods `background()`, `foreground()`, `iconColor()`, `actionColor()`, `cornerRadius()`, `padding()`, and `shadow()`. See the [API Reference](#api-reference) for the full list.

## Icons

Expand Down Expand Up @@ -179,6 +182,18 @@ You can pass either override on its own:
Toast::make('Rated')->icon(ios: 'star.fill')->show();
```

### Icon size

Choose one of three icon sizes for the custom renderer:

```php
Toast::success('Uploaded')
->iconSize('lg') // sm, md, or lg
->show();
```

The size is `18`, `24`, or `32` points/dp respectively. Calling `iconSize()` is optional, so existing toasts retain their current platform default. Platform-native appearance ignores this option because the operating system controls native icon sizing.

See NativePHP's [Icon name reference](https://nativephp.com/docs/mobile/4/edge-components/icon#icon-name-reference) for the names guaranteed to work consistently on both platforms.

## Typography
Expand Down Expand Up @@ -387,6 +402,33 @@ Toast::update($id)

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.

## Dynamic Presentation

Dynamic presentation briefly flows from the top camera area with a liquid-drop connector, starts as a compact foreground pill, and smoothly expands to reveal the toast content. The connector retracts once expansion finishes instead of remaining above the toast. The effect has a native SwiftUI design on iOS and a Material-style Compose design on Android:

```php
Toast::info('Syncing contacts...')
->dynamic()
->loading()
->persistent()
->show();
```

`dynamic()` is a shortcut for `presentation('dynamic')`. Use `presentation('standard')` to select the regular toast explicitly:

```php
Toast::success('Contacts synced')
->presentation('dynamic')
->progress(100)
->action('View', 'view-contacts')
->dismissible()
->show();
```

Dynamic presentation works on iOS and Android and retains the normal toast lifecycle: entrance and exit animations, duration, persistence, queue/stack behavior, unique keys, live updates, progress, loading, one action, close controls, swipe dismissal, opacity, colors, typography, and icon sizing all remain available. It is automatically anchored at the top, so callers do not need to add `position('top')`.

The dynamic renderer controls its placement, so `position()` and `direction()` are ignored.`native()` is also ignored while dynamic presentation is selected because dynamic presentation is already a platform-specific native design. The retained placement and native settings apply again if a sparse update changes the presentation back to `standard`.

## Opacity

Opacity is optional and applies to the entire toast, including its content and controls:
Expand Down Expand Up @@ -628,13 +670,41 @@ Dismissals are idempotent — dismissing an ID that is already gone is a no-op.

## Actions & Events

Add a native action button and handle its press:
Attach small routing or context values with `data()`, then handle a press anywhere on the toast body:

```php
use Native\Mobile\Attributes\On;
use Victorycodedev\ToastKit\Events\ToastPressed;

Toast::info('New message from Grace')
->data([
'conversation_id' => 42,
'message_id' => 781,
])
->dynamic()
->show();

#[On(ToastPressed::class)]
public function openConversation(string $toastId, array $data): void
{
Toast::dismiss($toastId);

$this->redirectRoute('conversations.show', [
'conversation' => $data['conversation_id'],
]);
}
```

Whole-toast pressing is enabled only when non-empty `data()` is attached. Toasts without attached data keep their existing interaction behavior and do not emit `ToastPressed`; calling `data([])` disables whole-toast pressing again. A body press does not automatically dismiss the toast, so the listener decides whether to dismiss, update, or retain it. Action and close controls consume their own presses and do not also emit `ToastPressed`.

Add a native action button and handle its press independently:

```php
use Native\Mobile\Attributes\On;
use Victorycodedev\ToastKit\Events\ToastActionPressed;

Toast::error('Connection lost')
->data(['request_id' => 91])
->action(
label: 'Retry',
id: 'retry',
Expand All @@ -645,14 +715,29 @@ Toast::error('Connection lost')
public function handleToastAction(
string $toastId,
string $actionId,
array $data,
): void {
if ($actionId === 'retry') {
$this->retry();
}
}
```

Pressing an action emits `ToastActionPressed` and dismisses the toast.
Pressing an action emits `ToastActionPressed` with the attached data and dismisses the toast. Keep attached data small and JSON-serializable—IDs, types, and slugs are preferable to complete models or sensitive values. Calling `data()` during an update replaces the attached object; `data([])` clears it.

Use `actions()` when a toast needs two choices. Every entry must explicitly provide both a non-empty `label` and a unique non-empty `id`:

```php
Toast::warning('Delete this draft?')
->data(['draft_id' => 42])
->actions([
['label' => 'Cancel', 'id' => 'cancel', 'color' => '#D1D5DB'],
['label' => 'Delete', 'id' => 'delete', 'color' => '#EF4444'],
])
->show();
```

`actions()` accepts one or two actions. The optional `color` on an individual action overrides `actionColor()` for that button; actions without a color continue using the shared action color or platform default. Pressing either action emits `ToastActionPressed` with that action's ID and then automatically dismisses the toast, matching `action()`. If `action()` and `actions()` are called on the same builder, the last method called wins. This makes it possible for an individual toast to override actions inherited from a preset without sending conflicting bridge values.

The other events follow the same pattern:

Expand Down Expand Up @@ -908,13 +993,14 @@ Refer to your Fetch package's documentation for its exact API surface.

## Events

ToastKit dispatches three events. Listen with NativePHP's `#[On]` attribute:
ToastKit dispatches four events. Listen with NativePHP's `#[On]` attribute:

| Event | Payload |
| --- | --- |
| `Victorycodedev\ToastKit\Events\ToastShown` | `toastId` (string) |
| `Victorycodedev\ToastKit\Events\ToastDismissed` | `toastId` (string), `reason` (string) |
| `Victorycodedev\ToastKit\Events\ToastActionPressed` | `toastId` (string), `actionId` (string) |
| `Victorycodedev\ToastKit\Events\ToastPressed` | `toastId` (string), `data` (array) |
| `Victorycodedev\ToastKit\Events\ToastActionPressed` | `toastId` (string), `actionId` (string), `data` (array) |

```php
use Native\Mobile\Attributes\On;
Expand Down Expand Up @@ -1013,7 +1099,10 @@ Native::test(ProfileScreen::class)
| `success()` / `error()` / `warning()` / `info()` / `neutral()` | Set the variant. |
| `variant(ToastVariant\|string $variant)` | Set the variant by enum or string. |
| `icon(?string $name = null, ?string $ios = null, ?string $android = null)` | Set an icon using string names, with optional SF Symbol (`ios:`) / Material Icon (`android:`) overrides. See [Icons](#icons). |
| `iconSize(string $size)` | Set custom-renderer icon size to `sm`, `md`, or `lg`; native appearance ignores it. |
| `position(ToastPosition\|string $position)` | `top`, `center`, or `bottom`. |
| `presentation(ToastPresentation\|string $presentation)` | Select `standard` or `dynamic` presentation. |
| `dynamic()` | Shortcut for `presentation('dynamic')`. |
| `native(bool $ios = true, bool $android = true)` | Use platform-native appearance selectively; custom rendering remains the default. |
| `opacity(float $opacity = 0.8)` | Set whole-toast opacity from `0.0` to `1.0`; out-of-range values are clamped. |
| `duration(int $milliseconds)` | Set the visible duration (makes the toast timed). |
Expand All @@ -1025,6 +1114,8 @@ Native::test(ProfileScreen::class)
| `swipeToDismiss(bool $enabled = true)` | Enable or disable swipe-to-dismiss. |
| `dismissible(bool $enabled = true)` | Show a visible close control. |
| `action(string $label, string $id)` | Add an action button with a label and ID. |
| `actions(array $actions)` | Add one or two actions; every entry requires a unique `id` and a `label`, with an optional per-action `color`. |
| `data(array $data)` | Attach JSON-serializable context delivered with body-press and action events. |
| `background(string $color)` | Set the background color. |
| `foreground(string $color)` | Set the text color. |
| `iconColor(string $color)` | Set the icon color. |
Expand All @@ -1048,6 +1139,7 @@ Native::test(ProfileScreen::class)
| --- | --- |
| `ToastVariant` | `success`, `error`, `warning`, `info`, `neutral` |
| `ToastPosition` | `top`, `center`, `bottom` |
| `ToastPresentation` | `standard`, `dynamic` |
| `ToastAnimation` | `fade`, `slide`, `scale`, `spring`, `snap`, `pop`, `reveal`, `bounce` |
| `ToastDirection` | `auto`, `left`, `right`, `top`, `bottom` |
| `ToastStrategy` | `queue`, `stack` |
Expand All @@ -1062,13 +1154,16 @@ Native::test(ProfileScreen::class)
| --- | --- |
| `variant` | `neutral` |
| `position` | `bottom` |
| `presentation` | `standard` |
| `duration` | `3000` ms |
| `persistent` | `false` |
| `animation` | `scale` |
| `direction` | `auto` |
| `loading` | `false` |
| `icon_size` | Platform default (omitted) |
| `swipe_to_dismiss` | `true` |
| `dismissible` | `false` |
| `data` | Empty and omitted unless configured |
| `strategy` | `queue` |
| `max_visible` | `3` |
| `corner_radius` | `16` |
Expand Down
5 changes: 3 additions & 2 deletions nativephp.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@
"events": [
"Victorycodedev\\ToastKit\\Events\\ToastShown",
"Victorycodedev\\ToastKit\\Events\\ToastDismissed",
"Victorycodedev\\ToastKit\\Events\\ToastActionPressed"
"Victorycodedev\\ToastKit\\Events\\ToastActionPressed",
"Victorycodedev\\ToastKit\\Events\\ToastPressed"
],
"service_provider": "Victorycodedev\\ToastKit\\ToastKitServiceProvider"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.victorycodedev.plugins.toastkit.bridge

import androidx.compose.ui.graphics.Color
import com.victorycodedev.plugins.toastkit.model.*
import org.json.JSONArray
import org.json.JSONObject

internal class ToastKitInputException(message: String) : IllegalArgumentException(message)
Expand Down Expand Up @@ -39,7 +40,9 @@ internal object ToastKitBridgeNormalizer {
next = next.copy(variant = name, style = variantStyle(name), icon = defaultIcon(name))
}
if (changes.containsKey("icon")) next = next.copy(icon = optionalValue(changes["icon"])?.let(::icon))
changes["icon_size"]?.let { next = next.copy(iconSize = iconSize(it)) }
changes["position"]?.let { next = next.copy(position = position(it)) }
changes["presentation"]?.let { next = next.copy(presentation = presentation(it)) }
changes["animation"]?.let { next = next.copy(animation = animation(it)) }
changes["direction"]?.let { next = next.copy(direction = direction(it)) }
changes["native"]?.let {
Expand All @@ -51,7 +54,9 @@ internal object ToastKitBridgeNormalizer {
changes["loading"]?.let { next = next.copy(loading = boolean(it, "loading")) }
changes["swipe_to_dismiss"]?.let { next = next.copy(swipeToDismiss = boolean(it, "swipe_to_dismiss")) }
changes["dismissible"]?.let { next = next.copy(dismissible = boolean(it, "dismissible")) }
if (changes.containsKey("action")) next = next.copy(action = optionalValue(changes["action"])?.let(::action))
if (changes.containsKey("action")) next = next.copy(actions = optionalValue(changes["action"])?.let(::action)?.let(::listOf) ?: emptyList())
if (changes.containsKey("actions")) next = next.copy(actions = actions(changes["actions"] ?: emptyList<Any>()))
if (changes.containsKey("data")) next = next.copy(data = data(changes["data"] ?: emptyMap<String, Any>()))
changes["strategy"]?.let { next = next.copy(strategy = strategy(it)) }
changes["max_visible"]?.let { next = next.copy(maxVisible = positiveInt(it, "max_visible")) }
changes["style"]?.let { next = next.copy(style = style(it, next.style)) }
Expand Down Expand Up @@ -80,15 +85,19 @@ internal object ToastKitBridgeNormalizer {
title = nullableString(values["title"], "title"),
variant = variant(values["variant"] ?: "neutral"),
icon = optionalValue(values["icon"])?.let(::icon),
iconSize = values["icon_size"]?.let(::iconSize),
position = position(values["position"] ?: "bottom"),
presentation = presentation(values["presentation"] ?: "standard"),
durationMs = if (persistent) null else positiveLong(values["duration"] ?: 3000, "duration"),
animation = animation(values["animation"] ?: "scale"),
direction = direction(values["direction"] ?: "auto"),
progress = optionalValue(values["progress"])?.let(::progress),
loading = values["loading"]?.let { boolean(it, "loading") } ?: false,
swipeToDismiss = values["swipe_to_dismiss"]?.let { boolean(it, "swipe_to_dismiss") } ?: true,
dismissible = values["dismissible"]?.let { boolean(it, "dismissible") } ?: false,
action = optionalValue(values["action"])?.let(::action),
actions = if (values.containsKey("actions")) actions(values["actions"] ?: emptyList<Any>())
else optionalValue(values["action"])?.let(::action)?.let(::listOf) ?: emptyList(),
data = values["data"]?.let(::data) ?: emptyMap(),
style = style(values["style"] ?: emptyMap<String, Any>(), variantStyle(values["variant"] as? String ?: "neutral")),
strategy = strategy(values["strategy"] ?: "queue"),
maxVisible = positiveInt(values["max_visible"] ?: 3, "max_visible"),
Expand All @@ -105,9 +114,40 @@ internal object ToastKitBridgeNormalizer {
return ToastKitIcon(name, android)
}

private fun iconSize(value: Any) = ToastKitIconSize.valueOf(
enumString(value, "icon size", setOf("sm", "md", "lg")).uppercase(),
)

private fun action(value: Any): ToastKitAction {
val map = stringMap(value) ?: throw ToastKitInputException("action must be an object")
return ToastKitAction(requiredString(map, "id"), requiredString(map, "label"))
return ToastKitAction(
requiredString(map, "id"),
requiredString(map, "label"),
map["color"]?.let { color(it, "action.color") },
)
}

private fun actions(value: Any): List<ToastKitAction> {
val values = when (value) {
is JSONArray -> (0 until value.length()).map(value::get)
is Collection<*> -> value.toList()
else -> throw ToastKitInputException("actions must be an array")
}
requireInput(values.size in 1..2, "actions must contain one or two actions")
val actions = values.map { action(it ?: throw ToastKitInputException("action must be an object")) }
requireInput(actions.map { it.id }.distinct().size == actions.size, "action IDs must be unique")
return actions
}

private fun data(value: Any): Map<String, Any> {
stringMap(value)?.let { return it }

// PHP encodes an empty associative array as JSON `[]`. Treat that
// representation as an empty data object so data remains optional.
if (value is JSONArray && value.length() == 0) return emptyMap()
if (value is Collection<*> && value.isEmpty()) return emptyMap()

throw ToastKitInputException("data must be an object")
}

private fun nativeMode(value: Any): Pair<Boolean, Boolean> {
Expand Down Expand Up @@ -189,6 +229,7 @@ internal object ToastKitBridgeNormalizer {

private fun variant(value: Any): String = enumString(value, "variant", setOf("neutral", "success", "error", "warning", "info"))
private fun position(value: Any) = ToastKitPosition.valueOf(enumString(value, "position", setOf("top", "center", "bottom")).uppercase())
private fun presentation(value: Any) = ToastKitPresentation.valueOf(enumString(value, "presentation", setOf("standard", "dynamic")).uppercase())
private fun animation(value: Any) = ToastKitAnimation.valueOf(enumString(value, "animation", setOf("fade", "slide", "scale", "spring", "snap", "pop", "reveal", "bounce")).uppercase())
private fun direction(value: Any) = ToastKitDirection.valueOf(enumString(value, "direction", setOf("auto", "left", "right", "top", "bottom")).uppercase())
private fun strategy(value: Any) = ToastKitStrategy.valueOf(enumString(value, "strategy", setOf("queue", "stack")).uppercase())
Expand Down
Loading
Loading