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
8 changes: 8 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ jobs:
run: |
package_version="$(sed -n 's/^version:[[:space:]]*//p' pubspec.yaml)"
test "${GITHUB_REF_NAME#v}" = "$package_version"
- name: Verify deployed hosted runtime
run: |
runtime="$(sed -n "s/.*seatLayerHostedWebVersion = '\(.*\)';/\1/p" lib/src/seat_layer_configuration.dart)"
metadata="$(curl --fail --silent --show-error "https://cdn.seatlayer.io/seatlayer-js@$runtime/release.json")"
test "$(printf '%s' "$metadata" | python3 -c 'import json,sys; print(json.load(sys.stdin)["version"])')" = "$runtime"
test "$(printf '%s' "$metadata" | python3 -c 'import json,sys; print(str(json.load(sys.stdin)["promotable"]).lower())')" = true
curl --fail --silent --show-error --output /dev/null \
"https://cdn.seatlayer.io/seatlayer-js@$runtime/mobile.html"
- run: dart pub publish --dry-run

publish:
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Changelog

## 0.2.0

- Loads the pinned `seatlayer-js@0.66.0/mobile.html` document at
`https://cdn.seatlayer.io`; buyer-access sessions require that exact origin.
- Separates the hosted runtime version (`0.66.0`) from the explicit offline
demo/test fixture version (`0.59.0`).
- Adds renewable private buyer access, programmatic selection/category
controls, exact-count validators, typed validity/access streams, view-mode
parity, and fail-closed capability negotiation.
- Retains explicit Flutter asset loading for self-contained demos and tests.

## 0.1.3

