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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- 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()`.
- A catchable ToastKit exception hierarchy with configuration and missing-preset exceptions.
- JavaScript parity for animations, direction, progress, and loading.

- Initial public API: `Toast` facade, `PendingToast` and `PendingToastUpdate` builders.
- Five variants (`neutral`, `success`, `error`, `warning`, `info`) with native defaults.
- Content options: title, message, icons with iOS/Android overrides, and action buttons.
Expand All @@ -23,5 +29,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **Breaking:** ToastKit's public exceptions now extend the package-owned `ToastKitException` base, which extends `RuntimeException`. Replace ToastKit-specific `InvalidArgumentException` catches with `ToastKitException`.
- Composer requirement raised to PHP `^8.4` and NativePHP Mobile `^4.1`.
- Pest upgraded to `^4.0`.
221 changes: 216 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ ToastKit renders toasts as native overlays — Jetpack Compose on Android, Swift

- **Five variants** — `success`, `error`, `warning`, `info`, and `neutral`.
- **Rich content** — title, message, and icons with per-platform overrides.
- **Full customization** — position, duration, animation, swipe-to-dismiss, close control, colors, corner radius, padding, and shadow.
- **Full customization** — position, animation direction, progress, loading, swipe-to-dismiss, close control, colors, corner radius, padding, and shadow.
- **Action buttons** — a native action button with its own ID and pressed event.
- **Queue strategy** — FIFO, one toast at a time.
- **Stack strategy** — up to `maxVisible` toasts on screen with FIFO overflow.
Expand Down Expand Up @@ -100,7 +100,7 @@ Toast::make('Download complete')
->show();
```

Supported options include `title()`, `icon()`, `position()`, `duration()`, `persistent()`, `animation()`, `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()`, `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.

## Icons

Expand Down Expand Up @@ -237,6 +237,209 @@ Toast::update($id)
->show();
```

## Progress & Loading

Use determinate progress for work with a known percentage. Values are clamped to `0`–`100`, and updates animate without replacing the toast:

```php
$id = Toast::info('Uploading...')->persistent()->progress(0)->show();

Toast::update($id)->progress(42.5)->show();
Toast::update($id)->progress(100)->message('Upload complete')->show();
```

Use `loading()` when progress is unknown, and `loading(false)` to stop it:

```php
$id = Toast::info('Connecting...')->persistent()->loading()->show();
Toast::update($id)->loading(false)->message('Connected')->duration(1500)->show();
```

If a payload contains both `progress` and `loading: true`, determinate progress wins. This makes partial updates deterministic while preserving both values in the transport contract.

## Animations & Direction

ToastKit supports `fade`, `slide`, `scale`, `spring`, `snap`, `pop`, `reveal`, and `bounce`. Direction is independent and accepts `auto`, `left`, `right`, `top`, or `bottom`:

```php
Toast::success('Published')
->animation('bounce')
->direction('right')
->show();
```

`auto` is the default and derives the motion from the toast position. Reduced-motion platform settings use a short fade instead of spatial or spring motion.

## Presets

Define reusable presets during application boot, such as in a service provider:

```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('syncing', fn (PendingToast $toast) => $toast
->message('Syncing...')
->info()
->persistent()
->loading());
}
}
```

Each call creates a fresh builder, and options chained afterward override the preset:

```php
Toast::preset('syncing')->message('Syncing contacts...')->show();
```

Defining the same name again replaces the previous definition. Missing presets throw `PresetNotFoundException`. All intentional public configuration failures extend `ToastKitException`. Presets are PHP-only because they are registered in the Laravel application lifecycle.

Small applications can keep these definitions directly in `AppServiceProvider::boot()`. For larger applications, a convenient optional organization is an `App\Toasts\ToastPresets` class with a static `register()` method, called from `AppServiceProvider::boot()`. ToastKit does not generate or require this class; it simply keeps a longer preset catalog out of the provider. Precedence is: ToastKit defaults → preset → per-toast configuration → sparse update configuration.

```php
namespace App\Toasts;

use Victorycodedev\ToastKit\Facades\Toast;

final class ToastPresets
{
public static function register(): void
{
Toast::definePreset('payment-success', fn ($toast) => $toast
->success()->icon('check_circle')->animation('reveal')
->position('top')->duration(3000));

Toast::definePreset('payment-failed', fn ($toast) => $toast
->error()->icon('error')->animation('snap')->direction('left')
->position('top')->duration(5000));

Toast::definePreset('uploading', fn ($toast) => $toast
->info()->loading()->persistent());
}
}
```

