Safaeh

Safaeh - صفائح

safaeh
Adaptive sheets, onboarding designs, camera / QR chrome, page index, and sidenav for Flutter —
host app keeps i18n, routing, and the camera plugin.

Safaeh is presentation-only: it has no network, cloud, billing, or authentication-service dependency. Onboarding and auth widgets receive state and callbacks from the host application.

Live demo

pub.dev repo flutter license

Live demo — open the example catalog in the browser
zyzto.github.io/Safaeh

Live demo · Install · Quick start · Widgets · Features · Host app · Example · Changelog · Versioning · Host integration · العربية

The name safaeh comes from Arabic صفائح (ṣafāʾiḥ): sheets / plates — plural of صفيحة (ṣafīḥa).


Why

Flutter apps accumulate one-off bottom sheets, dialogs, rails, and camera overlays. Then you need:

  • the same route to be a phone sheet and a tablet dialog, and morph when the viewport crosses the breakpoint
  • chrome that honors MediaQuery.disableAnimationsOf
  • no easy_localization, Riverpod, go_router, or mobile_scanner inside the package

Safaeh is that chrome layer. Used in Hisab.

On pub.dev: safaeh · Repo: Zyzto/Safaeh.


Widgets

Card picker — live demo Confirm sheet — live demo Option tiles — live demo

Card picker · Confirm · Option tiles — try them in the live demo

Sidenav rail — live demo

Empty state — live demo Inline banner — live demo KPI card — live demo Async error — live demo

Empty state · Inline banner · KPI · Async / error — try them in the live demo

Captured with widgets_to_image (cd example && flutter test test/widget_images_test.dart).


Features at a glance

Area What you get
Onboarding Six public presets through SafaehOnboardingDesign; SafaehOnboarding, design catalog metadata, host-owned steps, trackers, action bars, list items, and generic SafaehAuthFlow
Sheets showSafaeh morphs phone sheet ↔ tablet dialog; SafaehSheet.enableDrag toggles phone drag-to-dismiss; showSafaehPicker / SafaehOption (cards, enabled); showSafaehTilePicker / SafaehTileOption (list rows, search); showSafaehMultiTilePicker (multi-select); showSafaehActionSheet; showSafaehInfo; showSafaehConfirm, showSafaehTimedConfirm, showSafaehTextInput, SafaehStatusBody, SafaehContentPanel, buildSafaehSheetShell, SafaehOptionList, SafaehOptionTile
Dropdown SafaehAnchoredDropdownChip / SafaehDropdownOption for anchored menus that match the trigger width, with host label and selection-color hooks
Dialog showSafaehDialog centered panel (railWidthOf is ignored for alignment)
Theme SafaehTheme / SafaehThemeData for breakpoint, motion, radius, rail widths, camera compact height, contentMaxWidth, desktop band tokens, sheetBodyInset, floatingAppearance; copyWith
Chrome SafaehEmptyState, SafaehInlineBanner, SafaehMetaChip, SafaehGlyphAvatar, SafaehKpiCard, SafaehBorderedListChrome, SafaehSectionHeader, SafaehLtrText, SafaehUserText, SafaehAsyncBody, SafaehErrorBody, SafaehAccentSurfaces, applySafaehMaterialChrome
Debug showSafaehDebugMenu, SafaehDebugMenuFab, host-registered SafaehDebugSections, SafaehL10nEditOverlay behind SafaehL10nBackend
Motion safaehResolvedMotion zeros durations when animations are disabled
Nav SafaehSidenav temporary drawer (asDrawer: true), clipping rail, or overlay rail (overlay: true); SafaehFloatingNavBar (same SafaehSidenavDestination); keyboard-aware bottom-nav metrics and FAB placement
Page index SafaehPageIndex, overlay, scrollToPageSection, safaehActivePageSectionId (ids + keys only — no .tr() on scroll)
App bar SafaehMorphingAppBar, SafaehMorphingAppBarAction, SafaehMorphingAppBarBottom for page-aware title, action, and bottom chrome morphing
Content safaehBandMetrics, safaehRailAwareBandMetrics, SafaehContentBand (railAware), SafaehContentAlignedPage, SafaehEndAsideLayout, SafaehContentAlignedAppBar.forContentArea, SafaehContentAlignedFabLocation.of
Camera showSafaehCameraSheet / SafaehCameraSheetHost paper-roll compact ↔ full
QR chrome SafaehQrScannerOverlay (optional host preview), SafaehQrTopBar, SafaehQrMessageBody, SafaehQrFramePainter
RTL safaehChevronEnd, safaehChevronStart, safaehArrowBack (LTR glyphs; Material matchTextDirection mirrors them)

