Skip to content

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 wrapping ElevatedButton/FilledButton.tonal/ OutlinedButton/TextButton (or their .icon/.tonalIcon counterparts via KhButton.icon), selected via KhButtonVariant or one of the KhButton.filled/.primary/.secondary/.outline/.text/.icon named constructors. Forwards onLongPress, focusNode, and autofocus to the wrapped Material widget, alongside onPressed/style. Also takes size (KhButtonSize.small/.medium/.large), pill (stadium shape) and isLoading (spinner over the label, taps swallowed; not on KhButton.icon).
  • KhTextField — a themed text input wrapping TextFormField (a practical subset of its constructor: controller/validation/formatting/keyboard behavior). decoration is the override point, merged over Theme.of(context).inputDecorationTheme via Flutter's own InputDecoration.applyDefaults.
  • KhCard — a themed container wrapping Card, deferring to Theme.of(context).cardTheme by default; color/elevation/shape/ margin/clipBehavior all override the theme per instance when set. Passing onTap/onLongPress makes it tappable (an InkWell inside the card).
  • KhSemanticColor/resolveKhSemanticColor — resolves success/ warning/info/error/neutral to an actual color, reading Theme.of(context).extension<KhThemeExtension>() for the first three (falling back to ColorScheme if none is registered).
  • KhChip/KhBadge — themed status indicators wrapping Chip/Badge, the first real consumers of kh_theme's KhThemeExtension, via KhSemanticColor.
  • KhDialog — a themed dialog wrapping AlertDialog (a practical subset of its constructor), plus KhDialog.alert/.confirm/.success static helpers wrapping showDialog for the most common cases.
  • KhBottomSheet — a themed modal sheet body (optional title, required content), plus KhBottomSheet.show<T> wrapping showModalBottomSheet.
  • KhSnackbar.show — shows a themed SnackBar through the nearest ScaffoldMessenger, with an optional KhSemanticColor variant and action; it clears the current snackbar first unless clearPrevious: false.
  • KhDropdown<T> — a themed DropdownButtonFormField taking standard DropdownMenuItem<T>s, with validator/onSaved form support.
  • KhDatePicker — a FormField<DateTime> that opens the Material date picker between firstDate and lastDate.
  • KhFieldLabel — a label shown above a field, with an optional required-field marker (required: true).
  • KhCheckbox, KhSwitch, KhRadio<T>/KhRadioGroup<T> — themed selection controls. KhRadioGroup wraps Flutter's RadioGroup, which manages selection for every KhRadio below it; KhCheckbox.isError shows the error color.
  • KhLoadingState/KhEmptyState/KhErrorState — the three states most list/detail screens need. KhErrorState accepts a KhFailure from kh_core (shown as user-safe copy via KhFailureMessages, never its raw message) and an optional retry callback (rendered as a KhButton), so every screen renders errors consistently.
  • KhBottomNav — app-shell bottom navigation with per-item badges, in three styles: the standard Material 3 bar, a floating rounded bar, and KhBottomNav.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:

melos bootstrap   # from the repo root, once
cd packages/example
flutter run
cd packages/example && flutter test
# or, from the repo root:
melos run test:flutter

Customization principle

Every Kh<Component> in this package:

  1. Wraps a standard Flutter/Material widget rather than reimplementing it.
  2. Mirrors that widget's own constructor shape, so adopting it in an existing screen is close to a drop-in rename, not a rewrite.
  3. Accepts an explicit style/override parameter that takes precedence over theme defaults for that one call site.
  4. Reads its default styling from Theme.of(context) — never from kh_theme's token classes directly — so a consuming app's custom KhTheme.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:

KhCard(
  child: Padding(
    padding: const EdgeInsets.all(16),
    child: Text('Content'),
  ),
);

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:

  1. The widget's own messageBuilder, for one screen's wording.
  2. The nearest KhFailureMessages, for app-wide wording or translations.
  3. 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.