- Updated the vendored buyer runtime to `seatlayer-js@0.59.0` (sha256
Expand Down
41 changes: 30 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ admission, floors, accessibility controls, and a typed event bridge.
[React Native](https://github.com/seatlayer/seatlayer-react-native) ·
[AI Toolkit](https://github.com/seatlayer/seatlayer-ai-toolkit)

> **Public preview:** `0.1.x` is the first public Flutter line. Validate it with
> **Public preview:** `0.2.x` is the current public Flutter line. Validate it with
> a test event and physical iOS/Android devices before production rollout.

## Install
Expand All @@ -31,7 +31,7 @@ Or add it to `pubspec.yaml`:

```yaml
dependencies:
seatlayer: ^0.1.2
seatlayer: ^0.2.0
```

Then import the public library:
Expand Down Expand Up @@ -99,6 +99,17 @@ controller.onHoldExpired.listen(returnBuyerToMap);
controller.onError.listen(reportSeatLayerError);
```

For private channel inventory, mint short-lived sessions on your backend for
the exact allowed origin `https://cdn.seatlayer.io`:

```dart
final configuration = SeatLayerConfiguration(
event: 'ev_private',
buyerAccessTokenProvider: (context) =>
buyerBackend.mintSeatLayerAccess(context.reason),
);
```

## Security boundary

The Flutter app **selects and holds** inventory. Your trusted backend **inspects
Expand All @@ -114,10 +125,12 @@ before connecting checkout.

## How it works

The SDK hosts a vendored `seatlayer-js@0.59.0` buyer bundle (sha256 `89bc29fb…`) inside
`webview_flutter` and communicates over SeatLayer's versioned bridge protocol.
The UI can start without downloading SDK JavaScript; live chart and inventory
data still come from the configured SeatLayer API.
Production views load the immutable
`seatlayer-js@0.66.0/mobile.html` document and its lazy assets from
`https://cdn.seatlayer.io` inside `webview_flutter`. This gives iOS and Android
one canonical HTTPS origin for origin-bound buyer sessions. Tokens stay in
memory and are never put in page URLs or events. Explicit bundled fixture pages
remain supported for demos and tests and are pinned separately to `0.59.0`.

The public contract matches the Web and iOS SDKs:

Expand All @@ -130,14 +143,20 @@ The public contract matches the Web and iOS SDKs:

`hold` · `resumeHold` · `extendHold` · `release` · `releaseLabels` ·
`bestAvailable` · `holdGA` · `setSeatTier` · `getSelection` ·
`getCurrentHold` · `getGAAreas` · `getFloors` · `setFloor` ·
`setColorblindSafe` · `zoomIn` · `zoomOut` · `zoomToFit` · `destroy`
`selectObjects` · `deselectObjects` · `clearSelection` · `selectCategories` ·
`deselectCategories` · `setSelectableObjects` · `setMaxSelection` ·
`getSelectionValidity` · `refreshAccess` · `getCurrentHold` · `getGAAreas` ·
`getFloors` · `setFloor` · `setColorblindSafe` · `setViewMode` ·
`getViewMode` · `zoomIn` · `zoomOut` · `zoomToFit` · `destroy`

## Event streams

`onReady` · `onSelectionChanged` · `onHold` · `onHoldRestored` ·
`onHoldExpired` · `onError` · `onHint` · `onGAClick` · `onSeatHover` ·
`onDeckTap` · `onUnknownEvent`
`onReady` · `onSelectionChanged` · `onSelectionValidityChanged` ·
`onSelectionValid` · `onSelectionInvalid` · `onSelectionLimit` ·
`onBuyerAccessExpired` · `onBuyerAccessUnavailable` ·
`onSelectedObjectsUnavailable` · `onHold` · `onHoldRestored` · `onHoldExpired` ·
`onError` · `onHint` · `onGAClick` · `onSeatHover` · `onDeckTap` ·
`onUnknownEvent`

## Layout requirement

Expand Down
4 changes: 2 additions & 2 deletions lib/seatlayer.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/// SeatLayer Flutter SDK.
///
/// Embed an interactive SeatLayer seat map by placing a [SeatLayerView] and
/// driving it through a [SeatLayerController]. The widget hosts the vendored
/// web bundle in a WebView and speaks the shared, versioned bridge protocol
/// driving it through a [SeatLayerController]. The widget hosts the immutable,
/// version-pinned mobile runtime in a WebView and speaks the shared bridge —
/// the same contract the iOS and web SDKs implement.
library seatlayer;

Expand Down
23 changes: 23 additions & 0 deletions lib/src/open_enums.dart
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,29 @@ final class SeatStatusUnknown extends SeatStatus {
int get hashCode => raw.hashCode;
}

/// Buyer canvas projection used by the shared picker.
class SeatLayerViewMode {
const SeatLayerViewMode(this.raw);
final String raw;

static const flat = SeatLayerViewMode('flat');
static const isometric = SeatLayerViewMode('iso');
static const perspective = SeatLayerViewMode('perspective');

factory SeatLayerViewMode.fromRaw(String raw) => switch (raw) {
'flat' => flat,
'iso' => isometric,
'perspective' => perspective,
_ => SeatLayerViewMode(raw),
};

@override
bool operator ==(Object other) =>
other is SeatLayerViewMode && other.raw == raw;
@override
int get hashCode => raw.hashCode;
}

/// Decode an optional open-enum string, tolerating null and non-strings.
EventMode? eventModeOrNull(Object? v) =>
v is String ? EventMode.fromRaw(v) : null;
Expand Down
226 changes: 226 additions & 0 deletions lib/src/payloads.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:async';

import 'bridge/bridge_protocol.dart';
import 'json.dart';
import 'open_enums.dart';
Expand All @@ -9,6 +11,120 @@ import 'open_enums.dart';
// a field or ships a new enum value. Open enums fold unknown values into their
// `Unknown` variant (see open_enums.dart).

/// Why private buyer access needs a fresh bearer.
class BuyerAccessRefreshReason {
const BuyerAccessRefreshReason(this.raw);
final String raw;

static const initial = BuyerAccessRefreshReason('initial');
static const expiring = BuyerAccessRefreshReason('expiring');
static const expired = BuyerAccessRefreshReason('expired');
static const unauthorized = BuyerAccessRefreshReason('unauthorized');
static const reconnect = BuyerAccessRefreshReason('reconnect');
static const manual = BuyerAccessRefreshReason('manual');

factory BuyerAccessRefreshReason.fromRaw(String raw) => switch (raw) {
'initial' => initial,
'expiring' => expiring,
'expired' => expired,
'unauthorized' => unauthorized,
'reconnect' => reconnect,
'manual' => manual,
_ => BuyerAccessRefreshReason(raw),
};

@override
bool operator ==(Object other) =>
other is BuyerAccessRefreshReason && other.raw == raw;
@override
int get hashCode => raw.hashCode;
}

class BuyerAccessUnavailableReason {
const BuyerAccessUnavailableReason(this.raw);
final String raw;

static const revoked = BuyerAccessUnavailableReason('revoked');
static const paused = BuyerAccessUnavailableReason('paused');
static const invalid = BuyerAccessUnavailableReason('invalid');
static const originMismatch = BuyerAccessUnavailableReason('origin_mismatch');
static const eventMismatch = BuyerAccessUnavailableReason('event_mismatch');
static const groupMismatch = BuyerAccessUnavailableReason('group_mismatch');
static const modeMismatch = BuyerAccessUnavailableReason('mode_mismatch');
static const channelDenied = BuyerAccessUnavailableReason('channel_denied');
static const invalidScope = BuyerAccessUnavailableReason('invalid_scope');
static const providerFailed = BuyerAccessUnavailableReason('provider_failed');
static const noToken = BuyerAccessUnavailableReason('no_token');

factory BuyerAccessUnavailableReason.fromRaw(String raw) =>
BuyerAccessUnavailableReason(raw);
}

class SelectionViolation {
const SelectionViolation(this.raw);
final String raw;

@override
bool operator ==(Object other) =>
other is SelectionViolation && other.raw == raw;
@override
int get hashCode => raw.hashCode;
}

class SelectedObjectUnavailableReason {
const SelectedObjectUnavailableReason(this.raw);
final String raw;
}

class BuyerAccessToken {
const BuyerAccessToken({required this.token, this.expiresAt});
final String token;

/// Epoch milliseconds; omit to refresh reactively.
final double? expiresAt;

Map<String, Object?> toJson() => {
'token': token,
if (expiresAt != null) 'expiresAt': expiresAt,
};
}

class BuyerAccessRequestContext {
const BuyerAccessRequestContext({required this.reason});
final BuyerAccessRefreshReason reason;
}

typedef BuyerAccessTokenProvider = FutureOr<BuyerAccessToken> Function(
BuyerAccessRequestContext context,
);

sealed class SelectionValidator {
const SelectionValidator();
Map<String, Object?> toJson();
}

final class MinimumSelectedPlaces extends SelectionValidator {
const MinimumSelectedPlaces(this.minimum);
final int minimum;
@override
Map<String, Object?> toJson() => {
'type': 'minimumSelectedPlaces',
'minimum': minimum,
};
}

final class ConsecutiveSeats extends SelectionValidator {
const ConsecutiveSeats();
@override
Map<String, Object?> toJson() => const {'type': 'consecutiveSeats'};
}

final class NoOrphanSeats extends SelectionValidator {
const NoOrphanSeats();
@override
Map<String, Object?> toJson() => const {'type': 'noOrphanSeats'};
}

/// A ticket tier a category offers (Adult / Child / …).
class CategoryTier {
const CategoryTier(
Expand Down Expand Up @@ -100,6 +216,116 @@ class SelectedSeat {
}
}

class SelectionValidity {
const SelectionValidity({
required this.isValid,
required this.count,
required this.required,
required this.remaining,
required this.seats,
required this.violations,
});

final bool isValid;
final int count;
final int required;
final int remaining;
final List<SelectedSeat> seats;
final List<SelectionViolation> violations;

static SelectionValidity? fromJson(Object? value) {
final isValid = jBool(jGet(value, 'isValid'));
final count = jInt(jGet(value, 'count'));
final required = jInt(jGet(value, 'required'));
final remaining = jInt(jGet(value, 'remaining'));
if (isValid == null ||
count == null ||
required == null ||
remaining == null) {
return null;
}
return SelectionValidity(
isValid: isValid,
count: count,
required: required,
remaining: remaining,
seats: jListOf(jGet(value, 'seats'), SelectedSeat.fromJson),
violations: jListOf(
jGet(value, 'violations'),
(item) => item is String ? SelectionViolation(item) : null,
),
);
}
}

class BuyerAccessExpiredEvent {
const BuyerAccessExpiredEvent({
required this.reason,
required this.refreshed,
this.code,
});
final BuyerAccessRefreshReason reason;
final String? code;
final bool refreshed;

static BuyerAccessExpiredEvent? fromJson(Object? value) {
final reason = jStr(jGet(value, 'reason'));
final refreshed = jBool(jGet(value, 'refreshed'));
if (reason == null || refreshed == null) return null;
return BuyerAccessExpiredEvent(
reason: BuyerAccessRefreshReason.fromRaw(reason),
code: jStr(jGet(value, 'code')),
refreshed: refreshed,
);
}
}

class BuyerAccessUnavailableEvent {
const BuyerAccessUnavailableEvent({
required this.reason,
required this.retryable,
this.code,
this.status,
});
final BuyerAccessUnavailableReason reason;
final String? code;
final int? status;
final bool retryable;

static BuyerAccessUnavailableEvent? fromJson(Object? value) {
final reason = jStr(jGet(value, 'reason'));
final retryable = jBool(jGet(value, 'retryable'));
if (reason == null || retryable == null) return null;
return BuyerAccessUnavailableEvent(
reason: BuyerAccessUnavailableReason.fromRaw(reason),
code: jStr(jGet(value, 'code')),
status: jInt(jGet(value, 'status')),
retryable: retryable,
);
}
}

class SelectedObjectUnavailableEvent {
const SelectedObjectUnavailableEvent({
required this.labels,
required this.reason,
this.code,
});
final List<String> labels;
final SelectedObjectUnavailableReason reason;
final String? code;

static SelectedObjectUnavailableEvent? fromJson(Object? value) {
final reason = jStr(jGet(value, 'reason'));
if (reason == null) return null;
return SelectedObjectUnavailableEvent(
labels: jListOf(jGet(value, 'labels'), (item) => jStr(item)),
reason: SelectedObjectUnavailableReason(reason),
code: jStr(jGet(value, 'code')),
);
}
}

/// One line of a hold.
class HoldLineItem {
const HoldLineItem({
Expand Down
Loading