Core: Flutter Material only. Preview, decode, copy, and navigation stay in the host.


Install

dependencies:
  safaeh: ^0.6.0

Or:

flutter pub add safaeh

Git tag pin (see VERSIONING.md):

dependencies:
  safaeh:
    git:
      url: https://github.com/Zyzto/Safaeh.git
      ref: v0.6.0
import 'package:safaeh/safaeh.dart';

Current version: 0.6.0. See doc/chrome.md and doc/debug-menu.md.


Quick start

1. Wrap the app

SafaehTheme(
  data: const SafaehThemeData(
    tabletBreakpoint: 600,
    dialogMaxWidth: 560,
  ),
  child: MaterialApp(
    home: const MyHome(),
  ),
);

Call-sites can still override breakpoint, motion, and transitions.

2. Choose a public onboarding design

All six onboarding designs are part of the package. The host chooses the design and owns persistence, localization, routing, and domain actions:

SafaehOnboarding(
  design: SafaehOnboardingDesign.orbit,
  steps: [
    SafaehOnboardingStep(
      id: 'welcome',
      titleBuilder: (context) => const Text('Welcome'),
      bodyBuilder: (context) => const Text('Your host-owned content.'),
    ),
  ],
  labels: SafaehOnboardingLabels(
    next: 'Continue',
    stepProgress: (current, total) => '$current / $total',
  ),
  actions: SafaehOnboardingHostActions(
    languageControl: const LanguageButton(),
    themeControl: const ThemeButton(),
    onComplete: () async => SafaehOnboardingResult.completed,
  ),
);

Use SafaehOnboardingDesignCatalog.all to build a host-owned design picker. Safaeh does not save the selection. SafaehAuthFlow uses the same six presets for generic sign-in, sign-up, recovery, magic-link, and pending-email screens; its callbacks are supplied by the host.

3. Adaptive sheet

await showSafaeh<void>(
  context: context,
  title: 'Rename',
  titleBuilder: (context, style) => Text('Rename', style: style),
  child: const TextField(),
);

Phone: bottom sheet. Tablet+: centered dialog. The same route morphs when width crosses tabletBreakpoint. Pass phonePlacement: SafaehPhoneSheetPlacement.center to grow the phone sheet so the first content center aligns with the phone center (still flush with the bottom).

On phone, the whole sheet surface (handle, title, edges) can be dragged down to dismiss. enableDrag sets the initial value; a descendant can change it later with SafaehSheet.of(context).enableDrag. Lists still scroll; a downward pull at their top edge is handed to the sheet.

4. Option picker

final choice = await showSafaehPicker<int>(
  context: context,
  title: 'How to settle',
  selected: 1,
  options: const [
    SafaehOption(
      value: 1,
      label: 'Minimal',
      subtitle: 'Fewest transfers',
      icon: Icons.bolt_outlined,
    ),
  ],
);

The in-body title hides when the viewport is wide (header title only). SafaehOption.enabled greys the card and ignores taps.

5. Tile picker (list rows)

final mode = await showSafaehTilePicker<String>(
  context: context,
  title: 'Import mode',
  titleBuilder: (context, style) => Text('Import mode', style: style),
  header: const Text('12 new · 3 updated'),
  selected: 'add',
  options: const [
    SafaehTileOption(
      value: 'add',
      label: 'Add copies',
      subtitle: 'Keeps existing data',
      leading: Icon(Icons.add_circle_outline),
    ),
    SafaehTileOption(
      value: 'replace',
      label: 'Replace',
      enabled: false,
    ),
  ],
);

Uses SafaehOptionList + SafaehOptionTile. Hosts that already wrap showSafaeh can mount SafaehTilePickerBody as the child. Same knobs as other showSafaeh* helpers (railWidthOf, motion, …).

6. Confirm and text input

Host passes action labels. The footer is the commit action only; barrier tap, drag, and tablet close dismiss. showSafaehConfirm returns true if confirmed and null if dismissed. Treat only ok == true as confirmed.

final ok = await showSafaehConfirm(
  context: context,
  title: 'Delete item',
  content: 'This cannot be undone.',
  confirmLabel: 'Delete',
  isDestructive: true,
  titleBuilder: (context, style) => Text('Delete item', style: style),
);

