harbor

Every edge of the screen, claimed once.

Every screen is a rectangle, and something is always pushing in from its edges: the status bar, the home indicator, a notch, a TV's overscan, the keyboard, and your own headers, tab bars and composers. harbor gives each of them a place and a rule, so your content stops doing inset arithmetic.

Every edge of the screen is claimed once, by the layer that owns it, and everything inside gets what is left. The platform claims the system edges and the keyboard, your bars claim theirs, and your content gets the room.

The harbour illustration beside a phone running harbor. An overlay bar, drawn as a pier, is the header rows scroll under; a solid bar, drawn as a quay, is the tab bar the list stops at.

Left, the harbour illustration. Right, a phone running the real package, driven by the same clock. The GIF is the opening; the narrated video (three and a half minutes) tours everything in the package on a phone, from the system edges to widget-test trials. The wide-format video (one minute) shows what changes on other screens: a rail on a tablet, right-to-left, a phone on its side, a dual-screen hinge, and a television's title-safe band.

Try every class in your browser: the Harbor Field Guide, with a pretend phone for each class, its options, and a gauge to bring the keyboard in and out.

Depends on the Flutter SDK only. Trials for widget tests come in a package of their own, harbor_test, so the test framework stays out of your app's dependencies.

Installing

flutter pub add harbor
flutter pub add dev:harbor_test   # trials, for widget tests

Using Material? harbor_material has the Material pieces an app would otherwise write around harbor by hand: HarborMaterialFrame in place of a Scaffold, an AppBar in a bar with its colour behind the status bar, harbor's defaults from MaterialLocalizations, a SnackBar-look toast and a Material bottom sheet.

MIT licensed.

The one rule

A frame has layers: the body (your content) at the bottom, bars above it, floats, popovers and toasts above those, and the edges (the system edges and the keyboard) above everything. A system edge is an inset at an edge of the frame (the status bar at the top); HarborEdge is the side of the frame itself.

Something on the same layer takes space. Something on a higher layer becomes padding for everything beneath it.

  • A solid bar (HarborBar.solid): the body stops at it, as at Scaffold.appBar. It sits beside the body rather than over it, so it takes its space and the body starts where it ends, like a Column.
  • An overlay bar (HarborBar.overlay): the body runs under it, as under an app bar with extendBodyBehindAppBar, and the body is told, through MediaQuery.padding, how far it reaches.

That's the whole difference between a header your list scrolls under and a header your list starts below. What no edge and no bar covers is the room. HarborRoom.of(context) describes what bounds the room at a point in the tree: how far the system edges, the bars and their fades reach in on each edge, and the margin.

In Flutter's terms

Where harbor resembles a Flutter widget, this is what is different.

harbor Closest Flutter concept The difference that matters
HarborRoot MaterialApp.builder: one per app, above the Navigator, inside the ScaffoldMessenger that MaterialApp wraps around the builder's output It holds the system edges, the keyboard reading and the toasts every route shares. It moves nothing out of the keyboard's way itself
HarborFrame Scaffold Any number of bars on all four edges, each measured. A Scaffold has one app bar, capped at its preferredSize, and one bottom bar
HarborFrame.page A route, which reads the MediaQuery from above the Navigator A frame that does not start a page takes the bars of the frame around it as part of its system edges. A route under HarborRoot needs no .page: the root has no bars, so it lays out the same either way. Write it for a frame inside another frame that should ignore that frame's bars
HarborSystemEdges MediaQuery.padding and viewPadding The same insets, read from MediaQuery, plus a TV's title-safe band (HarborSystemEdges.titleSafe) or fixed edges (HarborSystemEdges.fixed, and HarborSystemEdges.none for goldens)
HarborKeyboard MediaQuery.viewInsets.bottom; Scaffold.resizeToAvoidBottomInset It adds a phase, a settled height, and how much of it still reaches this point (remaining). A resizing Scaffold moves the whole body; here each bar decides. The keyboard stays in viewInsets, never in padding
HarborBar.solid Scaffold.appBar and bottomNavigationBar: the body starts where they end Measured rather than declared, and on any edge: a start bar holds a NavigationRail as a Row would. Several stack
HarborBar.overlay An app bar under Scaffold(extendBodyBehindAppBar: true), a bottom bar under extendBody: true The same mechanism: the body runs under it and its MediaQuery.padding says how far. An overlay bar does it on any edge, and for a stack of bars
HarborEdgeEffect A ShaderMask fade, with a BackdropFilter frost under the bar The fade's length counts toward where content rests, so the band and the first row's resting line never drift apart
HarborClear SafeArea SafeArea reads MediaQuery.padding alone, so it misses the keyboard. HarborClear keeps clear of it at the bottom too, can clear the system edges alone (clearsBars: false) or the bars at rest (follow:), and takes directional edges. Both mark what they cleared as handled, and minimum: is a floor on both
HarborClear.rowInsets Horizontal page padding: a Padding on each row It adds whatever is in the way on the sides (a side cutout, a rail) to the frame's margin, and only the rows that ask get it, so the list itself still runs to the frame's edge
HarborScrollView ListView, CustomScrollView A ListView with no padding pads its ends by MediaQuery.padding but not by the keyboard, and a CustomScrollView pads nothing. A HarborScrollView clears both ends, keyboard included (unless clearsKeyboard: false), and widens every reveal by what covers its edges. HarborSliverClear is the SliverSafeArea of a scroll view you build yourself
HarborSliverBar PinnedHeaderSliver, SliverAppBar(pinned: true) It pins at the bars' face rather than the viewport's edge, several stack, and reveals keep clear of it
HarborRoom.of(context) Content outside any SafeArea that reads MediaQuery.padding itself The room data splits each edge into system edges and bars, which MediaQuery.padding adds together
HarborHandled MediaQuery.removePadding removePadding lowers viewPadding only by the padding it removes; HarborHandled zeroes it, and with clearsKeyboard: the keyboard, and zeroes harbor's own room data, so harbor widgets beneath read zero too. clearsBars: false handles the system edges alone and leaves the bars, which removePadding cannot tell apart, and keepsGeometry: true handles them for clearing alone, leaving them to be measured
HarborKeyboardStance.rides / .stays Rides: the bottom of a resizing Scaffold's body. Stays: Scaffold.bottomNavigationBar, which the keyboard covers Chosen per bar, so a composer can ride the keyboard while the tab bar under it stays
HarborKeyboardStance.reserves None It reserves the keyboard's height whether the keyboard is up or not, so a panel can trade places with it and nothing moves
HarborHideBar Rebuilding the Scaffold without its bottomNavigationBar Asked for from deep in the page and counted. state: HarborBarState.dark keeps the bar's space as Visibility(maintainSize: true) does; HarborBarState.withdrawn, the default, slides it out and gives the space back
HarborAttachedBar ScaffoldState.showBottomSheet, which puts a widget into an ancestor's frame from deep in the tree An attached bar is a bar: it takes its space (or the body runs under it), and leaves with the widget that added it
HarborFloat Scaffold.floatingActionButton; a Stack with Positioned It sits in the room, so it clears the system edges, every bar and the keyboard. A float with a barrier: is modal, as a ModalBarrier makes a page, but it is not a route
HarborPopover OverlayPortal (it is one), as MenuAnchor and RawMenuAnchor use Placement only: it keeps the popover in the room and flips it when its side has no room. It brings no menu semantics, keyboard navigation or tap-outside dismissal; your controller opens and closes it
HarborToasts.show SnackBar, through ScaffoldMessenger.showSnackBar Toasts at one slot take turns, as snack bars do, and a toast's time counts only while it is in sight; toasts at different slots show together. It builds any widget, at one of four heights (HarborToastSlot), clear of the bars of the page that showed it. Both are live regions
showHarborSheet showModalBottomSheet; HarborSheet.draggable is built on DraggableScrollableSheet; barrier: HarborBarrier.none is showBottomSheet Its header and footer are bars, so the body runs under the header and the footer rides the keyboard. harbor imports no Material, so a Material app passes in its theme's pieces (Sheets and dialogs)
HarborBarrier ModalRoute.barrierColor, barrierDismissible, barrierLabel; ModalBottomSheetRoute.barrierOnTapHint One value for all four, taken by sheets, dialogs and floats alike: .dimmed, .clear or .none
HarborDefaults DefaultTextStyle.merge; the parts of a ThemeData that are policy, not look Set once on the root, a frame or a HarborDefaultsScope, merged field by field, and carried into the sheets, dialogs and toasts a page opens. No widget-valued fields: a sheet's surface stays in your own wrapper
showHarborSheet(coversPage: true) None A Scaffold lifts its floating action button over a bottom sheet but leaves the body under it. A sheet that covers the page tells the page that opened it how far it covers, and the page's content keeps clear
HarborKeepVisible Scrollable.ensureVisible, RenderObject.showOnScreen, TextField.scrollPadding A reveal clears the bars and the keyboard of every HarborScrollView it passes through. HarborCoverageListener, how much of a widget the header covers, and HarborLift in a HarborKeepVisibleRegion, lifting content that does not scroll, have no Flutter equivalent
HarborScaledScreen A FittedBox around a MediaQuery with a fixed size The real screen's insets are re-based into the reference screen's coordinates. Under a bare FittedBox, content still reads the real screen's MediaQuery
HarborInspector debugPaintSizeEnabled It draws who holds each edge, at which layer, and the room, and serves the same as data (HarborInspector.snapshot)
pumpHarborTrial tester.view.padding, viewPadding and viewInsets, set with FakeViewPadding Devices come with their status bar, home indicator, keyboard height and folds already set, and assertions are about the room, not numbers

Two names end in State without being a State: HarborKeyboardState is an immutable snapshot of the keyboard, as MediaQueryData is, and HarborBarState is an enum, as AnimationStatus is.

Getting started

Mount the root once, above your Navigator:

import 'package:harbor/harbor.dart';

MaterialApp(
  builder: (context, child) => HarborRoot(
    margin: const EdgeInsets.symmetric(horizontal: 16),
    child: child!,
  ),
  home: const InboxPage(),
);

