seat_kit 0.2.0 copy "seat_kit: ^0.2.0" to clipboard
seat_kit: ^0.2.0 copied to clipboard

A Flutter toolkit for modeling, rendering, and selecting seats on interactive seat maps for events and venues.

seat_kit #

A Flutter toolkit for modeling, rendering, and selecting seats on interactive seat maps for events and venues.

Features #

  • Canvas-based rendering — a single CustomPainter draws thousands of seats at 60 fps; pan and pinch/scroll zoom via InteractiveViewer with correct hit-testing at any zoom level.
  • Selection logic — a Riverpod notifier (SeatSelectionController) enforces configurable rules: max selectable, row-adjacency, availability gating. Every mutation returns a typed SelectionResult; rejections are values, not exceptions.
  • Seven built-in layout generatorssleeper48Layout, sleeper44Layout, sleeper60Layout, theaterGrid, busLayout, restaurantTables, gaSection — or load any custom layout from JSON.
  • Fully serializable — every model has toJson / fromJson so layouts can be loaded from a backend, a bundled asset, or generated at runtime.
  • Accessible visuals — status communicated by shape glyph and color; themed via SeatMapTheme.

Screenshots #

Filled seat style Outlined seat style
[Filled seat style] [Outlined seat style]
Filtering Continue — returned payload
[Filtering] [Continue — returned payload]

Installation #

dependencies:
  seat_kit: ^0.0.1
  flutter_riverpod: ^3.3.1   # required peer dependency
flutter pub get

Quick start #

Wrap your app in a ProviderScope (required by Riverpod), generate a map with one of the built-in templates, and drop in SeatMapView:

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:seat_kit/seat_kit.dart';

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(home: const VenuePage());
  }
}

class VenuePage extends StatelessWidget {
  const VenuePage({super.key});

  @override
  Widget build(BuildContext context) {
    // Generate a 10-row × 20-column theatre map with a 4-seat limit.
    final seatMap = theaterGrid(
      rows: 10,
      cols: 20,
      aisleAfterColumns: [5, 15],
      selectionRules: const SelectionRules(maxSelectable: 4),
    );

    return Scaffold(
      appBar: AppBar(title: Text(seatMap.name)),
      body: SeatMapView(
        seatMap: seatMap,
        // Called whenever the user's selection changes.
        onSelectionChanged: (selected) {
          debugPrint('Selected: ${selected.map((s) => s.label).join(', ')}');
        },
        // Called for every tap — including rejections.
        onTapResult: (result) {
          if (result case SelectionRejected(:final reason)) {
            debugPrint('Rejected: $reason');
          }
        },
      ),
    );
  }
}

The generic <T> payload #

SeatMap<T> and SeatElement<T> carry an optional, opaque developer payload T on every seat. The kit never reads or interprets data — it just stores it and hands it back. Use SeatMap<dynamic> (or omit the type argument, as the examples above do) when you don't need a typed payload.

class FareInfo {
  const FareInfo({required this.gst});
  final double gst;
}

final seatMap = theaterGrid<FareInfo>(
  rows: 10,
  cols: 20,
  dataBuilder: (seatId, seatType) => const FareInfo(gst: 0.18),
);

SeatMapView<FareInfo>(
  seatMap: seatMap,
  onSelectionChanged: (selected) {
    // `selected` is `List<SeatElement<FareInfo>>` — `seat.data` is typed,
    // no secondary lookup needed.
    for (final seat in selected) {
      debugPrint('${seat.label}: gst=${seat.data?.gst}');
    }
  },
);

Seat style: filled vs outlined #

SeatMapView.seatStyle controls how available, unselected seats are painted:

  • SeatStyle.filled (default) — the seat is filled solid with its category/status color, with the label and price drawn in white.
  • SeatStyle.outlined — the seat renders with a white/transparent fill and a colored border, with the label and price drawn in the category/status color — the seat type is shown by color, not by fill.
SeatMapView(
  seatMap: seatMap,
  seatStyle: SeatStyle.outlined,
)