final name = await showSafaehTextInput(
  context: context,
  title: 'Tag name',
  doneLabel: 'Done',
  titleBuilder: (context, style) => Text('Tag name', style: style),
);

7. Centered dialog

await showSafaehDialog<void>(
  context: context,
  builder: (context) => const Card(child: Text('Hello')),
);

8. Floating nav and content band

SafaehFloatingNavBar(
  selectedIndex: index,
  onDestinationSelected: (i) => setState(() => index = i),
  destinations: const [
    SafaehSidenavDestination(
      label: 'Home',
      icon: Icons.home_outlined,
      selectedIcon: Icons.home,
    ),
  ],
);

SafaehContentBand(
  aside: const Text('On this page'),
  child: body,
);

When the host owns a floating bottom nav, expose its geometry to overlays and FABs instead of repeating offsets. The visual inset is intentionally smaller than the scroll-content inset:

SafaehBottomNavScope(
  child: Stack(
    children: [
      body,
      SafaehPageIndexOverlay(
        title: 'On this page',
        entries: entries,
        activeId: activeId,
        onSelect: onSelect,
      ),
    ],
  ),
);

final fabLocation = SafaehBottomNavAwareFabLocation.resolve(
  context,
  base: FloatingActionButtonLocation.endFloat,
);

Use SafaehFloatingNavBar(hideWhenKeyboardVisible: true) when the shell nav should disappear while the IME is open. Set the shell scaffold's resizeToAvoidBottomInset to false so the chrome does not jump during the keyboard transition. SafaehBottomNavMetrics exposes separate visual and content insets for custom layouts.

SafaehContentBand centers from incoming constraints and hides aside when narrow (SafaehThemeData.isWide). Hosts with a sibling shell rail keep their own leftOffset / bandWidth math and use SafaehEndAsideLayout.

9. Morphing app bar

Drive one shell app bar from the live PageController.page. Safaeh crossfades adjacent host-provided titles, keeps a stable action slot, and lets the host fade actions or bottom chrome at the same page position:

SafaehMorphingAppBar(
  page: pageController.page ?? 0,
  titles: const [
    Text('Home'),
    Text('Statistics'),
    Text('Settings'),
  ],
  // Reserve the leading slot when actions should not move the title.
  leading: const SizedBox(width: kToolbarHeight),
  actionsBuilder: (context, page) => Stack(
    alignment: AlignmentDirectional.center,
    children: [
      SafaehMorphingAppBarAction(
        page: page,
        targetPage: 0,
        child: IconButton(
          onPressed: openHome,
          icon: const Icon(Icons.home_outlined),
        ),
      ),
      SafaehMorphingAppBarAction(
        page: page,
        targetPage: 2,
        child: IconButton(
          onPressed: openSettings,
          icon: const Icon(Icons.settings_outlined),
        ),
      ),
    ],
  ),
  bottom: SafaehMorphingAppBarBottom(
    factor: (2 - (pageController.page ?? 0)).clamp(0.0, 1.0).toDouble(),
    height: 48,
    child: const Center(child: Text('Range controls')),
  ),
);

titles and all action text stay in the host, so localization and routing do not enter Safaeh. page is clamped to the title range before the action builder runs. SafaehMorphingAppBarAction uses linear page distance for its opacity and ignores taps below its interactive threshold. The bottom helper clips and resizes its child while reporting the matching preferred height. See host integration and the Morphing app bar catalog entry in example/.

10. Shared feedback

Mount one host around the app navigator. The host owns the feedback overlay, surface treatment, animation, safe-area handling, and optional bottom-nav clearance; the app still owns translated copy and domain callbacks:

SafaehFeedbackHost(
  itemWidthBuilder: (context) => MediaQuery.sizeOf(context).width - 32,
  bottomInsetBuilder: (context) => 72,
  child: MaterialApp.router(routerConfig: router),
);

context.showSafaehFeedback(
  'Saved',
  type: SafaehFeedbackType.success,
);

context.showSafaehFeedbackWithAction(
  'Deleted',
  actionLabel: 'Undo',
  onAction: undo,
);

Use showSafaehCustomFeedback for app-specific content while retaining the same host placement and dismissal contract. Keep localization, reporting, and business state in the host app.

Band metrics for other apps (app bar, FAB, aside):

final metrics = safaehBandMetrics(
  contentAreaWidth: constraints.maxWidth,
  maxWidth: 600,
);

SafaehContentAlignedAppBar(
  leftOffset: metrics.leftOffset,
  bandWidth: metrics.bandWidth,
  title: const Text('Title'),
);

