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
CustomPainterdraws thousands of seats at 60 fps; pan and pinch/scroll zoom viaInteractiveViewerwith 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 typedSelectionResult; rejections are values, not exceptions. - Seven built-in layout generators —
sleeper48Layout,sleeper44Layout,sleeper60Layout,theaterGrid,busLayout,restaurantTables,gaSection— or load any custom layout from JSON. - Fully serializable — every model has
toJson/fromJsonso 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 |
|---|---|
![]() |
![]() |
| 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:
- Place type — Bus, Theater, Restaurant, ...
- 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 back — id, 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()orSelectionDecision.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 typeddatapayload. 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
canSelectpredicate inspectstag(or any other field) and returnsSelectionDecision.deny(message: ...)for seats that violate the rule. The kit runs this check after its own built-in rules pass and surfaces the denial throughmessageBuilder. - 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
canSelectchanges; 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/toJsonare hand-written, not generated byjson_serializable(seelib/src/models/seat_map.dart).json_serializable6.13.x crashes on aList<GenericClass<T>>field (elements: List<SeatElement<T>>) inside a@Freezed(genericArgumentFactories: true)class. Do not delete the hand-written_$SeatMapFromJson/_$SeatMapToJsonfunctions or re-add aseat_map.g.dartpart —flutter pub run build_runner buildwill 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.
Libraries
- seat_kit
- Models, rendering, and selection tooling for interactive seat maps.