Then call `ToastPresets::register()` from your application's `AppServiceProvider::boot()`:

```php
namespace App\Providers;

use App\Toasts\ToastPresets;
use Illuminate\Support\ServiceProvider;

final class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
ToastPresets::register();
}
}
```

You may instead keep all toast presets in a dedicated service provider, such as `ToastServiceProvider`:

```php
namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Victorycodedev\ToastKit\PendingToast;
use Victorycodedev\ToastKit\Facades\Toast;

final class ToastServiceProvider extends ServiceProvider
{
public function boot(): void
{
Toast::definePreset('payment-success', fn (PendingToast $toast) => $toast
->success()
->icon('check_circle')
->animation('reveal')
->position('top')
->duration(3000));

Toast::definePreset('payment-failed', fn (PendingToast $toast) => $toast
->error()
->icon('error')
->animation('snap')
->direction('left')
->position('top')
->duration(5000));

Toast::definePreset('uploading', fn (PendingToast $toast) => $toast
->info()
->loading()
->persistent());
}
}
```

Register that provider using your Laravel application's normal provider registration, for example in `bootstrap/providers.php`:

```php
return [
App\Providers\AppServiceProvider::class,
App\Providers\ToastServiceProvider::class,
];
```

### Exceptions

ToastKit exposes one package-owned exception hierarchy:

```text
RuntimeException
└── ToastKitException
├── InvalidToastConfigurationException
└── PresetNotFoundException
```

Catch `ToastKitException` when you want to handle every error intentionally raised by ToastKit:

```php
use Victorycodedev\ToastKit\Exceptions\ToastKitException;

try {
Toast::preset('payment-success')->message('Paid')->show();
} catch (ToastKitException $exception) {
report($exception);
}
```

Use a specific subclass when the application can recover from one particular condition, such as a missing preset:

```php
use Victorycodedev\ToastKit\Exceptions\PresetNotFoundException;

try {
Toast::preset('payment-success');
} catch (PresetNotFoundException $exception) {
// The requested preset was not registered.
}
```

#### Migrating exception catches

ToastKit exceptions no longer extend PHP's `InvalidArgumentException`. Applications that previously caught that exception for ToastKit calls should use the package base exception instead:

```php
// Before
catch (\InvalidArgumentException $exception) {
// ...
}

// Now
catch (\Victorycodedev\ToastKit\Exceptions\ToastKitException $exception) {
// ...
}
```

## Dismissing Toasts