SafaehContentAlignedFabLocation.resolve(
  leftOffset: metrics.leftOffset,
  bandWidth: metrics.bandWidth,
  endFree: metrics.endFree,
  textDirection: Directionality.of(context),
);

See doc/host-integration.md.

11. Overlay sidenav

For a sidenav that expands over the page without reserving layout width, put the overlay rail above the host content in a Stack:

Stack(
  fit: StackFit.expand,
  children: [
    const PageBody(),
    SafaehSidenav(
      overlay: true,
      collapsed: collapsed,
      onToggleCompact: () => setState(() => collapsed = !collapsed),
      floatingAppearance: const SafaehFloatingAppearance(
        style: SafaehFloatingSurfaceStyle.glass,
      ),
      title: 'Safaeh',
      selectedIndex: index,
      onDestinationSelected: onDestinationSelected,
      destinations: destinations,
    ),
  ],
);

The overlay inherits SafaehThemeData.floatingAppearance when the direct value is omitted. Its scrim, if needed, remains host-owned.

12. Shared floating-surface appearance

Package-owned floating chrome can inherit one appearance from SafaehThemeData, or override it on an individual widget or call:

const appearance = SafaehFloatingAppearance(
  style: SafaehFloatingSurfaceStyle.glass,
  transparency: 48, // 0 = opaque, 100 = fully transparent
  blurSigma: 18,
  tintColor: Color(0xFFF7F0E5),
);

SafaehTheme(
  data: const SafaehThemeData(floatingAppearance: appearance),
  child: const MyApp(),
);

SafaehFloatingNavBar(
  floatingAppearance: const SafaehFloatingAppearance(
    style: SafaehFloatingSurfaceStyle.vista,
  ),
  selectedIndex: index,
  onDestinationSelected: (i) => setState(() => index = i),
  destinations: destinations,
);

The presets are solid, translucent, glass (iOS-like), and vista (Vista/Aero-like). transparency, blurSigma, tintColor, border, and shadows are nullable preset overrides. For sheets and dialogs, a call wins over SafaehRouteOptions.floatingAppearance, which wins over the theme:

When tintColor is omitted, glass uses the active theme surface as a white-ish frosted tint in light themes and the matching dark surface in dark themes. Set tintColor when a host wants a branded glass color.

await showSafaeh<void>(
  context: context,
  route: const SafaehRouteOptions(
    floatingAppearance: SafaehFloatingAppearance(
      style: SafaehFloatingSurfaceStyle.translucent,
    ),
  ),
  floatingAppearance: const SafaehFloatingAppearance(
    style: SafaehFloatingSurfaceStyle.glass,
  ),
  child: const MySheetBody(),
);

The treatment applies to package-owned shells: the floating nav, overlay sidenav, narrow page-index trigger/popover, sheet and dialog shells, camera panel, and QR top bar. Scrims, previews, host children, QR message content, wide page-index rails, and host-owned FABs remain unchanged. With no appearance configured, existing rendering is retained, including the floating nav's transparent default.

13. Camera / QR chrome

await showSafaehCameraSheet<void>(
  context: context,
  builder: (context, sheet) => MyPreview(
    expanded: sheet.expanded,
    onToggle: sheet.toggleExpanded,
    onClose: sheet.dismiss,
  ),
);

Embed on a route with SafaehCameraSheetHost (omit openAnimation, pass onDismiss). Put SafaehQrScannerOverlay / SafaehQrMessageBody inside that bottom panel — they are full-bleed overlays, not their own sheet. Keep mobile_scanner in the app.


What stays in the host

Concern Stays in the app
Copy easy_localization, UserText, titleBuilder
Routing go_router; floating sheets stay viewport-centered
Camera mobile_scanner, permissions, SystemChrome orientation lock
State Riverpod / whatever the app already uses
Tiles UserText + optional accent colors on SafaehOptionTile

UI inventory

Sheets: showSafaeh, SafaehSheet, SafaehRouteOptions, showSafaehPicker, SafaehOption, SafaehOptionPickerBody, showSafaehTilePicker, showSafaehMultiTilePicker, SafaehTileOption, SafaehTilePickerBody, SafaehTileBuilder, showSafaehConfirm, SafaehConfirmSheet, showSafaehTextInput, SafaehTextInputSheet, SafaehStatusBody, buildSafaehSheetShell, SafaehOptionList, SafaehOptionTile, kSheetContentPadding, kSafaehSheetPadding, SafaehTitleBuilder, SafaehLabelBuilder, safaehTitleFromLabel, safaehPop, SafaehTransition, safaehFadeScale, safaehFade, SafaehPhoneSheetPlacement, safaehPhoneCenterSheetTop