Every inset harbor takes on more than one edge (a frame's margin and minimum, a scroll view's padding, fixed system edges, a toast's margin) is an EdgeInsetsGeometry, as Padding's is: EdgeInsets keeps to the side it names, and EdgeInsetsDirectional follows the reading direction. A bar's minimum, for its one edge, is a number.

Build a page from a frame:

HarborFrame(
  top: [
    HarborBar.overlay(
      edgeEffect: const HarborEdgeEffect.fade(length: 12, blurSigma: 20),
      child: const InboxHeader(),
    ),
  ],
  bottom: [HarborBar.solid(child: const InboxTabs())],
  body: HarborScrollView(
    slivers: [
      SliverList.builder(
        itemCount: 40,
        itemBuilder: (context, i) => HarborClear.rowInsets(child: MessageRow(i)),
      ),
    ],
  ),
)

What each piece does:

  • The header is an overlay bar. It pads itself below the status bar and frosts the content beneath it.
  • The tab bar is a solid bar. It pads itself above the home indicator, and the body ends at its top.
  • The scroll view runs the full height of the body, under the header. Its first row comes to rest past the header's fade, and its last row rests clear of the tab bar. Whatever sizes the header and the tab bar laid out at, nobody declared a height.
  • The rows keep the row insets, the 16 margin plus any side cutout. The list itself still runs to the frame's edge, so a carousel inside it can too.

Bars

HarborBar.overlay(child: header)        // the body runs under it (extendBodyBehindAppBar)
HarborBar.solid(child: tabBar)          // the body stops at it (Scaffold.appBar)

Bars on one edge are listed in reading order (top and bottom top to bottom, start and end in reading order) and stack, so the first top bar and the last bottom bar are the ones against the edge. Bars on the same layer add up; an overlay bar over a solid bar reaches past it. List solid bars nearer the edge than overlay bars: a solid bar listed inside an overlay bar is reported as an error in debug builds.

A bar takes the system edge on its own side: a top bar pads its child below the status bar, and a bottom bar pads it above the home indicator. Its backdrop is painted under the whole of its space, system edge included. The insets across it (a top bar's sides) stay in the child's MediaQuery for the child to handle, except on a side a start or end bar holds: that bar took the system edge there.

A bar sizes its child the way a Row or Column does: the child spans its edge (for a top or bottom bar, the part between any side bars) and picks its own depth. A NavigationRail in a start bar is as wide as it is in a Row, and an AppBar in a top bar is as tall as its toolbar. Something that fills whatever it is given, like a ListView, needs a size, just as it would in a Row. A PreferredSizeWidget in a top or bottom bar is held to its own height, as Scaffold holds its appBar, so an AppBar with a bottom: (a TabBar) is its toolbar and its bottom high, below the status bar, and keeps a toolbar height that comes from AppBarTheme. That height is the child's intrinsic height, or its preferredSize where the child holds a LayoutBuilder or a ListView, which cannot be asked. A side bar's child is left alone: a preferred size is a height.

HarborBar.reserve(extent: 80) holds an edge for something drawn elsewhere (a footer in an overlay, a bar a parent paints): it reaches 80 in from the edge, system edge included, or 80 past the bars outside it, paints nothing and takes no taps. It is an overlay bar by default (kind: HarborBarKind.solid stops the body at it) and takes a keyboard: like any bar.

A HarborFrame fills the space it is given, as a page does (HarborSizing.fill). sizing: HarborSizing.hug() makes it as tall as its body and bars, as a content-sized sheet is, and HarborSizing.hug(maxFraction: 0.9) caps that at a share of the space above the keyboard (of the whole height when bodyClearsKeyboard is false). systemEdges: replaces the system edges a frame reads from MediaQuery (HarborSystemEdges.fixed(...), HarborSystemEdges.none for goldens, HarborSystemEdges.titleSafe(...) on a TV), on a HarborRoot or any HarborFrame, and minimum: is a floor under what its body keeps clear of on each edge.

The side bars own the corners. A start or end bar runs the frame's full height, and a top or bottom bar runs between them, as a tablet's NavigationRail sits beside its AppBar. A header beside a rail starts where the rail ends: its title lines up with the rows below, and it is not handed the system edge the rail took, only the system edge on the side no rail holds. A rail that withdraws gives its corners back as it goes; one that widens over the page on focus (restingExtent:) holds the header at its resting width.

Option Use it for
edgeEffect: HarborEdgeEffect.fade(length:, blurSigma:, restsAt:) Content fades as it passes under; it rests past the fade (HarborRest.fadeEnd) or at the bar (.barFace)
edgeEffect: HarborEdgeEffect.fade(faceOpacity:, curve:) The fade's ramp: how opaque content is at the bar's face (a quarter by default) and the curve out to the fade's end (a straight line). faceOpacity: 1 is a band that counts toward where content rests but fades nothing past the bar. A fade is an alpha mask, so it takes no colour: tint the background or the bar's backdrop, or paint a scrim with HarborFrame(edgeEffectPainter:)
edgeEffect: HarborEdgeEffect.hairline() A line on the bar's inner face, for a bar content scrolls up to
keyboard: HarborKeyboardStance.rides Rides up on the keyboard, staying on top of it: a composer, a sheet's footer. It sits on the home indicator until the keyboard is taller than it, so it never dips
keyboard: HarborKeyboardStance.stays Stays put; the keyboard covers it: a tab bar (the default)
keyboard: HarborKeyboardStance.reserves Keeps the keyboard's space whether the keyboard is up or down, so nothing moves: a styling panel. It holds that space at the settled keyboard height either way, so its child's HarborKeyboardSpace fills that space when the keyboard is down. It runs to the screen's edge: no system edge, no minimum
state: HarborBarState.dark Not drawn and not tappable, but it keeps its space
state: HarborBarState.withdrawn Slides out and gives its space back
extentPolicy: How a withdrawing bar gives its space back: hold (once it's gone, the default), follow, release
animationStyle: How it moves, as AnimationStyle sets it on Flutter's routes: duration and curve to return or light up, reverseDuration and reverseCurve to withdraw or go dark, AnimationStyle.noAnimation for none. Left unset, 250 ms along Curves.easeInOutCubic
keyboard: HarborKeyboardStance.withdraws Leaves while the keyboard is up and comes back once it is down: a tool strip that would otherwise park mid-screen. It lays out as a bar that stays, so the keyboard covers it as it slides out
restingExtent: The size to hold at rest for a bar that grows, like a rail that opens on focus
minimum: 16 At least this much room on the edge, system edge or not, for the bar outermost on its edge (the one that takes the system edge)
systemEdges: HarborSystemEdgeStance.live / .steady / .none How it takes the system edge on its side: as it is now (gone at the bottom while the keyboard is up; the default for a bar that rides the keyboard), as it is with the keyboard down (the default for one that stays), or not at all (the default for one that reserves)
hitTestBehavior: Opaque by default, so taps on the header never reach rows under it

Content

Role Widget Use it for
Keeps clear HarborClear(edges:, bars:, follow:, keyboard:, rowInsets:, minimum:, padding:) Forms, fixed buttons, static blocks
Keeps clear of one edge HarborClear(edges: {HarborEdge.bottom}) A form footer under a page header: it clears the system edge and the keyboard at the bottom, and leaves the header to the rest of the page
Row insets HarborClear.rowInsets(child:) A row that lines up with the page margin
Scroll view HarborScrollView(slivers:, minimum:, bars:, keyboard:) / HarborScrollView.box(child:) Lists, grids, carousels (scrollDirection: Axis.horizontal)
One sliver HarborSliverClear(sliver:, minimum:, bars:, keyboard:) A sliver in your own CustomScrollView
Pinned header HarborSliverBar(child:) A header or tab strip inside the scroll that pins at the bars' face and stacks
Sticky HarborSticky(child:) A pill that rides with its item, then sticks below the bars and pinned headers
Centered HarborCenter(overlapBudget:) Controls centered in the frame that may overlap the bars by at most a budget
Ignores the bars Builder(builder: (context) { final room = HarborRoom.of(context); ... }) Backgrounds, heroes; room.systemEdges, .bars, .fadeBands, .frameSize

Each one marks what it cleared as handled, so nothing beneath clears it again. HarborHandled does the same for a layer you pad by hand, and HarborScrollView.paddingOf gives a third-party list the right scroll padding.

A third-party list that reads only MediaQuery.padding (a chat list, a feed) already gets, in a frame's body, the larger of the system edges and the bars on every edge. What it misses is the fade, reveal widening, the row insets, minimum and, under bodyClearsKeyboard: false, the keyboard. Give it HarborScrollView.paddingOf(context) if it takes a padding; otherwise rewrite MediaQuery around it yourself, with MediaQuery.of(context).copyWith(padding: ...). That rewrites Flutter's MediaQuery, not harbor's room data, so nothing drifts from harbor's measurements. harbor itself never puts the keyboard into padding.

A scroll view under a header it does not own, which already cleared the top, goes in HarborHandled(edges: {HarborEdge.top}): on that edge MediaQuery and the room data read zero, so the scroll view's first row, the cover its pinned sliver bars pin at and its reveals all leave the top alone, as a Scaffold's body does under its app bar. {HarborEdge.bottom} does the same for a list that stops short of the bottom of the screen. To start the first sliver under the header instead, use startsUnderBars (below).

Content that runs under a bar on purpose, with a scroll view nested further down it, goes in HarborHandled(edges: {HarborEdge.top}, keepsGeometry: true): the edge is handled for clearing alone. Nothing beneath keeps clear of it again (MediaQuery.padding reads zero there, as beneath any layer that cleared it, and so do HarborClear, HarborScrollView and HarborSliverClear), but the system edges and the bars stay in the room data (HarborRoomData.geometryOnly names the edge), so HarborRoom.barFaceOf and HarborRoom.of(context).bars still measure the header for an overlay that lines up with it. A plain HarborHandled would take the header out of both. A detail page whose hero runs under the header and whose tab holds a grid:

HarborScrollView(
  startsUnderBars: true,
  slivers: [
    SliverToBoxAdapter(
      child: Column(children: [
        const HeroImage(),                             // handed the leading end: under the header
        HarborHandled(
          edges: const {HarborEdge.top},
          keepsGeometry: true,                         // the grid's first row rests at its own top
          child: SizedBox(height: 600, child: HarborScrollView(slivers: [grid])),
        ),
      ]),
    ),
  ],
)

The mark is needed only in the first sliver of a scroll view that starts under the bars, which is handed the leading end back so a hero's title can keep off the status bar, or under a scroll view harbor did not build. A scroll view nested in a later sliver of a startsUnderBars view already reads zero there and does not clear the header again: put the grid in its own sliver and it needs no mark. The inspector lists the mark with an amount of zero, since it moves nothing.

A layer that pads by the system edges alone (a page's safe-area margin, read from HarborRoom.of(context).systemEdges) handles the system edges and keeps the bars with HarborHandled(edges: {HarborEdge.bottom}, clearsBars: false). A list inside it still rests on a frosted tab bar: beneath, the bars, their fades and MediaQuery.padding are measured from the layer's edge. A scroll view needs no such option across its scroll: it handles only its own ends, so a carousel's items are handed the bars above and below it as they are.

HarborScrollView(clearsBars: false) rests its ends clear of the system edges alone, not the bars: a list that keeps its last row off the home indicator but scrolls on under a frosted tab bar, or a carousel that runs under a rail. Its ends handle the system edges alone, as HarborHandled(clearsBars: false) does, so its rows still read how far the bars reach, measured from the scroll view's edge, and can pad themselves under them. Pinned sliver bars still pin at the bars' face, reveals still bring a row clear of the bars, and the bottom end still keeps clear of the keyboard while keyboard is true. HarborSliverClear and HarborScrollView.paddingOf take the same clearsBars:.

HarborScrollView(clearsKeyboard: false) leaves the keyboard to someone else, as HarborClear(clearsKeyboard: false) does: a list in a host whose body already ends at the keyboard, or one that stays put while a keyboard opens over it. Its bottom end and its reveals leave the keyboard out, and it does not mark the keyboard handled, so a HarborClear block inside can still keep clear of it, and the scroll view holds still while the keyboard moves. The sliver and paddingOf take it too.

HarborRoom.barFaceOf(context, HarborEdge.top) is how far the header reaches, to its inner face, whether or not it has an edge effect: for artwork that lines up under a frosted header. It holds still while the keyboard moves. It reads zero inside a scroll view, which has handled its ends; start the scroll view under the bars to lay artwork under the header. Beneath HarborHandled(keepsGeometry: true) it still reads the header.

HarborClear(clearsBars: false) keeps clear of the system edges alone, without the bars and, unlike a scroll view, without the keyboard whatever its keyboard: a hero title under a translucent header that must not touch the status bar. To keep clear of the system edges and the keyboard but not a header, keep clear of the bottom edge alone: the header is on the top edge, so it is left to the page. follow: HarborFollow.resting holds still while a bar grows over it. HarborScrollView(startsUnderBars: true) starts its first sliver at the frame's edge, under the bars, for a hero that runs under a translucent header. That sliver is handed back the leading end alone; the trailing end and the keyboard stay handled. minimum: on a scroll view or a HarborSliverClear is a floor on each end, as on SafeArea: the end rests clear of whatever is in the way or the minimum, whichever is larger, so a phone with a home button still keeps 16 under the last row. HarborScrollView.box given no bound across the scroll, as a horizontal one is in a Column, is as thick as its child: a row of chips as tall as the chips, its ends still clear.

A HarborScrollView takes the rest of a CustomScrollView's parameters, with the same names and defaults (the box form those of a SingleChildScrollView): restorationId: brings its scroll position back after the app is restarted, and keyboardDismissBehavior: left unset follows the app's ScrollBehavior. With a center:, both ends of the scroll still rest clear of the bars, and the center sliver starts clear of the leading ones. anchor: is the one that reads differently: it is a fraction of the room between the bars, not of the viewport that runs under them, so anchor: 1 puts the center on a composer's face and lifts it with the keyboard.

Scroll views also draw the edge effect: their content fades as it passes under a bar with a fade, while content that ignores the bars (a background, a hero) is left as it is. Give a HarborFrame an edgeEffectPainter to fade its whole body instead (edgeEffectPainter: HarborFadeMask.alphaFade), or to paint the edge effect your own way (a progressive blur shader).

Scroll views widen every reveal by what covers their trailing edge. A focused field, Scrollable.ensureVisible and focus traversal all bring a row clear of the bars and the keyboard with no extra code.

The keyboard

HarborFrame(bodyClearsKeyboard: true, ...)   // default: the body ends at the keyboard's top
HarborFrame(bodyClearsKeyboard: false, ...)  // the body runs under; content reads it
HarborKeyboard.of(context)                   // height, remaining, settledHeight, phase
HarborKeyboard.isShownOf(context)            // whether it is shown, rebuilding only when that flips

MediaQuery.padding never carries the keyboard. That stays in viewInsets, as Flutter has it, and harbor widgets add it where they keep clear of the bottom. HarborKeyboard.of(context).height is still readable after a frame has moved out of the keyboard's way: for information, never for layout. settledHeight is the last settled keyboard height in this orientation, and it falls as well as rises. Its phase is hidden, opening, shown or closing.

Readers rebuild only for what they read. HarborKeyboard.of follows every frame drawn while the keyboard moves; HarborKeyboard.isShownOf hears it come and go, and HarborRoom.of(context, aspect: HarborRoomAspect.bars) holds still while it moves, and so does the systemEdges aspect, on a phone with a home indicator too. harbor's own content reads the same way: row insets, a horizontal scroll view, a HarborRoom.of reader or a HarborKeyboardSpace in a page the keyboard runs under is not rebuilt as it opens. Only what lays out against it is.

HarborRoom.steadySystemEdgeOf(context, HarborEdge.bottom) is the home indicator's height, held while the keyboard is up, as viewPadding is in Flutter: for a footer that keeps its size while the keyboard animates. A body that clears the keyboard has no viewPadding left at the bottom while the keyboard is up, so read it here. It rebuilds its reader when the view padding changes, as MediaQuery.viewPaddingOf does, but not on every frame of the keyboard. It is zero below a solid bar that took the system edge, and below anything that marked the edge handled. A footer that sits on the keyboard instead, giving the home indicator's room back while the keyboard covers it, reads MediaQuery.viewPaddingOf in a body that clears the keyboard: the indicator with the keyboard down, 0 with it up.

A keyboard the platform does not report

harbor reads the keyboard from MediaQuery.viewInsets.bottom, as Scaffold and EditableText do, so that is the one place to feed it a keyboard the platform does not report: one drawn by a plugin or a platform view, a TV's on-screen keyboard reported over a channel. Rebuild MediaQuery above the root with the larger of the two:

MaterialApp(
  builder: (context, child) {
    final MediaQueryData data = MediaQuery.of(context);
    final double keyboard = math.max(data.viewInsets.bottom, imeHeight); // logical px
    return MediaQuery(
      data: data.copyWith(viewInsets: data.viewInsets.copyWith(bottom: keyboard)),
      child: HarborRoot(child: child!),
    );
  },
);

HarborKeyboardSource does the same from a ValueListenable<double>, rebuilding only its MediaQuery when the height changes:

HarborKeyboardSource(height: imeHeight, child: HarborRoot(child: child!)) // imeHeight: a ValueNotifier<double>

Bars, the keyboard reading, scroll views and Flutter's own widgets then all see the same keyboard: a composer that rides it rides it, a tab bar that stays is covered. Mind:

  • Logical pixels. A channel usually reports physical ones; divide by MediaQuery.devicePixelRatioOf(context).
  • The larger value, not the sum, so a keyboard the platform does report is not counted twice.
  • A floating keyboard (an iPad's, a split one) covers no edge and should not count as shown: report zero for it, as the platform does.
  • Above a scaled screen. Put it outside HarborScaledScreen, so the height is re-based into the reference screen's coordinates with the rest of the insets.

HarborFrame and Scaffold

A frame does what a Scaffold does for the edges, so a page built from a HarborFrame does not need one. Keep a Scaffold only where Material needs it, for its surface or to host SnackBars, and then give it the frame as its body and turn off its resizing:

Scaffold(
  resizeToAvoidBottomInset: false, // the frame handles the keyboard
  body: HarborFrame(top: [...], bottom: [...], body: ...),
)

A resizing Scaffold shrinks the frame before the frame ever sees the keyboard. Nothing is counted twice, but every bar then rides up above the keyboard: a tab bar that stays is lifted instead of covered, and bodyClearsKeyboard: false has nothing to run under. Leave the Scaffold's appBar, bottomNavigationBar and floatingActionButton empty and use bars and floats instead.

Talking to the frame

HarborHideBar(edge: HarborEdge.bottom, state: HarborBarState.withdrawn, child: panel)
HarborAttachedBar(edge: HarborEdge.bottom, bar: HarborBar.overlay(child: unsavedBar), child: form)
HarborFrame.of(context).hideBar(HarborEdge.top, state: HarborBarState.dark) // returns a claim; release() it

HarborFrame.of(context) is the nearest frame's handle, as Scaffold.of is the nearest ScaffoldState. It throws a FlutterError when there is no frame above the context, in release builds too, as Scaffold.of does; HarborFrame.maybeOf returns null instead. The frame makes its handle and runs its lifecycle, so the handle carries only what content asks of it: claims, attached bars, page covers and the room. Claims are counted and go to the nearest frame that has a bar on that edge. A HarborHideBar takes its claim while it is built, after its frame, so the bar hides in the next frame drawn; a claim taken with hideBar in the tap that opens a mode lands in the next frame drawn, and a page that opens without the bar builds it with state: HarborBarState.withdrawn. An attached bar joins the frame's bars on the next frame drawn; one added by hand with attachBar returns a HarborAttachedBarHandle to update or remove it by.

Floats, popovers and toasts

HarborFrame(
  floats: [
    HarborFloat(placement: const HarborAligned(alignment: AlignmentDirectional.bottomEnd), child: fab),
    HarborFloat(placement: HarborAnchored(launchAnchor, side: HarborAnchorSide.above, overlap: 6), child: bubble),
    HarborFloat(barrier: const HarborBarrier.clear(), onDismiss: closeQuickActions, child: quickActions), // modal
  ],
  bottom: [HarborBar.solid(child: InboxTabs(launch: HarborAnchorPoint(anchor: launchAnchor, child: launchButton)))],
  body: ...,
)

HarborToasts.show(context, slot: HarborToastSlot.low, builder: (_) => Toast('Saved'));
HarborToasts.show(context, placement: const HarborAligned(alignment: Alignment(0, -0.8)), builder: (_) => Toast('Saved'));
HarborToasts.show(context, placement: HarborAnchored(copyAnchor, side: HarborAnchorSide.above), builder: (_) => Toast('Copied'));
final undo = HarborToasts.show(context, timing: HarborToastTiming.untilHidden, builder: (_) => UndoToast(onUndo: restore));
final HarborToastClosedReason why = await undo.closed;    // hide, dismiss, timeout or remove
HarborToasts.show(
  context,
  transitionBuilder: (context, animation, child) => SlideTransition(
    position: Tween(begin: const Offset(0, 1), end: Offset.zero).animate(animation),
    child: child,
  ),
  builder: (_) => Toast('Saved'),
);

Floats sit in the room: the rectangle no system edge, bar or keyboard covers. Where in it is the float's placement, a HarborPlacement, which toasts and popovers take too, so a placement means the same everywhere:

  • HarborAligned(alignment:, margin:) puts it at a point of the room, margin in from its edges: bottom centre and 16 by default.
  • HarborAnchored(anchor, side:, gap:, overlap:, crossAlignment:, crossOffset:, flips:, margin:) puts it by an anchor: below it, 8 away, centred on it and 8 in from the room's edges by default. overlap reaches back over the anchor, as a speech bubble's tail sits over its button.

An anchored float sits on its side of its anchor; start and end are in reading order, as in AlignmentDirectional, so start is on the right under right-to-left. Across that side it is centred on its anchor unless its crossAlignment says start or end, the anchor's edges in reading order (or its top and bottom beside it), as a dropdown lines up under its button's leading edge; crossOffset moves it on from there, toward the reading end. A float and a toast keep their side; a popover flips to the other when its side has no room, unless its placement says flips: false. While its anchor is not in the tree, an anchored float is not shown, takes no taps and is not read out by screen readers. It is placed again in every frame that is drawn, so it moves with its anchor in the same frame drawn, a row scrolling under an open menu included. A HarborAnchor refers to one HarborAnchorPoint, so give each row of a list its own; in debug builds two points left on one anchor are reported after the frame drawn, as two leaders on one LayerLink are. A placement's alignment and margin take directional values, so AlignmentDirectional.bottomEnd puts a button where a right-to-left reader expects it. On a dual-screen device a float keeps to one screen and keeps its margin off the hinge, as it does off the room's edges. A float with a barrier: is modal: HarborBarrier.clear() keeps taps off the page and its bars while leaving them in view (HarborBarrier.dimmed() tints them too), and tells screen readers to leave them alone; a tap beside the float, back or Escape calls its onDismiss, and the floats listed before it are hidden while it is up. A barrier that is not dismissible ignores the tap, and back and Escape still call onDismiss. The barrier's label is what a screen reader announces for it ('Dismiss', unless HarborDefaults.barrierLabel above it says otherwise); a Material app sets MaterialLocalizations.of(context).modalBarrierDismissLabel there once (Defaults). A float's modal getter reads whether it has one. A modal float holds keyboard focus as a dialog does: it takes focus when it opens, Tab goes round inside it and the arrow keys (a TV remote's D-pad) stop at its edges, and focus goes back to where it was when it closes. requestFocus: false leaves focus on the page, as it does for a route; Escape from there still closes the float, unless focus is on a page of a Navigator nested in the frame's body, whose route answers Escape before the frame does. A toast is shown at a slot (top, high, middle, low) or by a placement: at an exact point (HarborAligned), placed as a float there would be. An AlignmentDirectional follows the reading direction of the page that showed it. It fades and scales in over animationStyle (220 ms each way by default). A transitionBuilder brings your own entrance and exit, run on harbor's animation, and AnimationStyle.noAnimation shows a widget that animates itself as it is, as showSnackBar(snackBarAnimationStyle:) does. A hidden toast stays at least 300 ms, so its own exit can run. A toast goes to the page on top (a sheet over a page over the root), so a low toast clears that sheet's footer, and it also stays clear of the bars of the frame it was shown from (a tab's own header). If its frame leaves, the toast moves to the page now on top.

A toast shown with a HarborAnchored placement sits by a HarborAnchorPoint instead, as an anchored float does: on its side (below by default), gap away (8), lined up by its crossAlignment (centred), and kept in the room: "Copied" by the button that copied. Show it from the page that holds the anchor: it is shown by that page, not the one on top. Toasts at one anchor and side take turns. While the anchor is out of the tree the toast is not shown, takes no taps and is not read out, and its time stops. If its page is popped, it does not move to the page now on top: it is hidden, and closed reports remove.

Toasts shown at the same slot or alignment of one page take turns, as a ScaffoldMessenger shows its snack bars: the next comes in once the one before it has run its exit, so "Copied" tapped twice is never drawn over itself. To replace the toast that is up, hide() it; one hidden while it waits leaves without being shown. dismiss() closes it the same way but says the user did it: closed reports HarborToastClosedReason.dismiss, as it does when a screen reader dismisses a toast, so a toast's own close button, or a toast in a Dismissible, counts as dismissed in analytics rather than hidden by the app. Toasts at different slots show together. queue: HarborToastQueue.replace takes the turn at once instead: the toast goes first and the one up leaves, closed with HarborToastClosedReason.replaced, for an Undo whose window is tied to a pending action. HarborDefaultsScope(defaults: HarborDefaults(toastQueue: HarborToastQueue.replace), child:) sets that for a subtree, such as a remote-driven TV screen where a newer "Saved" should replace the one up rather than arrive late. closed completes once a toast has left, with why. entry.phase (waiting, shown, closed) and entry.wasShown say where it is synchronously, before closed completes: a toast hidden while it waits is closed by the time hide() returns, and wasShown is false, so an analytics count or an onDismiss can answer on the spot.

A toast stays 4 s, as a SnackBar does, and its duration counts only while it is in sight: from the end of its entrance, and not while another route covers its page (the time starts over when that route leaves). Its timing:, a HarborToastTiming, says otherwise: HarborToastTiming(duration: ...) for another length, and HarborToastTiming.untilHidden keeps it up until it is hidden, as SnackBar(persist: true) does. Give that to a toast with a button, an Undo: a screen-reader user moving through the page needs longer than 4 s to reach it. entry.hold() stops the time until you release() the HarborToastHold it returns: call it from the toast's own Listener when a finger goes down, or when focus moves in; HarborToastEntry.maybeOf(context) inside the toast's builder is its own entry. harbor never holds a toast by itself. Holds are counted, the time starts over at the last release, and entry.held says whether one is on. HarborToastTiming(countsEntrance: true) counts the duration from the start of the entrance, so a 4 s toast is on screen 4 s, and setting entry.duration after it is shown starts the time over with the new one (longer once a screen reader turns on, say; null keeps it until it is hidden). Leave an untilHidden toast's duration alone: its duration is null, and setting one gives it a time to run out, so a helper that stretches every toast's time checks entry.duration != null first. HarborToastTiming(countsWhileCovered: true) keeps the time running while another page covers the toast's, so a "Saved" toast is gone when the user comes back; a hold still stops it. A toast with neither a slot nor an alignment goes to HarborToastSlot.high, and target: HarborToastTarget.root sends it to the outermost frame, clear of the system edges alone.

A toast keeps 16 in from the room's edges (8 by an anchor). Its placement's margin sets that, and HarborAligned(alignment: HarborToastSlot.low.alignment, margin: EdgeInsets.zero) gives a bar the width of the screen, a fixed SnackBar's look, still above the bars and off the system edges. reachesSystemEdges: true, for a toast at a slot or an alignment, lets it run over the system edges it is against (under the home indicator at the bottom, the status bar at the top, the side insets always), still off the bars and the keyboard, and hands its content the system edges it covers as MediaQuery.padding to keep its text clear: SnackBarBehavior.fixed's look.

An anchored toast never flips, so its transition knows its side: HarborToastEntry.maybeOf(context) in a transitionBuilder gives its anchor and side, and HarborAnchorClip(anchor:, side:, child:) clips at the anchor's edge, for a toast that slides out from under the field it hangs from.

A toast shown with no frame above it (a widget test that pumps a bare MaterialApp, a preview, a screen not yet built from a frame) still shows: it goes to the nearest Overlay, at its slot and clear of MediaQuery.padding and viewInsets. Without a HarborRoot, the outermost frame of the page is its page: a toast shown from a frame inside a header goes to the page, not the header. With no overlay either, it is reported through FlutterError.reportError, in release builds too. A toast's timers stop when it is hidden or when nothing is left to show it, so a test that ends with one up has no timer pending.

final menu = OverlayPortalController();

HarborPopover(                          // in a list row, anywhere below a frame
  controller: menu,
  placement: const HarborAnchored(        // by its child: no anchor of its own
    null,
    side: HarborAnchorSide.below,
    crossAlignment: HarborCrossAlignment.start, // under the row's leading edge
  ),
  onDismiss: menu.hide,                   // a tap outside, Escape or back
  consumeOutsideTaps: true,               // and that tap presses nothing else
  popoverBuilder: (context) => const RowMenu(),
  child: GestureDetector(onTap: menu.toggle, child: row),
)

A popover is an anchored float opened from where it is used rather than listed in HarborFrame.floats: a menu from a list row, a popover from a button in another package. It is an OverlayPortal, so it builds with the row's themes and sits in the nearest Overlay, placed in the room of the frame around the row by its placement, anchored to its child when the placement has no anchor. Until that anchor is in the tree, it is not shown, takes no taps and is not read out. Given no placement it sits above its child, as it did before placements; HarborAnchored's own side is below. When its side has no room, it flips to the other side of the anchor (unless flips: false), so a menu from a row just above the tab bar or the keyboard opens above the row; when neither side has room, it is held inside the room. HarborPopover.sideOf(context) in the popover is the side it landed on, so a popover can point its arrow at the anchor after a flip. The popover is placed as it paints, so it hears of a flip on the next frame drawn. To have it open in the frame drawn as it is built (a menu opened by building it), call controller.show() in your widget's initState: an OverlayPortalController keeps a show() made before its portal is attached, and the portal shows as it attaches.

With an onDismiss, a popover closes as a MenuAnchor does: the popover and its child are one TapRegion group, so a tap outside both calls onDismiss while a tap on the row that opened it is left to the row, and so do Escape with focus in either and back (before it reaches the page). The tap goes on to what is under it, as a MenuAnchor's does, unless consumeOutsideTaps is set. It puts up no barrier and leaves the page to screen readers, as a menu does. So in a modal float, a tap on the barrier while the popover is open calls both onDismisses, and in a dialog it calls the popover's and closes the dialog, as it does with a MenuAnchor open in a dialog.

A popover leaves focus where it was when it opens, as a MenuAnchor does. requestFocus: true makes it take focus as a sheet with no barrier does: the popover is a focus scope of its own that becomes the first focus of the scope around its child (the page's, or a modal float's or a dialog's), so a control in it with autofocus: true takes it from there. It is not modal, so Tab past its last control does what it does at a route's edge rather than going round inside it. When it closes, focus goes back to where it was, if focus is still in the popover. It needs an onDismiss, so Escape from inside it can close it; that Escape closes the popover before a modal float it was opened from.

A full-screen page over everything

To cover the whole screen from deep in a page, the tab bar included, without pushing a route, show it through an OverlayPortal on the root overlay and make its child a page:

OverlayPortal(
  controller: viewer,
  overlayLocation: OverlayChildLocation.rootOverlay,
  overlayChildBuilder: (context) => HarborFrame.page(
    top: [HarborBar.overlay(child: viewerHeader)],
    body: const HarborClear(child: Viewer()),
  ),
  child: thumbnail,
)

An overlay child takes its themes and frame from where the portal is declared, but its MediaQuery padding from the overlay it paints in, the root's. A page there reads its system edges from that padding, so it clears the home indicator that the tab host's bar held below the portal. Without the frame, SafeArea and HarborClear still clear it (they read the padding), but HarborRoom.of and HarborClear(clearsBars: false) read the declaring page's room, which has no bottom system edge under a tab bar.

Sheets and dialogs

showHarborSheet(context, builder: (_) => HarborSheet(header: title, footer: actions, body: form));
showHarborSheet(context, builder: (_) => HarborSheet.draggable(
  header: title,
  extent: const HarborSheetExtent(rest: 0.5, max: 0.88, min: 0.25), // the defaults
  builder: (context, controller) => HarborScrollView(controller: controller, slivers: [...]),
));
showHarborSheet(context, coversPage: true, barrier: HarborBarrier.none, builder: ...);
showHarborDialog(context, inheritRoom: true, builder: ...);
showHarborDialog(context, clearsSystemEdges: false, builder: ...);

A sheet starts a page of its own. It takes the themes and text style of the page that opened it, and stops short of the status bar. Its header is an overlay bar with a fade (on a draggable sheet, dragging the header drags the sheet), and its footer is a solid bar that rides the keyboard and keeps 16 off the edge. It keeps the home indicator in its system edges, so its footer clears it exactly once.

A dialog keeps clear of the system edges, as showDialog's does with useSafeArea: a tall Material Dialog stops short of the status bar and the home indicator as it does the keyboard. They are marked handled, so a HarborClear inside does not clear them again. With inheritRoom: true the opener's bars are among them, and the dialog keeps between its header and its composer. clearsSystemEdges: false runs the dialog under them and hands them to it in MediaQuery.padding, as every dialog did through 0.9.

header: and footer: take a widget and put it in those bars, HarborSheet.defaultHeaderBar and HarborSheet.defaultFooterBar. To choose anything else about either bar, give the whole bar instead, as headerBar: or footerBar:, and every bar option is open (on HarborSheet.draggable too, where any header bar is still the handle):

HarborSheet(
  headerBar: HarborBar.overlay(child: tabs),                          // no fade
  footerBar: HarborBar.solid(keyboard: HarborKeyboardStance.withdraws, child: tools), // leaves while the keyboard is up
  body: deck,
)
HarborSheet(footerBar: HarborSheet.defaultFooterBar(actions).copyWith(keyboard: HarborKeyboardStance.stays), body: form)
``` A draggable
sheet's heights are fractions of the space between the status bar and the
keyboard. A sheet opened with **`coversPage: true`**
reports how far it covers the page that opened it, and that page's
content keeps clear of it while it's up: in the same frame drawn as the sheet,
as it slides in and out and as it is dragged.

`showHarborSheet(reachesTop: true)` lets a sheet reach the top of the
screen, and hands its header and body the status bar to clear, as a page's
are. A header that paints under the status bar and pads its own title would
pad twice, so it passes `HarborSheet(clearsTopSystemEdge: false)` (on
`HarborSheet.draggable` too): the sheet keeps the height `reachesTop` gave
it, and the top system edge is handled for its content, which reads zero there.
Toasts and floats in the sheet still keep clear of the status bar. Without
`reachesTop` it changes nothing: the sheet stops short of the status
bar and is handed no top system edge to clear. So the opener decides how high the
sheet reaches, and the sheet decides whether its content pads for the status bar.
`fillsScreenAtHinge` (below) reaches the top too, on a split screen only: there it
implies `reachesTop`, and elsewhere `reachesTop` alone decides.

On a phone on its side, or with a cutout on a side edge, `HarborSheet(clearsSides: true)`
keeps the header, body and footer clear of the system edges on the left and right and
marks them handled, so nothing beneath clears them again, while the surface still runs
edge to edge. It is off by default, as `showModalBottomSheet`'s `useSafeArea`
is: then the header and footer run the sheet's full width and are handed the
side system edges in `MediaQuery.padding`, and so is the body, which clears them only
where it keeps clear. `useSafeArea` insets the whole sheet, its surface
and its top too; `clearsSides` insets only what is over the surface, and only
the sides.

A sheet keeps its body above the keyboard and caps its height by the space
above it. `HarborSheet(bodyClearsKeyboard: false)` leaves the keyboard to the body,
as `HarborFrame(bodyClearsKeyboard: false)` does a page's: for an editing toolbar that
holds a keyboard-sized space so nothing jumps when a field takes focus, or an
inspector the keyboard may cover. The body runs under the keyboard and reads it
in `MediaQuery.viewInsets`, a footer that rides the keyboard still rides up on it, and
`maxExtentFraction` (0.9 of the space above the keyboard by default), or a
draggable sheet's extents, is a share of the whole height.

A sheet comes up from the bottom edge. A sheet anchored to the top, one that pulls down from under
the status bar, is out of scope: build it from a float or a popover, or an `OverlayPortal` of your
own, and keep it clear of the status bar with `HarborRoom.of(context).systemEdges`.

On a dual-screen device, sheets and dialogs keep to one screen, as Material's do,
and toasts and floats keep to the screen that holds them, never across the hinge.
A flat fold, which has no width, may still be spanned.

Which hinges and folds they keep off is a policy, `HarborHinge`, modelled on
Material 3's `HingePolicy`:

| `HarborHinge` | Keeps off |
|---|---|
| `avoidSeparating` (default) | A hinge with width, and a fold that is half open: what separates the screen |
| `avoidOccluding` | Only a hinge with width, which hides content; a half-open fold is still one screen |
| `always` | Every hinge and fold, a flat one too |
| `never` | Nothing: the inner screen is one wide, unbroken screen, for a canvas or a map |

Set the app's default with `HarborRoot(defaults: HarborDefaults(hinge:))`, a page's with
`HarborFrame(defaults: HarborDefaults(hinge:))`, and one overlay's on `showHarborSheet(hinge:)`,
`showHarborDialog(hinge:)`, `HarborToasts.show(hinge:)` or `HarborFloat(hinge:)`; the nearest
wins. A page's policy goes with what it opens: a sheet opened from the page keeps it, and so does
a toast shown from that sheet. A popover (a row's menu) keeps to
the screen that holds its anchor, as a Material menu keeps to its button's, and
flips within it; `HarborPopover(hinge:)` sets its own. A sheet a hinge keeps to
one screen can fill that screen, top to bottom, with
`showHarborSheet(fillsScreenAtHinge: true)`, as Microsoft suggests for a partial
overlay on a dual-screen device. It changes only where harbor puts
things: `MediaQuery.displayFeatures` stays as the platform reports it, so a
two-pane layout or a video player beneath still sees the hinge. `anchorPoint:`
picks the side.

Bars keep off a hinge the page's policy keeps off, as both Microsoft and Google
ask: a header or a tab bar across a vertical hinge keeps to the screen where
reading starts (the left one, or the right one right to left), and a rail down a
horizontal hinge keeps to the top screen. The body still runs across, so a page
is one canvas; split it into panes for two. Only a frame as big as the screen
reads the hinge. `HarborDefaults(hinge: HarborHinge.never)` on a frame lays bars across it,
and the page's overlays too; `HarborDefaults(barHinge: HarborHinge.never)` lays only the bars
across it, so its sheets, dialogs, toasts and menus still keep off. Both reach the frames nested
in the page as well, unless a nested frame sets its own: a frame's bars follow its own `barHinge`,
then its own `hinge`, and only then what a frame around it set.

`HarborPanes(start:, end:)` is two panes, modelled on Windows' `TwoPaneView`:
either side of a hinge the policy keeps off (side by side for a vertical one,
start on top for a horizontal one), side by side when the width reaches
`minWideWidth` (840 by default, Material's expanded width), one above the other
when the height reaches `minTallHeight` (off by default), and otherwise
`priority`'s pane alone. Each pane is built as a screen of its own, with its size,
the system edges only on its outer edges and only the display features inside it, so
each can be a `HarborFrame` with bars of its own: list and detail, two pages of a
book, a canvas and its tools. `HarborPanes.modeOf(context)` tells a list whether
its detail is on screen, so a tap can fill the end pane or open a page.

```dart
HarborPanes(
  start: HarborFrame(top: [HarborBar.overlay(child: header)], body: list),
  end: HarborFrame(body: detail),
  priority: HarborPanePriority.start, // the list, on a phone
)

HarborPosture.of(context) is how a foldable is held: flat, tabletop (half open with the fold across the screen) or book (half open with the fold down the middle). On a table, a HarborPanes puts its start pane above the fold and its end pane below, so a video player sits above its controls, as Android's tabletop guidance has it; in a book, the two panes are its pages.

A sheet with barrier: HarborBarrier.none is not a route of its own, so it is tied to the page that opened it: back (and a pop) closes it before the page, the iOS back swipe stands aside while it is up, it hides while another page is on top (from the first frame of that page's push until its pop has finished), and it leaves when its page is replaced or removed. In a page built from a frame, the page draws it, just above itself: a dialog, a modal sheet or a menu opened over the page sits over the sheet too, which stays in view behind its barrier and out of reach, as a Scaffold's persistent bottom sheet does. In a page with no frame it has nothing to be drawn by, so it hides while anything is over the page. Escape closes it as back does, from focus in the sheet or in a frame on its page, and is left to the widgets above while no such sheet is up. It is not modal, as a persistent bottom sheet is not: it is a focus scope of its own, as a route is, so Tab goes through the sheet in order and then on to the page, and it leaves focus where it was when it opens unless you pass requestFocus: true. Then focus goes back to the page when it closes. A PopScope inside such a sheet has no route to register with; put it around the page instead.

A page that handles back itself with PopScope(canPop: false) (a tab bar that goes back a tab) keeps back from a modal float, a popover and a sheet with no barrier, as it does from a Drawer or a persistent bottom sheet: Navigator.maybePop sees canPop: false before it looks at the page's local history, which is where harbor ties them, so only your handler hears back. Close them from that handler first, with Navigator.pop, which removes the newest entry of that history:

PopScope(
  canPop: false,
  onPopInvokedWithResult: (didPop, _) {
    if (didPop) return;
    if (ModalRoute.of(context)!.willHandlePopInternally) {
      Navigator.of(context).pop(); // closes the top float, popover or barrier-less sheet
      return;
    }
    tabs.back();
  },
  child: HarborFrame(...),
)

Each back press then closes one of them, the newest first, and only once they are all closed does it reach tabs.back().

final HarborSheetController nowPlaying = HarborSheetController();

showHarborSheet(context, barrier: HarborBarrier.none, controller: nowPlaying, builder: ...);
nowPlaying.close();   // from a button on the page: it slides out
nowPlaying.remove();  // it goes at once

A HarborSheetController closes a sheet from outside it, as a PersistentBottomSheetController closes Scaffold.showBottomSheet's: close(), a closed future, setState to rebuild it, and its slide as animation. remove() takes it away with no slide, as removeCurrentSnackBar does a snack bar. It is attached while its sheet is up (isAttached) and tells its listeners when that changes, so a button can show whether it opens or closes. close() and remove() do nothing once the sheet has left, however it went, so a widget can close the sheet it opened from its own dispose without asking first. It works for a sheet with a barrier too. transitionAnimationController: slides the sheet by a controller of your own in place of its 280 ms slide, as on showModalBottomSheet; you dispose it.

final Folder? folder = await showHarborSheet<Folder>(
  context,
  builder: (context) => HarborSheet(
    body: FolderList(onPick: (folder) => HarborSheet.close(context, folder)),
  ),
);

HarborSheet.close(context, result) closes the sheet context is in and completes the future that opened it with result, as Navigator.pop(context, result) does for a modal bottom sheet. It works whatever the barrier, and on a sheet opened by showModalBottomSheet or showGeneralDialog, where it pops that route. A sheet with no barrier is not a route, so back and Navigator.pop close it with no result: it returns its value only through HarborSheet.close.

What a sheet, a dialog or a float puts over the page is one value, a HarborBarrier: HarborBarrier.dimmed(color:, dismissible:, label:, onTapHint:) tints the page (showGeneralDialog's half-black by default, the default for sheets and dialogs), HarborBarrier.clear(dismissible:, label:, onTapHint:) leaves it in view, and HarborBarrier.none puts up nothing (the default for floats). A tap on a barrier closes what is over it unless it is not dismissible; back and Escape close it either way. Its label is what a screen reader announces for it ('Dismiss' when none is given), and its onTapHint says what tapping it does. A dialog is always a route with a barrier, so it cannot take none (an assert).

A sheet with a barrier is a route, as a modal bottom sheet is. routeSettings: reach your navigator observers and route-name analytics. Like a modal bottom sheet, the sheet is a semantics scope of its own, and screen readers announce its semanticLabel: as it opens. The barrier's semantics end at the sheet's top, as a modal bottom sheet's do, and follow it as it slides or is dragged, so touch exploration over the sheet finds its content rather than the barrier. It spans the screen unless you give it a maxWidth, or HarborDefaults.sheetMaxWidth above it sets one.

A dialog is a popup route, as one from showDialog is: a Hero does not fly into it, an observer of page routes does not count it as a screen, a draggable sheet inside it closes it, and its content is a route of its own for screen readers, named by semanticLabel:. It takes showDialog's route options: routeSettings:, barrier:, anchorPoint: (which screen of a dual-screen device it opens on), traversalEdgeBehavior:, requestFocus: and animationStyle: (its fade, 180 ms by default). transitionBuilder: brings your own entrance and exit in place of the fade, as showGeneralDialog's does: it is handed the route's animation curved by animationStyle, and under reduced motion an animation that is already complete, so the dialog is simply there.

showHarborDialog pushes a HarborDialogRoute, as showDialog pushes a DialogRoute. Push one yourself to keep the route or to choose the navigator:

final HarborDialogRoute<bool> confirm = HarborDialogRoute<bool>(context: context, builder: (_) => const ConfirmDelete());
final bool? delete = await Navigator.of(context).push(confirm);

It takes the same options. The page at context lends it its themes and, with inheritRoom: true, its room, both read as the route is pushed rather than when it is made. Inheriting the room, the dialog treats the opening page's bars as system edges, so a menu stays between that page's header and its composer.

Two more options are named and behave as showModalBottomSheet's. requestFocus: false leaves focus in the page. anchorPoint: picks which screen of a dual-screen device it opens on. barrier: HarborBarrier.dimmed(dismissible: false) is its isDismissible: false: a sheet the user has to answer, where a tap on the barrier does nothing and back still closes it.

What harbor draws on its own is drawn as the widgets layer draws it, with no theme: sheet and dialog barriers are showGeneralDialog's half-black (HarborBarrier.defaultColor, 0x80000000), and a hairline edge effect is a translucent black (0x1F000000), a shade of whatever bar it is on, as BorderSide's default is black; a dark bar passes its own color:. Every barrier harbor puts up is announced with the label it is given, then HarborDefaults.barrierLabel, and otherwise with 'Dismiss', the English that Material and Cupertino fall back to, since WidgetsLocalizations has none to offer.

harbor imports no design library: it sits on Flutter's widgets layer, and since Flutter 3.47 Material and Cupertino are packages of their own. So a Material app passes Material's pieces in, the lines that showModalBottomSheet would have filled in for it (harbor_material's HarborMaterialSheet and HarborMaterialDefaults are these lines, written once). The barrier's label and tap hint and the sheet's width are the same on every call, so they go on the root once (Defaults); the rest is per sheet:

showHarborSheet(
  context,
  routeSettings: const RouteSettings(name: 'reply'),
  semanticLabel: MaterialLocalizations.of(context).dialogLabel, // on iOS Material leaves it unnamed: pass null there
  builder: (_) => HarborSheet(
    contentBuilder: (context, content) => Material( // text fields and ink work in it
      type: MaterialType.transparency,
      textStyle: DefaultTextStyle.of(context).style,
      child: content,
    ),
    clip: const RoundedRectangleBorder(             // a photo at the top keeps the corners
      borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
    ),
    dragToClose: true,                              // drag it down to close
    surface: const ColoredBox(color: Colors.white),
    body: composer,
  ),
);

dragToClose: is off by default, where showModalBottomSheet's enableDrag is on, so a Material app that wants its sheets to follow a downward drag turns it on, as above. With it on, a content-sized sheet follows the finger down by any part that doesn't scroll, and closes on a fling faster than closeFlingVelocity: (700, as a modal bottom sheet) or when let go under half shown. A draggable sheet closes at its floor, and its HarborSheetExtent(snapSizes:) are the heights it snaps to (by default its rest and its ceiling). A fling down on its header from its lowest height goes to the floor and closes it when it is faster than 700 logical px/s, the speed at which a Material BottomSheet closes; a slower one goes back to that height. A fling down faster than 1200 (or closeFlingVelocity, if higher) closes it from any height. closeFlingVelocity: sets that speed, on either kind of sheet (closeFlingVelocity: 400 is the speed harbor used before 0.3.1). A fling on its list is DraggableScrollableSheet's own, as in a modal bottom sheet: from the lowest height it closes the sheet at any speed. HarborSheetExtent(shouldCloseOnMinExtent: false) rests at the floor instead. A draggable sheet opened some other way, by showModalBottomSheet or showGeneralDialog, closes that route instead; in a modal bottom sheet give it expand: false, as you would a DraggableScrollableSheet, so a tap above it reaches the barrier.

A draggable sheet takes a DraggableScrollableController, so the page or the sheet's own content can read and move it:

final DraggableScrollableController comments = DraggableScrollableController();

HarborSheet.draggable(controller: comments, header: title, builder: ...);
comments.animateTo(0.88, duration: const Duration(milliseconds: 220), curve: Curves.easeOutCubic); // its field took focus

Rebuilt with a new rest before it is dragged, a sheet moves there, as a DraggableScrollableSheet does with a new initialChildSize; after a drag, move it with the controller.

sheetAnimationStyle: takes an AnimationStyle, as showModalBottomSheet does: its duration and curve set how the sheet opens, reverseDuration and reverseCurve how it closes, and AnimationStyle.noAnimation opens and closes it at once. A dragged sheet stays under the finger whatever the curve, and reduced motion still wins.

Defaults

MaterialApp(
  builder: (context, child) {
    final MaterialLocalizations l = MaterialLocalizations.of(context);
    return HarborRoot(
      defaults: HarborDefaults(
        barrierLabel: l.modalBarrierDismissLabel,
        sheetBarrierOnTapHint: l.scrimOnTapHint(l.bottomSheetLabel),
        sheetMaxWidth: Theme.of(context).bottomSheetTheme.constraints?.maxWidth ?? 640,
      ),
      child: child!,
    );
  },
);
HarborFrame(defaults: const HarborDefaults(toastQueue: HarborToastQueue.replace), ...) // one page
HarborDefaultsScope(defaults: const HarborDefaults(hinge: HarborHinge.never), child: canvas)

HarborDefaults holds what an app or a page decides once for everything beneath it, so no call has to repeat it:

Field For harbor's own
hinge, barHinge Which hinges overlays and bars keep off; barHinge for the bars alone avoidSeparating; barHinge follows hinge
barrierLabel What a screen reader announces for a sheet's, dialog's or modal float's barrier 'Dismiss'
sheetBarrierOnTapHint What tapping a sheet's barrier does, for a screen reader none
sheetMaxWidth The widest a sheet is drawn, centred none: it spans the screen
toastQueue Whether a toast waits its turn or replaces the one up wait
toastTiming How long a toast stays 4 s
toastAnimationStyle How a toast comes in and leaves a fade and a scale, 220 ms
toastMargin How far a toast at a slot keeps in from the room's edges 16

HarborRoot(defaults:) sets them for the app, HarborFrame(defaults:) for a page, and HarborDefaultsScope for any other subtree. Every field is optional, and the nearest that sets a field wins, field by field: a page that sets toastQueue under a root that sets barrierLabel keeps the root's label. An argument given to one call (showHarborSheet(maxWidth:), a barrier's own label, HarborToasts.show(timing:)) wins over all of them. A page's defaults go with what it opens, as its themes do: the sheets, dialogs and toasts it opens, and what those open in turn. HarborDefaults.of(context) reads them in a build method; HarborDefaults.read(context) once, from a handler.

Defaults are read from below the scope that sets them, as a theme is: a context above it, including the context of the widget that builds the HarborDefaultsScope (or the root or frame), sees the outer defaults. A toast shown from that widget's own context waits its turn under a scope that says replace; show it from a context inside the scope's child.

toastMargin is for a toast shown at a slot: or at none: a toast given a placement: keeps the placement's own margin. A root does not take the defaults of a frame it is mounted in: a phone drawn inside a page starts from harbor's own. The defaults are policy, not look: a sheet's surface, clip or dragToClose belong in your own showAppSheet wrapper, and there are no widget-valued fields.

Keeping in sight

HarborCoverageListener(onCoverage: (covered) => titleOpacity.value = covered, child: heroTitle); // 0 to 1
HarborKeepVisible(child: field);                 // re-reveals as the keyboard opens or focus moves in
HarborKeepVisibleRegion(child: canvas)           // + HarborLift(clearance: 80, child: boat) inside it
HarborKeepVisible.reveal(context, clearance: 24);

Three widgets, one job each. A focused field reveals its own caret, as EditableText does. A HarborKeepVisible reveals all of itself: when the keyboard opens, and when focus moves into it from outside, by a tap or the keyboard's next action. So a field and the button under it come up together, clearance clear of the keyboard. onlyWhileFocused: true keeps the rest of a form's widgets still. A HarborCoverageListener reports how much of it the bars on its edge cover, and a HarborLift asks its region to lift it clear of what covers the bottom. active: false on HarborKeepVisible or HarborLift turns the job off without rebuilding the child as a different widget, as HarborHideBar(active:) does.

A region lifts over 280 ms along Curves.easeOutCubic and settles back the same way. Give it an animationStyle: to change that: its duration and curve are for the lift, its reverseDuration and reverseCurve for settling back, and AnimationStyle.noAnimation moves the content at once.

Accessibility

What harbor hides is hidden from everyone: a dark or withdrawn bar is skipped by keyboard focus and by screen readers, not only by taps, and a float whose anchor is not in the tree is not read out. Toasts are live regions, so screen readers announce them, with a dismiss action that hides them, as a SnackBar is. A toast whose widget is already its own live region (a SnackBar-like widget from your design library) is shown with liveRegion: false, so harbor adds no second, unlabelled one around it. A toast with a button is shown with timing: HarborToastTiming.untilHidden, as a SnackBar with an action persists, so it is still there when a screen reader reaches it. Sheets and toasts keep the themes of the page they came from, and so do dialogs. With reduced motion (MediaQuery.disableAnimations) bars, toasts, sheets and dialogs appear and leave without moving, and keep-in-sight reveals and lifts jump into place. On iOS a tap on the status bar scrolls a page built from a frame to the top, as it does under a Scaffold, and as there only the page whose status bar band is on top at the screen's top left: a page under a route in an outer navigator, under an overlay, or in the right-hand pane of a split stays where it is.

TV

MaterialApp(
  builder: (context, child) => HarborScaledScreen(
    referenceSize: const Size(1200, 675),
    systemEdges: const HarborSystemEdges.titleSafe(HarborTitleSafe.fraction(0.05)),
    child: HarborRoot(child: child!),
  ),
);

A scaled screen lays out on a reference screen and scales to fit, re-basing the real insets into its coordinates. Title-safe is one of the system edges, so bars take it and HarborClear keeps clear of it like any other inset. A rail is a start bar with a restingExtent, and pages choose HarborFollow.live (move with it) or .resting (let it open over them).

The inspector

HarborInspectorOverlay(child:) (around HarborRoot in MaterialApp.builder, or anywhere below it) draws every bar's space, each frame's room and the keyboard. Each bar is labelled with its extent; labelStyle: sets the labels' font, so they read in widget tests and goldens rather than as flutter_test's boxes. HarborInspector.snapshot(context) returns the same as data, and HarborInspector.nearest(context) the nearest frame alone: its frame, body, room and bars in global coordinates, the way a test or a tool reads a frame without reaching into it. In debug and profile builds the ext.harbor.inspector VM-service extension serves it as JSON, for tools that drive the app.

Each entry also says which widgets took an inset out of what lies beneath them, and how much: entry.consumers, one HarborConsumerRecord per edge each handled, outermost first, with its type (HarborClear, HarborClear.rowInsets, HarborScrollView, HarborSliverClear, HarborSliverBar, HarborHandled, or a HarborFrame nested in a body), the label (its debugLabel:) and key it was given, the edge, the amount and its rect. The amount is what the widget laid out with: a HarborClear's padding on that edge, where a scroll view's end rests, what a nested frame's bar took as its system edge, or what a HarborHandled removed. A list of two on one edge is content cleared twice; an amount of zero is a consumer that found its edge already cleared. entry.consumersOn(HarborEdge.top) reads one edge, the JSON carries them as consumers, and the overlay writes them inside each room, one line per edge (top: HarborScrollView "inbox" 103; showsConsumers: false leaves the lines out). They are collected in debug and profile builds only, so a release build pays nothing for them.

Tooling that runs outside the widget tree (a debug panel, a logger, an automation driver) reads HarborInspector.snapshotAll(): every frame of every live root, with no context. A root mounted inside another frame (a phone drawn in a page, a preview) is listed too, and each of its entries says isolated: true; ext.harbor.inspector leaves those out unless it is called with isolated=true. A root is taken off the inspector when it is disposed.

To hear frames come and go, as a NavigatorObserver hears routes, give the root a HarborFrameObserver:

class HarborLog extends HarborFrameObserver {
  @override
  void didJoin(HarborController frame) => debugPrint('joined ${frame.debugLabel}');
  @override
  void didLeave(HarborController frame) => debugPrint('left ${frame.debugLabel}');
}

HarborRoot(observers: [HarborLog()], child: child!)
HarborFrame.of(context).registry.addObserver(log); // or on a registry you already have; removeObserver(log)

A frame joins as it is built, in the middle of a frame drawn, so both events arrive after that frame drawn, in order: by then the frame has laid out and the inspector can read it. A frame that joins and leaves within one frame drawn is reported to no one. The layout records behind the inspector stay internal; read a frame through HarborInspector.snapshotOf(frame.registry) or HarborInspector.nearest.

The widget inspector and debugDumpApp show each harbor widget's settings, as they do a SafeArea's or a ListView's, leaving out the ones at their defaults: a HarborFrame lists its bars and floats, and a bar reads as HarborBar.overlay(keyboard: rides, debugLabel: "composer"). The values (HarborBar, HarborFloat, HarborEdgeEffect, HarborSystemEdges, HarborTitleSafe, HarborSheetExtent) are Diagnosticable, so they print the same way in a test failure or a log.

Two fields are for information only. HarborSystemEdgeFeature.hinge is reserved and never reported: a hinge is not a system edge, and bars, sheets, dialogs, toasts, floats and popovers keep off it through MediaQuery.displayFeatures, by the HarborHinge policy. HarborController.isPage records whether the controller's frame was built as a page (HarborFrame.page, or isPage: true) or has no frame above it; nothing in harbor reads it, and toasts find their page by route.

Trials

Trials are in harbor_test, a dev dependency beside harbor (flutter pub add dev:harbor_test). It brings flutter_test, which harbor itself does not depend on.

import 'package:harbor_test/harbor_test.dart';

testWidgets('the composer rides the keyboard', (tester) async {
  final trial = await tester.pumpHarborTrial(app, device: HarborTrialDevice.androidThreeButton);
  await trial.showKeyboard();
  expect(tester.getRect(find.byType(Composer)).bottom, trial.keyboardTop);
});

showKeyboard() pumps 600 ms, long enough for the frame to follow; showKeyboard(pumpFor: Duration.zero) stops at the first frame drawn after the keyboard arrives, and settle: true pumps until nothing is animating. hideKeyboard() takes the same.

Devices: iPhone17, iPhoneSE, androidThreeButton, androidGesture, iPhone17Landscape, androidThreeButtonLandscape, androidGestureLandscape, foldableOpen (a flat fold), dualScreenCover, dualScreenOpen (a hinge), television, plus the phones and all lists. Each device goes on the view at its own devicePixelRatio, and device.displayFeatures puts its folds and hinges there. A device's systemEdges are its insets from the status bar, the home indicator and any cutout: what other screenshot and device-frame packages call its safe area (safeArea). pumpHarborTrial(textScaleFactor:) grows the system text size, so bars are measured at the size their text grew to. trial.roomAround(finder) returns the room around a widget, and isInRoom asserts against it, so a test checks where something sits relative to everything in the way, not against a number: expect(find.byType(Menu), isInRoom(trial.roomAround(find.byType(Menu)))). trial.barsAround(finder) lists the bars of the frame around a widget. Both read HarborInspector.nearest, so a test of your own can too.

Upgrading from 0.x: renamed in 0.9

0.9 renamed the API after the model; 0.9 and 0.10 kept the 0.8 names, deprecated, and 1.0 removes them, with every other name deprecated before it. Code with no deprecated harbor names needs no change, unless it passes placement: null to a HarborFloat or a HarborPopover (their placement is no longer nullable: leave it out for the same default). For code that has them, move to 0.10.3 first, run dart fix --apply there, then dart format on what it changed, and then move to 1.0: on 0.10.3 every fix applies. Run against 1.0 directly, dart fix still migrates most of it, but not a const call of a removed class, a renamed enum value, or the few other cases the 1.0.0-dev.1 CHANGELOG lists. The main renames:

0.8 0.9
HarborSea HarborRoot
Harbor HarborFrame
Harbor(newPort: true) HarborFrame(isPage: true), which dart fix writes; HarborFrame.page(...) says the same and reads better
HarborDock.pier / .quay / .reserve HarborBar.overlay / .solid / .reserve
HarborDockState, HarborDockKind.pier / .quay HarborBarState, HarborBarKind.overlay / .solid
HarborCoast, coast: HarborSystemEdges, systemEdges:
HarborCoastStance HarborSystemEdgeStance
HarborTide.of / .isInOf HarborKeyboard.of / .isShownOf
HarborTideState.highWater / .isIn HarborKeyboardState.settledHeight / .isShown
HarborTideStance.float / .pilings / .dryDock, a bar's tide: HarborKeyboardStance.rides / .stays / .reserves, a bar's keyboard:
tide: on HarborMoored, HarborFairway, HarborFairwaySliver, HarborCastOff, paddingOf clearsKeyboard:
bars: (0.8.0) on the same, and HarborCastOff(docks:) clearsBars:
HarborDryDock(showsChildAtHighTide:), HarborTideSource HarborKeyboardSpace(showsChildWithKeyboard:), HarborKeyboardSource
bodyClearsTide: bodyClearsKeyboard:
HarborWake, wake: HarborEdgeEffect, edgeEffect: (edgeEffects: on a scroll view)
HarborWakeMask(wakes:), .alphaWake, wakePainter: HarborFadeMask(bands:), .alphaFade, edgeEffectPainter:
HarborWatersData.wakes / .wakeOf, HarborWakeBand HarborRoomData.fadeBands / .fadeBandOf, HarborFadeBand
HarborController.clearWater HarborController.room
HarborWaters, HarborWatersData HarborRoom, HarborRoomData
HarborWaters.dockFaceOf / .steadyCoastOf HarborRoom.barFaceOf / .steadySystemEdgeOf
HarborMoored HarborClear
HarborMooringLine, mooringLine: HarborClear.rowInsets(child:), rowInsets:
HarborCastOff HarborHandled
HarborFairway(startsInOpenWater:), HarborFairwaySliver HarborScrollView(startsUnderBars:), HarborSliverClear
HarborSliverDock HarborSliverBar
HarborOpenWater(builder:) a Builder and HarborRoom.of(context)
HarborMakeWay, makeWay HarborHideBar, hideBar
HarborPontoon(dock:), addPontoon HarborAttachedBar(bar:), attachBar
HarborBuoy, buoys: HarborFloat, floats:
HarborPortalBuoy(buoyBuilder:) HarborPopover(popoverBuilder:)
HarborFlares.raise, entry.lower() HarborToasts.show, entry.hide()
HarborFlareSlot, HarborFlareEntry, HarborFlareTarget.sea HarborToastSlot, HarborToastEntry, HarborToastTarget.root
showHarborSheet(breakwater:, keepsTopCoast:) showHarborSheet(coversPage:, reachesTop:)
showHarborDialog(inheritClearWater:) showHarborDialog(inheritRoom:)
HarborBeacon, HarborLighthouse, HarborLighthouseRegion HarborKeepInSight and HarborKeepInSightRegion in 0.9.0, since split into HarborCoverageListener, HarborKeepVisible and HarborLift, in a HarborKeepVisibleRegion (Keeping in sight)
HarborChart, HarborChartOverlay HarborInspector, HarborInspectorOverlay
HarborFleet, HarborFleetObserver, controller.fleet HarborFrameRegistry, HarborFrameObserver, controller.registry
HarborScaleModel HarborScaledScreen
pumpSeaTrial, raiseTide(), waterline, isInClearWater pumpHarborTrial, showKeyboard(), keyboardTop, isInRoom

HarborOpenWater was retired rather than renamed, and has no automatic fix. The HarborClear enum is removed so the widget can take its name: clear: HarborClear.coast is clearsBars: false, and HarborClear.everything was the default. HarborMooringLine(child:) becomes HarborClear.rowInsets(child:), which builds a HarborClear, so a test that found rows with find.byType(HarborMooringLine) finds none after the fix: give the row a Key, or find it with find.byWidgetPredicate((w) => w is HarborClear && w.rowInsets). The complete table, every member and parameter, is doc/design/naming.md, and the CHANGELOG's 1.0.0-dev.1 entry lists every name 1.0 removes and what dart fix still does against 1.0 itself.

A file that imports harbor with show needs the new names added after dart fix, or the import widened to import 'package:harbor/harbor.dart'; before it runs. A rename is renamed in the show list too, but a fix that brings in a name the call did not have (HarborToastTiming, HarborAligned, HarborAnchored, HarborBarrier, HarborDefaults, HarborDefaultsScope, HarborBarState, ...) writes it without touching the list, so the fixed file does not compile (undefined_function), and a second run does not repair it. That is the analyzer's data-driven fixes, which do not extend an import's combinators (#146).

A file that reaches harbor through a helper, without importing harbor itself (it calls session.frame.coast on a helper's typed field, or imports a library of yours that re-exports harbor), needs import 'package:harbor/harbor.dart'; added before dart fix, or it is left as it was, and a second run says "Nothing to fix!". dart fix removes the import again afterwards if the file names nothing from it. The analyzer offers a package's fixes only in a file that imports that package's library (#150). harbor_test's fixes are the same, with import 'package:harbor_test/harbor_test.dart';. On a jump straight from 0.x to 1.0, the same run can remove that import as unused before migrating the call, since the removed type does not resolve: re-add it and run dart fix --apply again, or step through 0.10.3, which keeps it.

Example

example/ is a small harbor game that exercises every pattern above. Toggle the inspector in its Harbor Office to see the layers.

This repository pins Flutter with fvm, hence fvm flutter below; plain flutter works too. The videos at the top of this page are example/lib/showcase/ (the phone tour, fvm flutter run -t lib/showcase_main.dart) and example/lib/showcase_wide/ (the wide-format one, fvm flutter run -t lib/showcase_wide_main.dart; render it with render_showcase.sh showcase_wide). example/tool/render_showcase.sh records it frame by frame on the test clock, so it comes out the same every time, and example/test/showcase_test.dart checks each caption against the real page on the phone.

The video is narrated. example/tool/narrate.py voices each line of example/lib/showcase/narration.tsv, times every word, and checks that each clip says what the script says. The showcase's timeline is built from those timings, so the keyboard rises as "comes in" is spoken. Each chapter is captioned with the API it shows (HarborBar.solid, HarborToasts.show, ...), and the narration says what it does, with the harbour as illustration ("like a quay, a stone wall on the shore"). Chapters the panorama was not drawn for show the Field Guide's drawing and label for the class, and each runs a real harbor page on the phone: sheets, dialogs and toasts open on the phone's own navigator as the narrator names them. Narration: the Kokoro-82M voice am_liam, generated on device by Kass.

The Harbor Field Guide (in your browser, the book button on the game's first page, or fvm flutter run -t lib/field_guide_main.dart) has a page for every class, titled by its name in code, with a harbour drawing to illustrate it (a quay, like a solid bar, is where the body stops) and a pretend phone to try it on: its own status bar, home indicator and keyboard, a gauge to drag the keyboard in and out, the class's options, live readings of MediaQuery and the room data, and the code for what's on the phone.

example/integration_test/play_through_test.dart plays through every scene on a real device or simulator, with real insets and the real software keyboard, taking a screenshot at each step and checking where everything landed:

cd example
PLAY_SHOTS=build/play fvm flutter drive --driver=test_driver/integration_test.dart \
  --target=integration_test/play_through_test.dart -d <simulator id>

To run it on a phone, copy example/ios/Flutter/Local.xcconfig.example to Local.xcconfig (it isn't checked in) and fill in your own team and a bundle ID you own: the example ships as com.example.harborExample, which Apple won't sign.

Turn off the simulator's hardware keyboard first (I/O ▸ Keyboard ▸ Connect Hardware Keyboard) so the software keyboard comes up. Screenshots show Flutter's own surface, so the keyboard itself is not in them.

The field guide's pages are checked by widget tests (example/test/field_guide_*_test.dart): each page's options are set one by one and where things land is measured on its pretend phone.

Libraries

harbor
Every edge of the screen, claimed once.