diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a661b1a..39c0720 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bf95b1..6df5b46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 60ec2d4..7b25640 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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: @@ -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 @@ -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: @@ -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 diff --git a/lib/seatlayer.dart b/lib/seatlayer.dart index 1ec30fb..fe6fef4 100644 --- a/lib/seatlayer.dart +++ b/lib/seatlayer.dart @@ -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; diff --git a/lib/src/open_enums.dart b/lib/src/open_enums.dart index 2d4a92f..aaf761c 100644 --- a/lib/src/open_enums.dart +++ b/lib/src/open_enums.dart @@ -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; diff --git a/lib/src/payloads.dart b/lib/src/payloads.dart index be0e218..20b38e3 100644 --- a/lib/src/payloads.dart +++ b/lib/src/payloads.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'bridge/bridge_protocol.dart'; import 'json.dart'; import 'open_enums.dart'; @@ -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 toJson() => { + 'token': token, + if (expiresAt != null) 'expiresAt': expiresAt, + }; +} + +class BuyerAccessRequestContext { + const BuyerAccessRequestContext({required this.reason}); + final BuyerAccessRefreshReason reason; +} + +typedef BuyerAccessTokenProvider = FutureOr Function( + BuyerAccessRequestContext context, +); + +sealed class SelectionValidator { + const SelectionValidator(); + Map toJson(); +} + +final class MinimumSelectedPlaces extends SelectionValidator { + const MinimumSelectedPlaces(this.minimum); + final int minimum; + @override + Map toJson() => { + 'type': 'minimumSelectedPlaces', + 'minimum': minimum, + }; +} + +final class ConsecutiveSeats extends SelectionValidator { + const ConsecutiveSeats(); + @override + Map toJson() => const {'type': 'consecutiveSeats'}; +} + +final class NoOrphanSeats extends SelectionValidator { + const NoOrphanSeats(); + @override + Map toJson() => const {'type': 'noOrphanSeats'}; +} + /// A ticket tier a category offers (Adult / Child / …). class CategoryTier { const CategoryTier( @@ -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 seats; + final List 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 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({ diff --git a/lib/src/seat_layer_configuration.dart b/lib/src/seat_layer_configuration.dart index bd33bd9..d725c93 100644 --- a/lib/src/seat_layer_configuration.dart +++ b/lib/src/seat_layer_configuration.dart @@ -1,11 +1,23 @@ import 'bridge/bridge_client.dart'; import 'bridge/bridge_protocol.dart'; +import 'open_enums.dart'; +import 'payloads.dart'; /// This SDK's version. -const String seatLayerSdkVersion = '0.1.2'; +const String seatLayerSdkVersion = '0.2.0'; -/// The web bundle version vendored into this package (`assets/seatlayer.js`). -const String seatLayerBundledWebVersion = '0.26.0'; +/// The immutable hosted web runtime used by production views. +const String seatLayerHostedWebVersion = '0.66.0'; + +/// Runtime retained only for explicit offline demo/test fixtures. +const String seatLayerLegacyFixtureWebVersion = '0.59.0'; + +@Deprecated( + 'Use seatLayerHostedWebVersion; production no longer uses a bundled runtime.') +const String seatLayerBundledWebVersion = seatLayerHostedWebVersion; +const String seatLayerMobileOrigin = 'https://cdn.seatlayer.io'; +const String seatLayerMobilePageUrl = + '$seatLayerMobileOrigin/seatlayer-js@$seatLayerHostedWebVersion/mobile.html'; /// Everything needed to boot a seat map. /// @@ -21,18 +33,45 @@ class SeatLayerConfiguration { this.messages, this.currency, this.colorblindSafe, + this.initialView, this.showsWebSeatTooltip = false, + this.buyerAccessToken, + this.buyerAccessTokenProvider, + this.selectedObjects, + this.selectableObjects, + this.numberOfPlacesToSelect, + this.selectionValidators, this.commandTimeout = BridgeClient.defaultTimeout, this.handshakeTimeout = const Duration(seconds: 30), this.hostInfo = const {}, this.assetPath = defaultAssetPath, - }); + }) { + if (event.trim().isEmpty) throw ArgumentError.value(event, 'event'); + if (maxSelection != null && maxSelection! < 1) { + throw ArgumentError.value(maxSelection, 'maxSelection'); + } + if (numberOfPlacesToSelect != null && numberOfPlacesToSelect! < 1) { + throw ArgumentError.value( + numberOfPlacesToSelect, + 'numberOfPlacesToSelect', + ); + } + if (buyerAccessToken != null && + (buyerAccessToken!.token.trim().isEmpty || + buyerAccessToken!.expiresAt?.isFinite == false)) { + throw ArgumentError.value(buyerAccessToken, 'buyerAccessToken'); + } + for (final validator + in selectionValidators ?? const []) { + if (validator is MinimumSelectedPlaces && validator.minimum < 1) { + throw ArgumentError.value(validator.minimum, 'selectionValidators'); + } + } + } - /// The Flutter asset that hosts the vendored bundle. Loaded from the package, - /// never the network — a seat map opens with zero network dependency at - /// startup. Override to load a self-contained fixture page (the example app - /// points this at its offline demo fixture). - static const String defaultAssetPath = 'packages/seatlayer/assets/index.html'; + /// Production uses the immutable hosted page. Override only for a bundled, + /// self-contained demo or test fixture. + static const String defaultAssetPath = seatLayerMobilePageUrl; /// Event key, e.g. `ev_xxx`. Required. final String event; @@ -60,11 +99,24 @@ class SeatLayerConfiguration { /// booked seats render hollow, so state never relies on hue alone. final bool? colorblindSafe; + final SeatLayerViewMode? initialView; + /// Whether the WEB side draws its in-canvas seat tooltip. Leave `false` when /// the app presents its own seat sheet — the default, because a hover tooltip /// is a pointer affordance that does not belong on touch. final bool showsWebSeatTooltip; + /// One-shot private buyer bearer. Prefer [buyerAccessTokenProvider]. + final BuyerAccessToken? buyerAccessToken; + + /// Renews private buyer access in memory without rebuilding the view. + final BuyerAccessTokenProvider? buyerAccessTokenProvider; + + final List? selectedObjects; + final List? selectableObjects; + final int? numberOfPlacesToSelect; + final List? selectionValidators; + /// Native-side deadline for a single command before it fails `sl_timeout`. final Duration commandTimeout; @@ -75,7 +127,7 @@ class SeatLayerConfiguration { /// bundle-side diagnostics. final Map hostInfo; - /// The Flutter asset key the WebView loads. + /// Hosted production URL, or a Flutter asset key for an explicit fixture. final String assetPath; /// The `init` payload: `{ protocol, host, chrome, config }`. @@ -94,7 +146,23 @@ class SeatLayerConfiguration { if (locale != null) config['locale'] = locale; if (currency != null) config['currency'] = currency; if (colorblindSafe != null) config['colorblindSafe'] = colorblindSafe; + if (initialView != null) config['initialView'] = initialView!.raw; if (messages != null) config['messages'] = messages; + if (buyerAccessToken != null) { + config['buyerAccessToken'] = buyerAccessToken!.toJson(); + } + if (buyerAccessTokenProvider != null) config['nativeAccessProvider'] = true; + if (selectedObjects != null) config['selectedObjects'] = selectedObjects; + if (selectableObjects != null) { + config['selectableObjects'] = selectableObjects; + } + if (numberOfPlacesToSelect != null) { + config['numberOfPlacesToSelect'] = numberOfPlacesToSelect; + } + if (selectionValidators != null) { + config['selectionValidators'] = + selectionValidators!.map((validator) => validator.toJson()).toList(); + } return { 'protocol': protocolRange.toJson(), @@ -103,4 +171,13 @@ class SeatLayerConfiguration { 'config': config, }; } + + bool get usesPrivateAccess => + buyerAccessToken != null || buyerAccessTokenProvider != null; + + bool get usesSelectionPolicy => + selectedObjects != null || + selectableObjects != null || + numberOfPlacesToSelect != null || + selectionValidators != null; } diff --git a/lib/src/seat_layer_controller.dart b/lib/src/seat_layer_controller.dart index 8a6240f..6aa12f7 100644 --- a/lib/src/seat_layer_controller.dart +++ b/lib/src/seat_layer_controller.dart @@ -5,6 +5,7 @@ import 'package:meta/meta.dart'; import 'bridge/bridge_client.dart'; import 'bridge/bridge_protocol.dart'; import 'bridge/envelope.dart'; +import 'open_enums.dart'; import 'payloads.dart'; import 'seat_layer_configuration.dart'; import 'seat_layer_error.dart'; @@ -37,6 +38,17 @@ class SeatLayerController { final _onReady = StreamController.broadcast(); final _onSelectionChanged = StreamController>.broadcast(); + final _onSelectionValidityChanged = + StreamController.broadcast(); + final _onSelectionValid = StreamController>.broadcast(); + final _onSelectionInvalid = StreamController.broadcast(); + final _onSelectionLimit = StreamController.broadcast(); + final _onBuyerAccessExpired = + StreamController.broadcast(); + final _onBuyerAccessUnavailable = + StreamController.broadcast(); + final _onSelectedObjectsUnavailable = + StreamController.broadcast(); final _onHold = StreamController.broadcast(); final _onHoldRestored = StreamController.broadcast(); final _onHoldExpired = StreamController.broadcast(); @@ -71,6 +83,19 @@ class SeatLayerController { Stream> get onSelectionChanged => _onSelectionChanged.stream; + Stream get onSelectionValidityChanged => + _onSelectionValidityChanged.stream; + Stream> get onSelectionValid => _onSelectionValid.stream; + Stream get onSelectionInvalid => + _onSelectionInvalid.stream; + Stream get onSelectionLimit => _onSelectionLimit.stream; + Stream get onBuyerAccessExpired => + _onBuyerAccessExpired.stream; + Stream get onBuyerAccessUnavailable => + _onBuyerAccessUnavailable.stream; + Stream get onSelectedObjectsUnavailable => + _onSelectedObjectsUnavailable.stream; + /// A hold was created or updated. Stream get onHold => _onHold.stream; @@ -80,10 +105,8 @@ class SeatLayerController { /// The open hold expired server-side. The seats are gone; return to selection. Stream get onHoldExpired => _onHoldExpired.stream; - /// A checkout signal from the bundle. NOTE: the v1 bridge protocol defines no - /// `checkout` event, so this never fires with the vendored 0.26.0 bundle; it - /// is wired through the forward-compatible event path for a future bundle that - /// emits one. Drive checkout from your own UI after a successful hold today. + /// A checkout signal from a future bundle. The current v1 mobile bridge does + /// not advertise this event; drive checkout from your own UI after a hold. Stream get onCheckout => _onCheckout.stream; /// Something failed out of band. A failed COMMAND does not appear here — it @@ -191,6 +214,26 @@ class SeatLayerController { case NegotiationAgreed(): final config = _configuration; if (config != null) { + if (config.usesPrivateAccess && + !info.supportsCapability('native-access-provider')) { + _finishHandshake(SeatLayerError.incompatible( + native: ProtocolRange.native, + web: info.protocolRange, + reason: 'the bundle does not support private buyer access', + )); + return; + } + if (config.usesSelectionPolicy && + (!info.supportsCapability('selection-controls') || + !info.supportsCapability('selection-validity'))) { + _finishHandshake(SeatLayerError.incompatible( + native: ProtocolRange.native, + web: info.protocolRange, + reason: + 'the bundle does not support the configured selection policy', + )); + return; + } unawaited(_client?.sendInit(config.initPayload()) ?? Future.value()); } } @@ -218,6 +261,32 @@ class SeatLayerController { _onSelectionChanged.add( _decodeList(jGetLocal(payload, 'seats'), SelectedSeat.fromJson), ); + case 'selection.validity.changed': + final validity = + SelectionValidity.fromJson(jGetLocal(payload, 'validity')); + if (validity != null) _onSelectionValidityChanged.add(validity); + case 'selection.valid': + _onSelectionValid.add( + _decodeList(jGetLocal(payload, 'seats'), SelectedSeat.fromJson), + ); + case 'selection.invalid': + final validity = + SelectionValidity.fromJson(jGetLocal(payload, 'validity')); + if (validity != null) _onSelectionInvalid.add(validity); + case 'selection.limit': + final maximum = jIntLocal(jGetLocal(payload, 'maxSelection')); + if (maximum != null) _onSelectionLimit.add(maximum); + case 'access.token.request': + unawaited(_provideBuyerAccessToken(payload)); + case 'access.expired': + final event = BuyerAccessExpiredEvent.fromJson(payload); + if (event != null) _onBuyerAccessExpired.add(event); + case 'access.unavailable': + final event = BuyerAccessUnavailableEvent.fromJson(payload); + if (event != null) _onBuyerAccessUnavailable.add(event); + case 'selection.unavailable': + final event = SelectedObjectUnavailableEvent.fromJson(payload); + if (event != null) _onSelectedObjectsUnavailable.add(event); case 'hold.changed': final hold = HoldResult.fromJson(jGetLocal(payload, 'hold')); if (hold != null) _onHold.add(hold); @@ -247,6 +316,57 @@ class SeatLayerController { } } + Future _provideBuyerAccessToken(Object? payload) async { + final requestId = jStrLocal(jGetLocal(payload, 'requestId')); + if (requestId == null) return; + final client = _client; + if (client == null) return; + final rawReason = jStrLocal(jGetLocal(payload, 'reason')) ?? 'initial'; + final provider = _configuration?.buyerAccessTokenProvider; + + if (provider == null) { + try { + await client.command( + 'access.token.unavailable', + payload: {'requestId': requestId}, + ); + } catch (_) {} + return; + } + + BuyerAccessToken token; + try { + token = await provider(BuyerAccessRequestContext( + reason: BuyerAccessRefreshReason.fromRaw(rawReason), + )); + if (token.token.trim().isEmpty || token.expiresAt?.isFinite == false) { + throw StateError('invalid buyer access token'); + } + } catch (_) { + // Provider failures are sanitized; errors and bearers never become events. + try { + await client.command( + 'access.token.unavailable', + payload: {'requestId': requestId}, + ); + } catch (_) {} + return; + } + + try { + await client.command( + 'access.token.provide', + payload: { + 'requestId': requestId, + 'token': token.token, + if (token.expiresAt != null) 'expiresAt': token.expiresAt, + }, + ); + } catch (_) { + // The request may have timed out or the view may have reloaded. + } + } + void _finishHandshake(Object outcome) { if (_hasFinished) { // A failure AFTER ready (a late sys.error / error) is a stream event, not @@ -349,6 +469,43 @@ class SeatLayerController { return _decodeList(jGetLocal(result, 'seats'), SelectedSeat.fromJson); } + Future> selectObjects(List objects) async { + final result = await _run('selectObjects', {'objects': objects}); + return _decodeList(jGetLocal(result, 'seats'), SelectedSeat.fromJson); + } + + Future deselectObjects(List objects) => + _run('deselectObjects', {'objects': objects}); + + Future clearSelection() => _run('clearSelection'); + + Future> selectCategories(List categoryKeys) async { + final result = + await _run('selectCategories', {'categoryKeys': categoryKeys}); + return _decodeList(jGetLocal(result, 'seats'), SelectedSeat.fromJson); + } + + Future deselectCategories(List categoryKeys) => + _run('deselectCategories', {'categoryKeys': categoryKeys}); + + Future setSelectableObjects(List? objects) => + _run('setSelectableObjects', {'objects': objects}); + + Future setMaxSelection(int maximum) { + if (maximum < 1) return Future.error(ArgumentError.value(maximum)); + return _run('setMaxSelection', {'maxSelection': maximum}); + } + + Future getSelectionValidity() async { + final result = await _run('getSelectionValidity'); + return SelectionValidity.fromJson(jGetLocal(result, 'validity')); + } + + Future refreshAccess() async { + final result = await _run('refreshAccess'); + return jBoolLocal(jGetLocal(result, 'refreshed')) ?? false; + } + /// The open hold, if any. Future getCurrentHold() async { final result = await _run('getCurrentHold'); @@ -373,6 +530,16 @@ class SeatLayerController { Future setColorblindSafe(bool on) => _run('setColorblindSafe', {'on': on}); + Future setViewMode(SeatLayerViewMode mode) => + _run('setViewMode', {'mode': mode.raw}); + + Future getViewMode() async { + final result = await _run('getViewMode'); + return SeatLayerViewMode.fromRaw( + jStrLocal(jGetLocal(result, 'mode')) ?? 'flat', + ); + } + Future zoomIn() => _run('zoomIn'); Future zoomOut() => _run('zoomOut'); Future zoomToFit() => _run('zoomToFit'); @@ -396,6 +563,13 @@ class SeatLayerController { _client?.close(); _onReady.close(); _onSelectionChanged.close(); + _onSelectionValidityChanged.close(); + _onSelectionValid.close(); + _onSelectionInvalid.close(); + _onSelectionLimit.close(); + _onBuyerAccessExpired.close(); + _onBuyerAccessUnavailable.close(); + _onSelectedObjectsUnavailable.close(); _onHold.close(); _onHoldRestored.close(); _onHoldExpired.close(); @@ -435,3 +609,4 @@ Object? jGetLocal(Object? v, String key) => v is Map ? (v.cast())[key] : null; String? jStrLocal(Object? v) => v is String ? v : null; bool? jBoolLocal(Object? v) => v is bool ? v : null; +int? jIntLocal(Object? v) => v is int ? v : (v is num ? v.toInt() : null); diff --git a/lib/src/seat_layer_view.dart b/lib/src/seat_layer_view.dart index 86640ef..b2484b0 100644 --- a/lib/src/seat_layer_view.dart +++ b/lib/src/seat_layer_view.dart @@ -19,7 +19,8 @@ const String _channelName = 'SeatLayer'; /// A seat map. /// -/// Hosts a [WebViewWidget] running the vendored SeatLayer bundle, performs the +/// Hosts a [WebViewWidget] running the immutable, version-pinned SeatLayer +/// mobile page, performs the /// bridge handshake, and drives the chart through the supplied /// [SeatLayerController]. /// @@ -82,6 +83,10 @@ class _SeatLayerViewState extends State { ) ..setNavigationDelegate( NavigationDelegate( + onNavigationRequest: (request) => + _allowsNavigation(widget.configuration.assetPath, request.url) + ? NavigationDecision.navigate + : NavigationDecision.prevent, onWebResourceError: (error) { // Only a hard failure of the main document should fail the load; // sub-resource noise must not abort a working chart. @@ -112,12 +117,32 @@ class _SeatLayerViewState extends State { )); try { - await _web.loadFlutterAsset(widget.configuration.assetPath); + final location = widget.configuration.assetPath; + final uri = Uri.tryParse(location); + if (uri != null && (uri.scheme == 'https' || uri.scheme == 'http')) { + await _web.loadRequest(uri); + } else { + await _web.loadFlutterAsset(location); + } } catch (e) { widget.controller.failWithTransport('could not load asset: $e'); } } + bool _allowsNavigation(String configuredLocation, String requestedLocation) { + final configured = Uri.tryParse(configuredLocation); + if (configured != null && + (configured.scheme == 'https' || configured.scheme == 'http')) { + return requestedLocation == configuredLocation; + } + + // `loadFlutterAsset` resolves a package key to a platform-specific file URL. + // Only that explicit fixture document is allowed to become the main page. + final requested = Uri.tryParse(requestedLocation); + return requested?.scheme == 'file' && + Uri.decodeComponent(requested!.path).endsWith(configuredLocation); + } + @override Widget build(BuildContext context) { final view = WebViewWidget( diff --git a/pubspec.yaml b/pubspec.yaml index 2abd8b7..cbd11d3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: seatlayer description: >- Official Flutter SDK for embedding interactive SeatLayer reserved-seating maps with holds, best available, live events, and a versioned WebView bridge. -version: 0.1.3 +version: 0.2.0 homepage: https://seatlayer.io/developers/ repository: https://github.com/seatlayer/seatlayer-flutter issue_tracker: https://github.com/seatlayer/seatlayer-flutter/issues diff --git a/test/payloads_test.dart b/test/payloads_test.dart index dd28e90..9752a20 100644 --- a/test/payloads_test.dart +++ b/test/payloads_test.dart @@ -1,8 +1,18 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:seatlayer/src/open_enums.dart'; import 'package:seatlayer/src/payloads.dart'; +import 'package:seatlayer/src/seat_layer_configuration.dart'; void main() { + test('hosted and fixture versions remain distinct and pinned', () { + expect(seatLayerHostedWebVersion, '0.66.0'); + expect(seatLayerLegacyFixtureWebVersion, '0.59.0'); + expect( + seatLayerMobilePageUrl, + 'https://cdn.seatlayer.io/seatlayer-js@$seatLayerHostedWebVersion/mobile.html', + ); + }); + group('open enums — unknown values never throw', () { test('a known seat status maps to its case', () { expect(SeatStatus.fromRaw('booked'), same(SeatStatus.booked)); @@ -146,4 +156,60 @@ void main() { expect(info.supportsCapability('ga'), isTrue); }); }); + + test('private selection configuration matches the mobile bridge contract', + () { + final configuration = SeatLayerConfiguration( + event: 'ev_private', + buyerAccessToken: + const BuyerAccessToken(token: 'bse_seed', expiresAt: 123), + buyerAccessTokenProvider: (context) => + BuyerAccessToken(token: 'bse_${context.reason.raw}'), + selectedObjects: const ['A-1'], + selectableObjects: const ['A-1', 'A-2'], + numberOfPlacesToSelect: 2, + selectionValidators: const [ + MinimumSelectedPlaces(2), + ConsecutiveSeats(), + ], + initialView: SeatLayerViewMode.perspective, + ); + final config = + configuration.initPayload()['config']! as Map; + expect(config['buyerAccessToken'], { + 'token': 'bse_seed', + 'expiresAt': 123.0, + }); + expect(config['nativeAccessProvider'], isTrue); + expect(config['selectedObjects'], ['A-1']); + expect(config['numberOfPlacesToSelect'], 2); + expect(config['selectionValidators'], [ + {'type': 'minimumSelectedPlaces', 'minimum': 2}, + {'type': 'consecutiveSeats'}, + ]); + expect(config['initialView'], 'perspective'); + expect(configuration.usesPrivateAccess, isTrue); + expect(configuration.usesSelectionPolicy, isTrue); + }); + + test('selection and access event decoders preserve unknown values', () { + final validity = SelectionValidity.fromJson({ + 'isValid': false, + 'count': 1, + 'required': 2, + 'remaining': 1, + 'seats': [ + {'id': 's1', 'label': 'A-1'}, + ], + 'violations': ['futureRule'], + })!; + expect(validity.seats.single.label, 'A-1'); + expect(validity.violations.single.raw, 'futureRule'); + + final access = BuyerAccessUnavailableEvent.fromJson({ + 'reason': 'future_access_state', + 'retryable': false, + })!; + expect(access.reason.raw, 'future_access_state'); + }); }