Dialog: showSafaehDialog

Camera: showSafaehCameraSheet, SafaehCameraSheetHost, SafaehCameraSheet, SheetHandleBar, SheetHandleDrag

QR: SafaehQrScannerOverlay, SafaehQrTopBar, SafaehQrMessageBody, SafaehQrFramePainter

Shell: SafaehSidenav, SafaehSidenavDestination, SafaehSidenavProfile, SafaehSidenavAvatar, SafaehFloatingNavBar, SafaehPageIndex, SafaehPageIndexOverlay, scrollToPageSection, safaehActivePageSectionId, SafaehMorphingAppBar, SafaehMorphingAppBarAction, SafaehMorphingAppBarBottom, safaehBandMetrics, safaehRailAwareBandMetrics, SafaehContentBand, SafaehContentAlignedPage, SafaehEndAsideLayout, SafaehContentAlignedAppBar, SafaehContentAlignedFabLocation

Chrome: SafaehEmptyState, SafaehInlineBanner, SafaehBannerTone, SafaehMetaChip, SafaehGlyphAvatar, SafaehKpiCard, SafaehBorderedListChrome, SafaehSectionHeader, SafaehLtrText, SafaehUserText, SafaehAsyncBody, SafaehLoadingBody, SafaehErrorBody, SafaehAccentStyle, SafaehAccentSurfaces, applySafaehMaterialChrome, scaleSafaehTextTheme, SafaehSemanticStatus

Debug: showSafaehDebugMenu, SafaehDebugMenuFab, SafaehDebugMenuBody, SafaehDebugSection, SafaehL10nEditOverlay, SafaehL10nBackend, SafaehL10nOverrideStore

Tokens: SafaehTheme, SafaehThemeData, SafaehThemeData.copyWith, safaehResolvedMotion, kSafaehCameraCompactHeightFraction, sheetBodyInset, sheetBodyInsetWide

RTL: safaehChevronEnd, safaehChevronStart, safaehArrowBack


Architecture

┌─────────────────────────────────────────────────────────────┐
│         Host app (i18n, GoRouter, camera, UserText)         │
└─────────────────────────────┬───────────────────────────────┘
                              │
┌─────────────────────────────▼───────────────────────────────┐
│                      SafaehTheme                             │
│   breakpoint · motion · radius · rail · camera fraction     │
└─────────────────────────────┬───────────────────────────────┘
                              │
┌─────────────────────────────▼───────────────────────────────┐
│  showSafaeh / picker / confirm / text / dialog              │
│  SafaehSidenav · floating nav · page index · content band   │
│  camera sheet host · QR overlay chrome                      │
└─────────────────────────────┬───────────────────────────────┘
                              │
┌─────────────────────────────▼───────────────────────────────┐
│              Flutter Material (no Riverpod)                 │
└─────────────────────────────────────────────────────────────┘

Example

Live demo — zyzto.github.io/Safaeh

The hosted catalog is the example/ web build. CI deploys it after tests pass on main.

A Riverpod-free catalog lives in example/ — same split as Edadat: catalog.dart (en / ar / ja / zh / es), app.dart (theme + home), and a vertical SafaehContentBand gallery of every public API. Section titles open the standalone demo; wide bands use extra columns on the same page. Language and theme toggles, no mobile_scanner.

The example is package-style (web only in-tree); analyze with:

cd example && flutter pub get && dart analyze --fatal-infos && flutter test

example/test/widget_images_test.dart writes widget PNGs to screenshots/.

To run on a device, generate the other platforms (flutter create . --platforms=android,ios inside example/). Details: example/README.md.

Package tests:

dart analyze --fatal-infos && flutter test

Branding

The logo wordmark uses Baz (Baz Light) — the same Arabic typeface as Edadat and Siglat. The SVG outlines صــفائح (tatweel after ص) so GitHub renders without loading the font. Baz is not registered as a package font and the OTF is not shipped.


Versioning

See VERSIONING.md and CHANGELOG.md. Tags are vX.Y.Z and must match pubspec.yaml.


License

MPL-2.0 — weak copyleft, commercial use allowed. Modified package files stay under MPL; your app can remain closed-source.

Libraries

safaeh
Safaeh: sheets, anchored dropdowns, morphing app bars, camera/QR chrome, page index, sidenav, and content-aside.