```php
Expand Down Expand Up @@ -462,7 +665,7 @@ Blade screen using native components:

## Real-world Example with NativePHP Fetch

ToastKit pairs naturally with a Fetch package for upload/download progress. Fetch is **not** a ToastKit dependency — this is an optional integration example.
ToastKit pairs naturally with the Fetch (Http) package for upload/download progress. Fetch is **not** a ToastKit dependency - this is an optional integration example.

```php
$toastId = Toast::info('Downloading...')
Expand Down Expand Up @@ -608,6 +811,8 @@ Native::test(ProfileScreen::class)
| `Toast::warning(string $message)` | A warning-variant toast. |
| `Toast::info(string $message)` | An info-variant toast. |
| `Toast::neutral(string $message)` | A neutral-variant toast. |
| `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::dismiss(string $id)` | Dismiss a toast by ID. |
| `Toast::dismissAll()` | Dismiss all active and queued toasts. |
Expand All @@ -629,7 +834,10 @@ Native::test(ProfileScreen::class)
| `position(ToastPosition\|string $position)` | `top`, `center`, or `bottom`. |
| `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`, or `spring`. |
| `animation(ToastAnimation\|string $animation)` | `fade`, `slide`, `scale`, `spring`, `snap`, `pop`, `reveal`, or `bounce`. |
| `direction(ToastDirection\|string $direction)` | `auto`, `left`, `right`, `top`, or `bottom`. |
| `progress(int\|float $progress)` | Set determinate progress, clamped to `0`–`100`. |
| `loading(bool $loading = true)` | Enable or disable indeterminate progress. |
| `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. |
Expand All @@ -656,7 +864,8 @@ Native::test(ProfileScreen::class)
| --- | --- |
| `ToastVariant` | `success`, `error`, `warning`, `info`, `neutral` |
| `ToastPosition` | `top`, `center`, `bottom` |
| `ToastAnimation` | `fade`, `slide`, `scale`, `spring` |
| `ToastAnimation` | `fade`, `slide`, `scale`, `spring`, `snap`, `pop`, `reveal`, `bounce` |
| `ToastDirection` | `auto`, `left`, `right`, `top`, `bottom` |
| `ToastStrategy` | `queue`, `stack` |
| `ToastTextSize` | `xs`, `sm`, `base`, `lg`, `xl` |
| `ToastTextWeight` | `normal`, `medium`, `semibold`, `bold` |
Expand All @@ -672,6 +881,8 @@ Native::test(ProfileScreen::class)
| `duration` | `3000` ms |
| `persistent` | `false` |
| `animation` | `scale` |
| `direction` | `auto` |
| `loading` | `false` |
| `swipe_to_dismiss` | `true` |
| `dismissible` | `false` |
| `strategy` | `queue` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ internal object ToastKitBridgeNormalizer {
if (changes.containsKey("icon")) next = next.copy(icon = optionalValue(changes["icon"])?.let(::icon))
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)) }
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")) }
changes["dismissible"]?.let { next = next.copy(dismissible = boolean(it, "dismissible")) }
if (changes.containsKey("action")) next = next.copy(action = optionalValue(changes["action"])?.let(::action))
Expand Down Expand Up @@ -63,6 +66,9 @@ internal object ToastKitBridgeNormalizer {
position = position(values["position"] ?: "bottom"),
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),
Expand Down Expand Up @@ -160,7 +166,8 @@ 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 animation(value: Any) = ToastKitAnimation.valueOf(enumString(value, "animation", setOf("fade", "slide", "scale", "spring")).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())
private fun textSize(value: Any) = ToastKitTextSize.valueOf(enumString(value, "text size", setOf("xs", "sm", "base", "lg", "xl")).uppercase())
private fun textWeight(value: Any) = ToastKitTextWeight.valueOf(enumString(value, "text weight", setOf("normal", "medium", "semibold", "bold")).uppercase())
Expand Down Expand Up @@ -194,6 +201,11 @@ internal object ToastKitBridgeNormalizer {
requireInput(result >= 0, "$name must not be negative")
return result
}
private fun progress(value: Any): Float {
val result = (value as? Number)?.toFloat() ?: throw ToastKitInputException("progress must be numeric")
requireInput(result.isFinite(), "progress must be finite")
return result.coerceIn(0f, 100f)
}
private fun stringMap(value: Any?): Map<String, Any>? = when (value) {
is JSONObject -> value.keys().asSequence().associate { key -> key to value.get(key) }
is Map<*, *> -> value.entries.associate { (key, v) -> key.toString() to (v ?: JSONObject.NULL) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,11 @@ internal object ToastKitManager {
waiting.remove(id)
visible.removeAll { it.id == id }
exiting.add(id)
val exitDuration = if (state.configuration.animation == ToastKitAnimation.SPRING) 450L else 260L
val exitDuration = when (state.configuration.animation) {
ToastKitAnimation.SPRING, ToastKitAnimation.BOUNCE -> 450L
ToastKitAnimation.REVEAL -> 280L
else -> 260L
}
main.postDelayed({
rendered.removeAll { it.id == id }
exiting.remove(id)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ package com.victorycodedev.plugins.toastkit.model
import androidx.compose.ui.graphics.Color

internal enum class ToastKitPosition { TOP, CENTER, BOTTOM }
internal enum class ToastKitAnimation { FADE, SLIDE, SCALE, SPRING }
internal enum class ToastKitAnimation { FADE, SLIDE, SCALE, SPRING, SNAP, POP, REVEAL, BOUNCE }
internal enum class ToastKitDirection { AUTO, LEFT, RIGHT, TOP, BOTTOM }
internal enum class ToastKitStrategy { QUEUE, STACK }
internal enum class ToastKitTextSize { XS, SM, BASE, LG, XL }
internal enum class ToastKitTextWeight { NORMAL, MEDIUM, SEMIBOLD, BOLD }
Expand Down Expand Up @@ -37,6 +38,9 @@ internal data class ToastKitConfiguration(
val position: ToastKitPosition,
val durationMs: Long?,
val animation: ToastKitAnimation,
val direction: ToastKitDirection,
val progress: Float?,
val loading: Boolean,
val swipeToDismiss: Boolean,
val dismissible: Boolean,
val action: ToastKitAction?,
Expand Down
Loading