Booked/unavailable seats (sold, held, blocked, accessible, or any tag-colored seat such as "ladies") and selected seats always render with a solid fill, in both styles, so they stay clearly visible regardless of which seatStyle is chosen.

Filtering #

Pass an opt-in predicate to dim and disable seats that don't match the current view — e.g. "show only available seats" or "show only the lower berths". Non-matching seats render at reduced opacity and are excluded from tap hit-testing; null (the default) means no filtering.

SeatMapView(
  seatMap: seatMap,
  filter: (seat) => seat.status == SeatStatus.available,
)

SeatFilterBar is a ready-made row of toggle cards — one per SeatCategory, plus an optional "Available only" chip — that emits exactly this predicate shape via onPredicateChanged:

SeatFilterBar(
  categories: myCategories,
  onPredicateChanged: (predicate) => setState(() => _filter = predicate),
)

// ... later
SeatMapView(seatMap: seatMap, filter: _filter)

Cards start off: with nothing active, every seat is visible. Activating one or more category cards shows only those category/categories (the union of active cards) and dims the rest; activating "Available only" ANDs an availability check on top. Deactivating every card returns to "everything visible".

Pass categories: myCategories as the distinct categories present in the current SeatMap (e.g. collected from seatMap.elements) rather than a fixed list — that way the chips always match whichever layout is loaded, and rebuild (with selections reset) whenever the categories list changes.

SeatFilter is the underlying immutable value class (allowedCategoryIds + onlyAvailable) if you want to build the predicate yourself.

Custom layouts from JSON #

Load any layout stored in a bundled asset or fetched from a backend:

import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:seat_kit/seat_kit.dart';

Future<SeatMap<dynamic>> loadVenueMap() async {
  final raw = await rootBundle.loadString('assets/maps/my_venue.json');
  return SeatMap.fromJson(
    jsonDecode(raw) as Map<String, dynamic>,
    (data) => data,
  );
}

See doc/templates.md for the full JSON schema and generator reference.

Reading selection state directly #

SeatSelectionController is a standard Riverpod notifier keyed by SeatMap, so you can observe or drive it from anywhere in your widget tree:

class BookingButton extends ConsumerWidget {
  const BookingButton({super.key, required this.seatMap});

  final SeatMap seatMap;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final selected = ref.watch(seatSelectionControllerProvider(seatMap));

    return FilledButton(
      onPressed: selected.isEmpty ? null : () => _checkout(selected),
      child: Text('Book ${selected.length} seat(s)'),
    );
  }

  void _checkout(Set<String> seatIds) {
    // Hand the selected ids to your booking service here.
  }
}

Template generators #

Function Description
sleeper48Layout(...) Flagship 48-seat sleeper coach: SU/SL/seater/DU columns, driver cabin, chassis + door
sleeper44Layout(...) 44-seat sleeper coach: SU/SL + stacked seaters over DL berths + DU berths
sleeper60Layout(...) 60-seat sleeper coach: CU, C-seaters over CL, D-seaters over DL, DU — mirrored across the aisle
theaterGrid(rows, cols, ...) Grid with row-letter labels, aisle gaps, optional curved rows
busLayout(rows, ...) Coach-bus 2+2 layout with central aisle and optional rear bench
restaurantTables(tables) Scattered tables with seats distributed around them
gaSection(zones) Capacity-based GA zones (one selectable element per zone)

See the example/ folder for a fully runnable demo across all templates.

Developer showcase (example app) #

The example/ app is a small developer showcase, not just a single demo screen. The top bar has two dropdowns:

  1. Place type — Bus, Theater, Restaurant, ...
  2. Layout — the named layouts available for that place type (e.g. Bus → "48-seater Sleeper").

Bus has three real layouts wired up to a SeatMap — "48-seater Sleeper" (sleeper48Layout, the flagship), "44-seater Sleeper" (sleeper44Layout), and "60-seater Sleeper" (sleeper60Layout). Every other entry is listed as disabled / "coming soon" so the structure is visible without rendering a fake layout.

