Safaeh - صفائح
safaeh
Adaptive sheets, camera / QR chrome, page index, and sidenav for Flutter —
host app keeps i18n, routing, and the camera plugin.
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, ormobile_scannerinside the package
Safaeh is that chrome layer. Used in Hisab.
On pub.dev: safaeh · Repo: Zyzto/Safaeh.
Widgets
Card picker · Confirm · Option tiles — 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 |
|---|---|
| Sheets | showSafaeh morphs phone sheet ↔ tablet dialog; showSafaehPicker / SafaehOption (cards, enabled); showSafaehTilePicker / SafaehTileOption (list rows, search); showSafaehMultiTilePicker (multi-select); showSafaehConfirm, showSafaehTextInput, SafaehStatusBody, buildSafaehSheetShell, SafaehOptionList, SafaehOptionTile |
| Dialog | showSafaehDialog centered panel with optional railWidthOf |
| Theme | SafaehTheme / SafaehThemeData for breakpoint, motion, radius, rail widths, camera compact height, contentMaxWidth; copyWith |
| Motion | safaehResolvedMotion zeros durations when animations are disabled |
| Nav | SafaehSidenav temporary drawer (asDrawer: true) or clipping rail; SafaehFloatingNavBar (same SafaehSidenavDestination) |
| Page index | SafaehPageIndex, overlay, scrollToPageSection, safaehActivePageSectionId (ids + keys only — no .tr() on scroll) |
| Content | safaehBandMetrics, SafaehContentBand, SafaehEndAsideLayout, SafaehContentAlignedAppBar, SafaehContentAlignedFabLocation |
| 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.2.1
Or:
flutter pub add safaeh
Git tag pin (see VERSIONING.md):
dependencies:
safaeh:
git:
url: https://github.com/Zyzto/Safaeh.git
ref: v0.2.1
import 'package:safaeh/safaeh.dart';
Current version: 0.2.1.
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. 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).
3. 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.
4. 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, …).
5. Confirm and text input
Host passes every label. Phone shows cancel in the action row; tablet uses the
sheet close control. showSafaehConfirm returns true if confirmed, false
if the phone cancel button is pressed, and null if dismissed (tablet close,
barrier, or system back). Treat only ok == true as confirmed.
final ok = await showSafaehConfirm(
context: context,
title: 'Delete item',
content: 'This cannot be undone.',
confirmLabel: 'Delete',
cancelLabel: 'Cancel',
isDestructive: true,
titleBuilder: (context, style) => Text('Delete item', style: style),
);
final name = await showSafaehTextInput(
context: context,
title: 'Tag name',
doneLabel: 'Done',
cancelLabel: 'Cancel',
titleBuilder: (context, style) => Text('Tag name', style: style),
);
6. Centered dialog
await showSafaehDialog<void>(
context: context,
railWidthOf: (context) => 0,
builder: (context) => const Card(child: Text('Hello')),
);
7. 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,
);
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.
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),
);
8. 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, reserved rail width via railWidthOf |
| 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, 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, safaehBandMetrics, SafaehContentBand, SafaehEndAsideLayout, SafaehContentAlignedAppBar, SafaehContentAlignedFabLocation
Tokens: SafaehTheme, SafaehThemeData, SafaehThemeData.copyWith, safaehResolvedMotion, kSafaehCameraCompactHeightFraction
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, camera/QR chrome, page index, sidenav, and content-aside.