kh_ui¶
Reusable widget library (buttons, inputs, cards, dialogs, and loading/empty/error states) for khflutterlib-based apps.
See PHASE_0_ARCHITECTURE.md §17 for the
customization principle every widget here follows.
Status¶
Available. Version 0.2.2+2, shipped in release train khflutterlib-v0.4.3.
Exports:
KhButton— a themed button wrappingElevatedButton/FilledButton.tonal/OutlinedButton/TextButton(or their.icon/.tonalIconcounterparts viaKhButton.icon), selected viaKhButtonVariantor one of theKhButton.filled/.primary/.secondary/.outline/.text/.iconnamed constructors. ForwardsonLongPress,focusNode, andautofocusto the wrapped Material widget, alongsideonPressed/style. Also takessize(KhButtonSize.small/.medium/.large),pill(stadium shape) andisLoading(spinner over the label, taps swallowed; not onKhButton.icon).KhTextField— a themed text input wrappingTextFormField(a practical subset of its constructor: controller/validation/formatting/keyboard behavior).decorationis the override point, merged overTheme.of(context).inputDecorationThemevia Flutter's ownInputDecoration.applyDefaults.KhCard— a themed container wrappingCard, deferring toTheme.of(context).cardThemeby default;color/elevation/shape/margin/clipBehaviorall override the theme per instance when set. PassingonTap/onLongPressmakes it tappable (anInkWellinside the card).KhSemanticColor/resolveKhSemanticColor— resolvessuccess/warning/info/error/neutralto an actual color, readingTheme.of(context).extension<KhThemeExtension>()for the first three (falling back toColorSchemeif none is registered).KhChip/KhBadge— themed status indicators wrappingChip/Badge, the first real consumers ofkh_theme'sKhThemeExtension, viaKhSemanticColor.KhDialog— a themed dialog wrappingAlertDialog(a practical subset of its constructor), plusKhDialog.alert/.confirm/.successstatic helpers wrappingshowDialogfor the most common cases.KhBottomSheet— a themed modal sheet body (optionaltitle, requiredcontent), plusKhBottomSheet.show<T>wrappingshowModalBottomSheet.KhSnackbar.show— shows a themedSnackBarthrough the nearestScaffoldMessenger, with an optionalKhSemanticColorvariantandaction; it clears the current snackbar first unlessclearPrevious: false.KhDropdown<T>— a themedDropdownButtonFormFieldtaking standardDropdownMenuItem<T>s, withvalidator/onSavedform support.KhDatePicker— aFormField<DateTime>that opens the Material date picker betweenfirstDateandlastDate.KhFieldLabel— a label shown above a field, with an optional required-field marker (required: true).KhCheckbox,KhSwitch,KhRadio<T>/KhRadioGroup<T>— themed selection controls.KhRadioGroupwraps Flutter'sRadioGroup, which manages selection for everyKhRadiobelow it;KhCheckbox.isErrorshows the error color.KhLoadingState/KhEmptyState/KhErrorState— the three states most list/detail screens need.KhErrorStateaccepts aKhFailurefromkh_core(shown as user-safe copy viaKhFailureMessages, never its raw message) and an optional retry callback (rendered as aKhButton), so every screen renders errors consistently.KhBottomNav— app-shell bottom navigation with per-item badges, in three styles: the standard Material 3 bar, afloatingrounded bar, andKhBottomNav.pill, a dark floating pill where the selected tab expands to show its icon and label side by side:
Scaffold(
extendBody: true, // content scrolls behind the floating pill
bottomNavigationBar: KhBottomNav.pill(
items: const [
KhBottomNavItem(icon: Icon(Icons.home_outlined), label: 'Home'),
KhBottomNavItem(icon: Icon(Icons.search), label: 'Search'),
],
selectedIndex: index,
onDestinationSelected: (i) => setState(() => index = i),
),
);
The pill's colors default to the theme's inverseSurface (bar) and
primary (selected tab), overridable with backgroundColor,
indicatorColor, foregroundColor and selectedForegroundColor.
Every tab is announced to screen readers with its label, even when only
its icon shows, and switches instantly when the device asks for reduced
motion.
- KhNavDrawer — a themed navigation drawer with header, footer, badges
and section dividers.
- KhSkeleton / KhShimmer — loading placeholders: rectangles, circles
and text lines, animated in sync by one KhShimmer around the whole
loading layout. The shimmer stops under reduced motion.
- KhAvatar — a picture, falling back to initials ("XG") when there's no
picture or it fails to load. The background is picked by a stable hash
of the name.
- KhSearchField — search input with a clear button, an optional trailing
action, and debounced onChanged (300ms by default; submit and clear
notify immediately).
- KhSegmentedControl — a pill toggle between two to four options
("Pickup | Delivery") with a sliding thumb.
- KhSelectableChip — an on/off filter chip (wraps FilterChip).
KhChip is the read-only status pill.
- KhQuantityStepper — a "− 2 +" control with min/max/step; buttons
disable at the limits, and screen readers can increase or decrease it
directly.
- KhNetworkImage — a sized image with a placeholder, fade-in and a
broken-image fallback. It uses NetworkImage by default; pass any
ImageProvider (e.g. CachedNetworkImageProvider) to
KhNetworkImage.provider for disk caching. kh_ui doesn't depend on a
caching package itself.
- KhOptionCard — a radio-style card with icon, title, subtitle and
trailing text, for choices like delivery methods.
- KhStepIndicator — progress through a multi-step flow, announced as
"Step 2 of 3: Delivery".
- KhStatTile — a dashboard number with label, icon and a trend coloured
by meaning (KhTrend.upBad is red, KhTrend.downGood green).
All of these take their colors from the theme and their corner radii
from KhShapeTokens, and the example/ app's Components screen shows
each one with live state.
Example app¶
The workspace's shared gallery app, packages/example, demonstrates every
widget above, themed via KhTheme. It's manual-QA tooling, not published
or consumed by any other package:
Customization principle¶
Every Kh<Component> in this package:
- Wraps a standard Flutter/Material widget rather than reimplementing it.
- Mirrors that widget's own constructor shape, so adopting it in an existing screen is close to a drop-in rename, not a rewrite.
- Accepts an explicit
style/override parameter that takes precedence over theme defaults for that one call site. - Reads its default styling from
Theme.of(context)— never fromkh_theme's token classes directly — so a consuming app's customKhTheme.light(seedColor: ..., fontFamily: ...)reaches every widget here automatically.
See PHASE_0_ARCHITECTURE.md §17 for the full rationale.
Usage¶
dependencies:
kh_ui:
git:
url: https://github.com/msflib/flutter.git
path: packages/kh_ui
ref: khflutterlib-v0.4.3 # use the same release tag for every kh_* package
import 'package:kh_theme/kh_theme.dart';
import 'package:kh_ui/kh_ui.dart';
MaterialApp(
theme: KhTheme.light(seedColor: Colors.teal), // from kh_theme
home: Scaffold(
body: KhButton(
onPressed: () {},
child: const Text('Continue'),
),
),
);
Named constructors cover the common variants:
KhButton.filled(onPressed: () {}, child: const Text('Checkout'));
KhButton.primary(onPressed: () {}, child: const Text('Save'));
KhButton.secondary(onPressed: () {}, child: const Text('Maybe later'));
KhButton.outline(onPressed: () {}, child: const Text('Cancel'));
KhButton.text(onPressed: () {}, child: const Text('Learn more'));
.filled is a solid primary fill (FilledButton), the strongest call to
action. .primary is Material 3's elevated button: a raised surface with
primary-coloured content, not a solid fill.
Every variant takes its corner radius from KhShapeTokens.button, and
cards, chips, dialogs, sheets, snackbars and inputs take theirs from the
matching token. test/theming/shape_tokens_test.dart checks this for
every shaped widget, so a widget that hard-codes a radius fails CI.
Any variant accepts an explicit style override for a one-off deviation,
without forking the widget or overriding the whole app's Theme:
KhButton.outline(
onPressed: () {},
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.teal, width: 2),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
),
child: const Text('Custom'),
);
kh_theme themes all four underlying Material button types with the same
KhShapeTokens.button shape, so every KhButton variant is visually
consistent by default, not just individually theme-colored.
size, pill and isLoading work on every constructor (isLoading
excepted on KhButton.icon). While isLoading is true the label fades
out under a spinner and taps are swallowed, but the button keeps its
enabled style, so a submit button doesn't flicker grey mid-request:
KhButton.filled(
onPressed: submit,
size: KhButtonSize.large,
pill: true,
isLoading: isSubmitting,
child: const Text('Place order'),
);
For an icon-and-label button, KhButton.icon forwards to the wrapped
Material widget's own .icon/.tonalIcon constructor — this keeps Material
3's own icon/label spacing and alignment, rather than losing it to a
hand-rolled Row when migrating an existing .icon button call site:
KhButton.icon(
onPressed: () {},
icon: const Icon(Icons.download),
label: const Text('Download'),
);
KhButton.icon(
onPressed: () {},
variant: KhButtonVariant.outline,
icon: const Icon(Icons.download),
label: const Text('Download'),
);
icon is optional on KhButton.icon (a label-only button is just a plain
KhButton), but label is required, matching every Material .icon
constructor it wraps.
onLongPress, focusNode, and autofocus are available on every
constructor, forwarded straight through to the wrapped Material widget:
KhButton(
onPressed: () {},
onLongPress: () => print('long-pressed'),
autofocus: true,
child: const Text('Go'),
);
KhTextField wraps TextFormField, themed by
Theme.of(context).inputDecorationTheme (from kh_theme) by default:
KhTextField(
decoration: const InputDecoration(labelText: 'Email'),
keyboardType: TextInputType.emailAddress,
validator: (value) =>
(value == null || value.isEmpty) ? 'Required' : null,
onChanged: (value) {},
);
Any InputDecoration field you set (label, hint, icons, border, ...)
overrides the theme for that one instance via Flutter's own
InputDecoration.applyDefaults — unset fields still fall back to the
theme, so no need to repeat the whole decoration just to change one part.
This first pass mirrors the TextFormField properties most call sites
actually use (validation, controllers, keyboard behavior, formatting);
less common ones are added as real screens need them — see the "Scope
note" on KhTextField's own doc comment.
KhTextField also enforces a few of TextFormField's own constraints at
construction time, via assert, instead of leaving them to surface as a
confusing runtime failure later: controller/initialValue are mutually
exclusive, an obscured field can't be multiline (obscureText implies
maxLines == 1), and minLines can't exceed maxLines.
An obscured field (obscureText: true) disables autocorrect and
enableSuggestions by default, so a password/PIN isn't cached or
suggested by the platform keyboard — set either explicitly to override:
KhTextField(
decoration: const InputDecoration(labelText: 'Password'),
obscureText: true,
maxLines: 1,
); // autocorrect/enableSuggestions default to false here
KhCard wraps Card, themed by Theme.of(context).cardTheme (from
kh_theme) by default:
Every constructor parameter is null by default and falls back to the
theme, same as Card itself — pass any of them to override for a single
instance:
KhCard(
elevation: KhElevation.level3, // from kh_theme, a one-off deviation
color: Colors.amber.shade50,
child: const Text('Highlighted'),
);
Pass onTap (and/or onLongPress) to make the whole card tappable.
KhCard puts an InkWell inside the card for you, clipped to its shape:
KhCard(
onTap: () => Navigator.of(context).pushNamed('/details'),
child: const ListTile(title: Text('Order #1042')),
);
KhLoadingState, KhEmptyState, and KhErrorState cover the three
states most list/detail screens need:
KhLoadingState(message: const Text('Loading orders...'));
KhEmptyState(
icon: const Icon(Icons.inbox_outlined),
title: const Text('No orders yet'),
message: const Text('Orders you place will show up here.'),
);
KhErrorState(
failure: failure, // a KhFailure from kh_core, e.g. from a KhResult
onRetry: () => refresh(),
);
KhErrorState renders user-facing copy for the failure and, when
onRetry is given, a KhButton.primary retry action, so every screen in
an app renders errors the same way instead of each one inventing its own
error UI.
Error copy¶
KhErrorState never shows KhFailure.message. That field is for
developers and logs, and can hold raw backend text such as
PostgrestException(...). The text comes from
KhFailureMessages.resolve, which checks three sources in order:
- The widget's own
messageBuilder, for one screen's wording. - The nearest
KhFailureMessages, for app-wide wording or translations. KhFailureMessages.defaultMessage, built-in copy chosen by failure type:
| Failure | Default copy |
|---|---|
KhNetworkFailure |
You're offline. Check your connection and try again. |
KhTimeoutFailure |
This is taking longer than usual. Please try again. |
KhUnauthorizedFailure |
Your session has expired. Please sign in again. |
KhNotFoundFailure |
We couldn't find what you were looking for. |
KhServerFailure (403) |
You don't have access to this. |
KhServerFailure (429) |
Too many attempts. Please wait a moment and try again. |
KhPermissionFailure |
This needs a permission that hasn't been granted yet. |
KhValidationFailure |
its own message, which is written for users |
| anything else | Something went wrong. Please try again. |
A builder returns null to defer to the next source, so an app can
override just the cases it cares about:
MaterialApp(
builder: (context, child) => KhFailureMessages(
builder: (context, failure) => switch (failure) {
KhNotFoundFailure() => 'This listing is no longer available.',
KhServerFailure(statusCode: 409) => 'That name is already taken.',
_ => null, // keep the built-in default
},
child: child!,
),
);
Migrating from 0.1.x: screens that relied on KhErrorState showing
failure.message now show the default copy for the failure's type. To
keep a specific message, pass it through messageBuilder or
KhFailureMessages, or use a KhValidationFailure if it's genuinely
written for users.
KhChip and KhBadge are the first widgets to actually consume
kh_theme's KhThemeExtension — their variant resolves to a color via
resolveKhSemanticColor: success/warning/info read
KhThemeExtension, error reads ColorScheme.error directly (Material
already has a slot for it), and neutral is a muted theme-appropriate
gray:
KhChip(
variant: KhSemanticColor.success,
label: const Text('Active'),
);
KhBadge(
label: const Text('3'),
child: const Icon(Icons.notifications),
); // defaults to the error variant, matching Badge's own default
KhBadge(
variant: KhSemanticColor.success,
isLabelVisible: false, // a plain dot, no count
child: const Icon(Icons.circle),
);
KhDialog wraps AlertDialog, with KhDialog.alert/.confirm static
helpers for the two most common cases — built on KhDialog and
KhButton, so every dialog in an app looks consistent without each
call site re-wiring Navigator.pop by hand:
await KhDialog.alert(
context,
title: const Text('Saved'),
content: const Text('Your changes have been saved.'),
);
final confirmed = await KhDialog.confirm(
context,
title: const Text('Delete item?'),
content: const Text("This can't be undone."),
);
if (confirmed ?? false) {
// proceed
}
confirm resolves to true (confirmed), false (cancelled), or null
(dismissed without choosing, e.g. a barrier tap). Both helpers forward
barrierDismissible/useRootNavigator to showDialog (default true
for both, matching showDialog itself) — set barrierDismissible:
false to force an explicit button tap instead of allowing a barrier tap
or back gesture to dismiss:
await KhDialog.alert(
context,
title: const Text('Session expired'),
barrierDismissible: false, // must tap OK
);
For anything more custom, build a KhDialog directly and pass your own
actions:
showDialog<void>(
context: context,
builder: (context) => KhDialog(
title: const Text('Delete item?'),
content: const Text("This can't be undone."),
actions: [
KhButton.text(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
KhButton.primary(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Delete'),
),
],
),
);
KhDialog.success shows a confirmation with an icon, a title, a message
and a single continue button:
await KhDialog.success(
context,
title: const Text('Order placed'),
message: const Text("We'll let you know when it ships."),
);
With no onContinue, the continue button just closes the dialog. Passing
onContinue replaces that, so your callback has to pop the dialog itself.
KhDialog's shape comes from kh_theme's dialogTheme, which uses
KhShapeTokens.dialog.
KhBottomSheet.show works the same way for modal sheets, and resolves to
whatever value the sheet is popped with:
final plan = await KhBottomSheet.show<String>(
context,
title: const Text('Choose a plan'),
content: const Text('...'),
);
KhSnackbar.show needs a context below a Scaffold (it uses
ScaffoldMessenger.of(context)):
KhSnackbar.show(context, content: const Text('Saved'));
KhSnackbar.show(
context,
content: const Text('Could not save changes'),
variant: KhSemanticColor.error,
action: SnackBarAction(label: 'Retry', onPressed: retry),
);
Form inputs beyond KhTextField:
const KhFieldLabel('Plan', required: true);
KhDropdown<Plan>(
decoration: const InputDecoration(labelText: 'Plan'),
items: const [
DropdownMenuItem(value: Plan.free, child: Text('Free')),
DropdownMenuItem(value: Plan.pro, child: Text('Pro')),
],
onChanged: (value) {},
);
KhDatePicker(
decoration: const InputDecoration(labelText: 'Start date'),
firstDate: DateTime(2020),
lastDate: DateTime(2030),
onChanged: (date) {},
);
KhRadioGroup<Plan>(
groupValue: plan,
onChanged: (value) => setState(() => plan = value),
child: Column(
children: [
Row(children: [KhRadio<Plan>(value: Plan.free), const Text('Free')]),
Row(children: [KhRadio<Plan>(value: Plan.pro), const Text('Pro')]),
],
),
);
Both KhChip/KhBadge accept an explicit backgroundColor (and
KhChip a labelStyle)
that overrides the resolved semantic color for a single instance, same
role style plays on KhButton. resolveKhSemanticColor falls back to
plain ColorScheme colors if the active theme wasn't built via
KhTheme.light/KhTheme.dark (so it has no KhThemeExtension
registered) — it won't crash, just look less on-brand.
KhChip.labelStyle is merged over the theme's own chip label style
and the resolved semantic color, not substituted for them — a
labelStyle that only sets fontWeight, say, still keeps the theme's
font and the semantic color unless it sets its own color too:
KhChip(
variant: KhSemanticColor.success,
labelStyle: const TextStyle(fontWeight: FontWeight.bold), // color still green
label: const Text('Active'),
);
deleteIconColor defaults to that same resolved color, so the delete
icon matches the label unless overridden separately. KhBadge.textColor
similarly avoids inheriting Badge's own onError-based default (which
only reads well against an error background) — it computes black or
white from the effective background's actual brightness instead.