The filter bar's category chips are derived dynamically from whichever layout is selected (see SeatFilterBar), so switching between the 48/44/60-seater layouts automatically shows that layout's real seat types (e.g. DL appears for the 44- and 60-seaters, CU/CL for the 60-seater).

Screenshot placeholder: showcase top bar with the Place type / Layout dropdowns and the rendered Sleeper 48 bus.

How layouts are organized #

example/lib/layout_registry.dart holds a tiny placeType -> layouts -> builder registry:

final List<PlaceTypeEntry> placeTypeRegistry = [
  PlaceTypeEntry(
    name: 'Bus',
    layouts: [
      LayoutEntry(name: '48-seater Sleeper', builder: () => busDemoMap()),
      LayoutEntry(name: '44-seater Sleeper', builder: () => bus44DemoMap()),
      LayoutEntry(name: '60-seater Sleeper', builder: () => bus60DemoMap()),
    ],
  ),
  const PlaceTypeEntry(
    name: 'Theater',
    layouts: [
      LayoutEntry(name: 'Standard Grid'),   // builder: null -> "coming soon"
      LayoutEntry(name: 'Curved Orchestra'),
      LayoutEntry(name: 'Balcony'),
    ],
  ),
  // ...
];

A LayoutEntry with builder: null is shown disabled in the dropdown and renders a "coming soon" placeholder if selected. To add a real layout, give it a builder that returns a SeatMap<dynamic> — no other UI changes are needed.

Continue → returned payload #

Once one or more seats are selected, a Continue button appears (in the side panel on wide screens, in the bottom bar on narrow screens). Pressing it opens a dialog showing the exact data SeatMapView.onSelectionChanged hands backid, label, status, category id, resolved price, tag, and the typed data payload — as formatted JSON, plus the app-side total (the kit itself never sums prices).

Screenshot placeholder: Continue dialog showing the JSON payload for a couple of selected seats.

App-owned business rules: canSelect and messageBuilder #

The kit enforces a small set of generic selection rules (status, max selectable, row-adjacency). Anything beyond that — "don't let this user book next to a seat reserved for someone else", loyalty-tier gating, party-size rules, etc. — is app policy, not something the kit should hardcode or even know the shape of. Two optional SeatMapView hooks cover this:

canSelect — the business-rule escape hatch #

final SelectionDecision Function(
  SeatElement<T> seat,
  SeatMap<T> seatMap,
  Set<String> selectedIds,
)? canSelect;
  • Evaluated only on selection attempts (not deselection), and only after the controller's built-in enum/max/adjacency checks already pass.
  • Returns SelectionDecision.allow() or SelectionDecision.deny(reason: ..., message: ...).
  • null (the default) means "no extra rule" — only the built-in checks apply.
  • The predicate can read anything on the seat/map: status, category, tag, position, neighbouring seats, even the typed data payload. The kit never inspects why you deny a seat — it just runs the function.

A SelectionDenied rejection surfaces as SelectionResult.rejected(reason, message: ...), where reason defaults to SelectionRejectionReason.blockedByRule.

messageBuilder — app-owned rejection text #

final String Function(SeatElement<T> seat, SelectionRejection reason)?
    messageBuilder;

When a tap is rejected (by a built-in rule or by canSelect), SeatMapView calls messageBuilder(seat, reason) and shows the returned string in a SnackBar. The kit ships zero hardcoded rejection strings — every message ("Seat already booked", "Maximum seats reached", ...) is supplied by the host app, which can tailor wording per seat (e.g. by tag) and per SelectionRejectionReason.

Worked example: ladies-adjacency as an app rule #

The showcase's Bus → 48-seater Sleeper demo seeds two seats (SL1, SL3) with tag: 'ladies' and a non-selectable status (sold / held — a ladies seat is never available with only a tag). A "Enforce ladies-adjacency rule" toggle in the panel swaps canSelect between null and a predicate that denies selecting any seat that is an immediate row-neighbor of a 'ladies'-tagged seat:

SelectionDecision _ladiesAdjacencyCanSelect(
  SeatElement<dynamic> seat,
  SeatMap<dynamic> map,
  Set<String> selectedIds,
) {
  final isAdjacentToLadies = map.elements.any(
    (other) => other.tag == 'ladies' && _isAdjacentSeat(seat, other, map),
  );
  return isAdjacentToLadies
      ? const SelectionDecision.deny(
          message: "Can't book next to a ladies seat",
        )
      : const SelectionDecision.allow();
}

// ...

SeatMapView<dynamic>(
  seatMap: map,
  canSelect: enforceLadiesAdjacency ? _ladiesAdjacencyCanSelect : null,
  messageBuilder: (seat, reason) => switch (reason.reason) {
    SelectionRejectionReason.seatUnavailable => seat.tag == 'ladies'
        ? 'Seat occupied by ladies'
        : 'Seat already booked',
    SelectionRejectionReason.maxReached => 'Maximum seats reached',
    SelectionRejectionReason.blockedByRule =>
      reason.message ?? 'Selection not allowed',
    // ...
  },
)

With the toggle off, canSelect: null — the kit applies only its built-in rules, so a seat next to SL3 selects normally. With it on, the same tap is denied with the app-supplied "Can't book next to a ladies seat" message — entirely app policy, expressed without the kit knowing what "ladies" or "adjacency rule" mean.

Gender / "ladies seat" rules: who owns what #

The kit has no concept of gender, passenger identity, or a "ladies rule". It will never grow a gender field, a passengerGender parameter, or a built-in adjacency rule for women-only seats. SeatElement.tag (used as 'ladies' in the example above) is a plain, app-defined string — the kit stores it, lets you color/label it, and otherwise never looks at it. canSelect is the complete mechanism for rules like this; the kit's only job is to run the predicate the app hands it.

What the app does with canSelect depends entirely on when it knows the prospective passenger's gender relative to seat selection:

  • Known before selection — if your booking flow collects gender (or reads it from a profile) before the user picks seats, you can enforce an adjacency-style rule at selection time exactly like the worked example above: a canSelect predicate inspects tag (or any other field) and returns SelectionDecision.deny(message: ...) for seats that violate the rule. The kit runs this check after its own built-in rules pass and surfaces the denial through messageBuilder.
  • Known only after selection — if gender is collected after the seats are chosen (e.g. during passenger details / checkout), the rule simply cannot be applied during selection — the information doesn't exist yet. In that case the app enforces it later, in its own gender-entry step (for example, validating the completed booking and asking the user to change a seat if it conflicts). This requires no kit support and no canSelect changes; the kit takes no position on this case at all.

In both cases, canSelect and SelectionDecision are the same generic, gender-agnostic API — the kit only ever runs the app's predicate and reports its allow / deny decision.

Notes for maintainers #

  • SeatMap<T>.fromJson / toJson are hand-written, not generated by json_serializable (see lib/src/models/seat_map.dart). json_serializable 6.13.x crashes on a List<GenericClass<T>> field (elements: List<SeatElement<T>>) inside a @Freezed(genericArgumentFactories: true) class. Do not delete the hand-written _$SeatMapFromJson / _$SeatMapToJson functions or re-add a seat_map.g.dart part — flutter pub run build_runner build will not regenerate JSON for this file, and that's intentional.

Screenshots #

Place showcase screenshots/GIFs in screenshots/ (referenced from pub.flutter-io.cn's package page and this README). See screenshots/README.md for the expected files.

Additional information #

  • Requires Dart SDK ≥ 3.11 and Flutter ≥ 1.17.
  • Licensed under the MIT License.
  • File issues and contributions at the package repository.
1
likes
160
points
6
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A Flutter toolkit for modeling, rendering, and selecting seats on interactive seat maps for events and venues.

Repository (GitHub)
View/report issues

Topics

#seat-map #booking #widget #canvas #riverpod

License

MIT (license)

Dependencies

flutter, flutter_riverpod, freezed_annotation, json_annotation, riverpod_annotation

More

Packages that depend on seat_kit