safaeh 0.4.0
safaeh: ^0.4.0 copied to clipboard
Adaptive sheets, dialogs, camera/QR chrome, page index, and sidenav for Flutter.
[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
— 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 — live demo] [Confirm sheet — live demo] [Option tiles — live demo]
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 |
|---|---|
| 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; showSafaehPicker / SafaehOption (cards, enabled); showSafaehTilePicker / SafaehTileOption (list rows, search); showSafaehMultiTilePicker (multi-select); showSafaehConfirm, showSafaehTextInput, SafaehStatusBody, 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 with optional railWidthOf |
| Theme | SafaehTheme / SafaehThemeData for breakpoint, motion, radius, rail widths, camera compact height, contentMaxWidth, floatingAppearance; copyWith |
| 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, 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.4.0
Or:
flutter pub add safaeh
Git tag pin (see VERSIONING.md):
dependencies:
safaeh:
git:
url: https://github.com/Zyzto/Safaeh.git
ref: v0.4.0
import 'package:safaeh/safaeh.dart';
Current version: 0.4.0.
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).
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 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),
);
7. Centered dialog #
await showSafaehDialog<void>(
context: context,
railWidthOf: (context) => 0,
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, 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, SafaehMorphingAppBar, SafaehMorphingAppBarAction, SafaehMorphingAppBarBottom, 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.