breaks_on_fridays_ui

A thin, opinionated layer of styled widgets on top of shadcn_flutter, accessed through a single BoF entry point.

Glossary

Core

Display & layout

Forms

Getting started

Add the package, then wrap your app in shadcn_flutter's ShadcnApp as usual (re-exported by this package, so import 'package:breaks_on_fridays_ui/breaks_on_fridays_ui.dart'; is all you need):

import 'package:breaks_on_fridays_ui/breaks_on_fridays_ui.dart';

void main() => runApp(
      ShadcnApp(
        home: Scaffold(child: BoF.text('Hello')),
      ),
    );

Custom styling

Most BoF.* widgets accept direct backgroundColor/foregroundColor/ fontSize/borderRadius params (plus a few extras per widget — border color/width, font weight, padding, track colors, etc.) to override individual pieces of their default styling, without having to build a whole shadcn_flutter theme object yourself:

BoF.button(
  'Custom',
  onPressed: () {},
  backgroundColor: Colors.purple,
  foregroundColor: Colors.white,
  fontSize: 16,
  borderRadius: BorderRadius.circular(20),
);

BoF.textField(
  placeholder: const Text('Search'),
  backgroundColor: Colors.blue.withValues(alpha: 0.08),
  foregroundColor: Colors.blue[900],
  borderRadius: BorderRadius.circular(16),
  onChanged: (v) {},
);

Two widget families exist internally in shadcn_flutter, and this package's override params bridge both the same way:

  • Button-family widgets (BoF.button, BoF.badge, BoF.chip, the chips rendered by BoF.multipleChoiceField/BoF.multipleAnswerField) resolve their look through a single AbstractButtonStyle. bofButtonStyle(base, {backgroundColor, foregroundColor, fontSize, fontWeight, borderRadius, borderColor, borderWidth, padding}) — exported alongside BoF — builds an override of that style, keeping base's hover/press/disabled behavior for anything you don't override. It's what each of those widgets' convenience params use internally, and it's public so you can build your own AbstractButtonStyle the same way (e.g. for BoF.badge's style param).
  • Direct-param widgets (BoF.textField, BoF.checkboxField, BoF.card, BoF.container, BoF.starRatingField, etc.) already take plain Color/TextStyle/BorderRadiusGeometry fields on the underlying shadcn_flutter widget, so their BoF.* wrapper just forwards them directly — no extra machinery needed.

A few widgets have no styling surface at all upstream (their shadcn_flutter implementation hardcodes colors with no theme or constructor override): BoF.colorField, BoF.phoneField, BoF.dateInputField, BoF.timeInputField, BoF.durationInputField, and per-cell colors on BoF.otpField (only its spacing/height are themable). Their doc comments call this out; where a picker/dialog counterpart exists (e.g. BoF.datePickerField for BoF.dateInputField), prefer that if custom colors matter.

Core

BoF.text

Returns a plain Text widget, so every shadcn_flutter typography modifier chains directly onto it:

BoF.text('Title').h1
BoF.text('Section').h3
BoF.text('Body copy').muted
BoF.text('Emphasis').bold.large

BoF.button

BoF.button(label, {type, icon, iconPosition, onPressed, ...}) mirrors shadcn_flutter's Button variants through BoFButtonType:

BoF.button('Save', onPressed: submit); // BoFButtonType.primary (default)
BoF.button('Cancel', type: BoFButtonType.outline, onPressed: cancel);
BoF.button('Delete', type: BoFButtonType.destructive, onPressed: delete);

BoFButtonType values: primary, secondary, outline, ghost, link, text, destructive. All of Button's other parameters (size, density, shape, focus/hover/tap/long-press callbacks, etc.) are exposed too.

label and icon are both optional — pass just one for a text-only or icon-only button, or both and position the icon with iconPosition:

BoF.button(null, icon: const Icon(Icons.add), onPressed: create); // icon-only
BoF.button('Save', onPressed: submit); // label-only
BoF.button(
  'Next',
  icon: const Icon(Icons.arrow_forward),
  iconPosition: BoFIconPosition.right, // left (default), right, top, bottom
  onPressed: next,
);

left/right use the button's native leading/trailing slots; top/bottom stack the icon and label in a column. Icon-only buttons default to ButtonDensity.icon padding unless density is set explicitly.

Content is centered (alignment: Alignment.center) by default — including icon+label buttons, which the underlying Button otherwise left-aligns. Pass an explicit alignment to override.

BoF.container

BoF.container(child, {type, ...}) wraps shadcn_flutter's OutlinedContainer and colors it from the theme based on BoFContainerType:

BoF.container(BoF.text('Card body'), type: BoFContainerType.outline); // default
BoF.container(BoF.text('Highlighted'), type: BoFContainerType.filled);
BoF.container(BoF.text('Danger'), type: BoFContainerType.destructive);

BoFContainerType values: filled, secondary, outline, ghost, destructive. Explicit backgroundColor/borderColor/borderWidth always override the type-based default.

See also BoF.card for a container tuned specifically for content cards (its own fill/shadow/padding defaults).

BoF.alertDialog

BoF.alertDialog(context, {...}) shows shadcn_flutter's AlertDialog and returns a Future that resolves when it closes. The common case is plain strings:

final confirmed = await BoF.alertDialog(
  context,
  title: 'Delete item',
  content: 'This action cannot be undone.',
  positiveText: 'Delete',
  negativeText: 'Cancel',
  positiveType: BoFButtonType.destructive,
);
if (confirmed == true) { ... }

By default, the positive button pops true and the negative button pops false; pass onPositive/onNegative to run your own logic instead (you're then responsible for popping, e.g. via Navigator.pop(context, ...)).

For anything the string params can't express, pass a widget instead: titleWidget/contentWidget override title/content, and actions overrides positiveText/negativeText entirely with your own button list. Button styling for the default positive/negative buttons reuses BoFButtonType — the same enum BoF.button uses — via positiveType/negativeType (defaulting to primary/outline), rather than introducing a separate enum just for the dialog:

BoF.alertDialog(
  context,
  titleWidget: Row(children: [Icon(Icons.warning), BoF.text('Careful')]),
  contentWidget: BoF.text('Custom content widget').muted,
  actions: [
    BoF.button('Got it', onPressed: () => Navigator.pop(context)),
  ],
);

Display & layout

Thin wrappers over more of shadcn_flutter's components, each exposing its key params plus a way to override styling.

BoF.avatar

BoF.avatar(initials: 'JD'); // initials only
BoF.avatar(initials: 'JD', photoUrl: user.photoUrl); // falls back to initials on load failure

badge takes an AvatarWidget (typically AvatarBadge(...), from shadcn_flutter directly) for a status dot/counter overlay, positioned via badgeAlignment/badgeGap.

BoF.badge

BoF.badge(BoF.text('New'), type: BoFBadgeType.destructive);

BoFBadgeType values: primary, secondary, outline, destructive. Accepts an AbstractButtonStyle? style to fully override its appearance — the same escape-hatch shape as BoF.button.

BoF.chip

BoF.chip(
  BoF.text('Filter'),
  trailing: BoF.chipButton(child: const Icon(Icons.close), onPressed: remove),
);

BoF.chipButton is a small button meant for a chip's leading/trailing slot. BoF.chip also accepts AbstractButtonStyle? style.

BoF.divider

BoF.divider(); // plain horizontal rule
BoF.divider(child: BoF.text('OR')); // with a centered label

BoF.verticalDivider

BoF.verticalDivider();

Same shape as BoF.divider, rotated — width instead of height.

BoF.tooltip

BoF.tooltip(BoF.button('Hover me', onPressed: () {}), message: 'Tooltip text');

Pass builder instead of message for a fully custom tooltip widget (rebuilt on each show).

BoF.popover

BoF.popover(icon, content: BoF.text('Popover content')); // shows on hover/long-press

Wraps shadcn_flutter's HoverCard. Pass builder instead of content for a fully custom, rebuilt-per-show popover.

BoF.progress

BoF.progress(progress: 0.65); // determinate
BoF.progress(); // progress: null -> indeterminate

Animates towards its value by default; pass disableAnimation: true to snap instantly.

BoF.circularProgress

BoF.circularProgress(value: 0.65); // determinate
BoF.circularProgress(); // value: null -> spinner

Same animate/animated: false behavior as BoF.progress.

BoF.skeleton

BoF.skeleton(BoF.text('Loading...'), enabled: isLoading);

Wraps any widget with shadcn_flutter's Skeletonizer; toggle enabled based on your loading state.

BoF.alert

BoF.alert(title: BoF.text('Heads up'), content: BoF.text('Something happened.'));
BoF.alert(title: BoF.text('Error'), destructive: true);

BoF.accordion

BoF.accordion(items: [
  BoFAccordionItem(title: BoF.text('Section 1'), content: BoF.text('Body 1')),
  BoFAccordionItem(title: BoF.text('Section 2'), content: BoF.text('Body 2')),
]);

Takes List<BoFAccordionItem> (title, content, expanded); only one item should set expanded: true.

BoF.card

BoF.card(BoF.text('Card body'), filled: true);

Distinct from BoF.container: Card has its own fill/shadow/padding defaults tuned for content cards (filled, fillColor, boxShadow, etc.), whereas BoF.container is a bare outlined/filled surface styled via BoFContainerType.

BoF.toast

BoF.toast(context, title: 'Saved', message: 'Your changes were saved.');

Shows a notification and returns a ToastOverlay (.isShowing, .close()). Its content defaults to BoF.alert styling (title/message/destructive) with a close button when dismissible (the default); pass content or builder for a fully custom toast body. location defaults to ToastLocation.bottomRight. Requires a ToastLayer ancestor — shadcn_flutter's ShadcnApp already provides one.

BoF.dropdownMenu

BoF.dropdownMenu(context, items: [
  BoFMenuItem(child: BoF.text('Edit'), onPressed: edit),
  BoFMenuItem(child: BoF.text('Delete'), onPressed: delete),
]);

Shows a menu anchored near context — typically opened from a BoF.button's onPressed. Each BoFMenuItem takes child/leading/trailing/onPressed/enabled, and an optional subMenu: List<BoFMenuItem> for nested menus.

Forms

BoF.form takes a list of field specs — plain data describing what each field is — and renders the matching shadcn_flutter widget itself. There is no TextEditingController (or any other controller) to create, wire up, or dispose: every field owns its value internally, and the form's onSubmit callback receives the finished values as a Map<String, Object?>.

Every field kind is also available as a plain standalone BoF.xxxField widget — the same rendering logic BoF.form uses, without the form/controller/validation machinery. Each is named to match its BoFXxxField form-spec counterpart one-to-one, since they render the exact same widget. Reach for the standalone version when you just need one value (a search box, a filter toggle) and don't want the overhead of a form for it; it takes initialValue/onChanged/enabled directly, with no label/hint/validator (add those yourself if needed, or use BoF.form with a single field).

Every standalone field also accepts an optional controller for updating its value programmatically — set controller.value and the field updates without a rebuild or a BoFFormController. When passed, it takes precedence over initialValue. The controller type differs per field; see each field's section below.

BoF.form

BoF.form(
  [
    BoFTextField(
      name: 'email',
      label: BoF.text('Email'),
      validator: const EmailValidator(),
    ),
    BoFTextField(
      name: 'password',
      label: BoF.text('Password'),
      obscureText: true,
      validator: const NotEmptyValidator() & const LengthValidator(min: 8),
    ),
    BoFCheckboxField(
      name: 'agree',
      label: BoF.text('I agree to the terms'),
      validator: const NonNullValidator<CheckboxState>(),
    ),
  ],
  onSubmit: (values) {
    print(values['email']);
    print(values['password']);
  },
)

Reading/driving the form like a ref

Pass a BoFFormController to read live values, listen for changes, or trigger validation/submission programmatically — this is the "ref" for the whole form:

final controller = BoFFormController();

BoF.form(fields, controller: controller, onSubmit: save);

// elsewhere:
controller.value<String>('email');   // current value, or null
controller.values;                   // Map<String, Object?> snapshot
controller.errorOf('email');         // current ValidationResult?, or null
controller.isValid;                  // true if no field currently has an error
await controller.submit();           // validates every field, then calls onSubmit if valid
controller.reset();                  // clears every field back to its initial value
controller.setValue('email', 'a@b.com'); // sets a value AND updates the rendered field
controller.addListener(() { ... });  // rebuild on any value/error change

setValue is for programmatic changes — e.g. prefilling the form once an async fetch resolves, or a "same as shipping" checkbox that copies values into other fields. It updates the rendered widget, not just the tracked value. Under the hood this remounts just that one field with the new value (the same mechanism reset() uses) — cheap and always correct, at the cost of losing that field's focus/cursor position if it happened to be focused at that exact moment. It's scoped per-field: setValue on one field never touches (or interrupts typing in) any other field, since typing itself goes through a separate internal path that doesn't trigger a remount.

BoFFormController is the "ref" for the form's data (values, errors, submit). If you also need the form's widget location — e.g. to scroll to it when validation fails — pass a GlobalKey too and read key.currentContext once the form has mounted:

final formKey = GlobalKey();
final controller = BoFFormController();

BoF.form(
  fields,
  key: formKey,
  controller: controller,
  onSubmit: save,
);

// e.g. from a submit button outside the form:
BoF.button('Submit', onPressed: () async {
  await controller.submit();
  if (!controller.isValid) {
    Scrollable.ensureVisible(formKey.currentContext!);
  }
});

Validation

Validators are shadcn_flutter's Validator<T> — composable Zod-style with & (AND), | (OR) and ~/unary - (NOT):

validator: const NotEmptyValidator() & const LengthValidator(min: 8, max: 64)
validator: const EmailValidator() | const URLValidator()

Built-in validators include NonNullValidator<T>, NotEmptyValidator, LengthValidator, SafePasswordValidator, MinValidator<T>, MaxValidator<T>, RangeValidator<T>, RegexValidator, EmailValidator, URLValidator, or write your own by extending Validator<T>.

Validation runs on every change and again on submit; BoF.form won't call onSubmit unless every field currently passes.

A larger example

This one also wires up a GlobalKey alongside the BoFFormController, with a submit button living outside the form that drives both: the controller runs validation/submission, and the key locates the form's BuildContext so it can be scrolled into view if validation fails.

final formKey = GlobalKey();
final controller = BoFFormController();

Column(
  children: [
    BoF.form(
      [
        BoFTextField(name: 'name', label: BoF.text('Full name')),
        BoFRadioGroupField<String>(
          name: 'plan',
          label: BoF.text('Plan'),
          options: const [
            BoFOption(value: 'free', label: Text('Free')),
            BoFOption(value: 'pro', label: Text('Pro')),
          ],
          initialValue: 'free',
        ),
        BoFSelectField<String>(
          name: 'country',
          label: BoF.text('Country'),
          options: const [
            BoFOption(value: 'us', label: Text('United States')),
            BoFOption(value: 'ph', label: Text('Philippines')),
          ],
        ),
        BoFDatePickerField(name: 'startDate', label: BoF.text('Start date')),
        BoFSliderField(name: 'budget', label: BoF.text('Monthly budget'), min: 0, max: 1000),
      ],
      key: formKey,
      controller: controller,
      onSubmit: (values) => print(values),
    ),
    BoF.button('Submit', onPressed: () async {
      await controller.submit();
      if (!controller.isValid) {
        Scrollable.ensureVisible(formKey.currentContext!);
      }
    }),
  ],
)

Every field spec needs name (used as the key in the submitted values map), label, and optionally hint and validator. Options-based fields (BoFRadioGroupField, BoFSelectField, BoFMultiSelectField, BoFMultipleChoiceField, BoFMultipleAnswerField) take a List<BoFOption<T>>, where BoFOption(value: ..., label: Text('...')).

BoF.textField

BoFTextField — value type String — wraps TextField.

BoF.textField(placeholder: Text('Search'), onChanged: (v) => print(v));

BoF.textField(
  placeholder: Text('Email'),
  leadingIcon: const Icon(Icons.email),
  onChanged: (v) => print(v),
);

BoF.textField(
  placeholder: Text('Password'),
  showPasswordToggle: true, // starts obscured, adds a reveal/hide button
  onChanged: (v) => print(v),
);

Params: leadingIcon/trailingIcon (added via shadcn_flutter's InputFeature.leading/InputFeature.trailing), obscureText, showPasswordToggle (+ passwordPeekMode: toggle or hold), features (raw List<InputFeature> escape hatch for anything else — clear button, copy/paste, spinner, hint popup, etc.), keyboardType, textInputAction, textCapitalization, maxLines, maxLength, readOnly, autofocus, focusNode, onSubmitted.

obscureText defaults to matching showPasswordToggle when left unset, so showPasswordToggle: true alone is enough for a normal password field; pass obscureText: false too if you want it to start revealed but stay toggleable.

Controller: TextEditingController.

BoF.textAreaField

BoFTextAreaField — value type String — wraps TextArea.

BoF.textAreaField(onChanged: (v) => print(v));

Params: minHeight, maxHeight. Controller: TextEditingController.

BoF.numberField

BoFNumberField — value type num — wraps TextField with a numeric keyboard.

BoF.numberField(onChanged: (v) => print(v));

shadcn_flutter has no dedicated number-input widget upstream; this parses the typed text to num. Controller: TextEditingController (holds the raw text, not the parsed num).

BoF.checkboxField

BoFCheckboxField — value type CheckboxState — wraps ControlledCheckbox.

BoF.checkboxField(onChanged: (v) => print(v));

Tri-state: checked / unchecked / indeterminate. Controller: CheckboxController.

BoF.switchField

BoFSwitchField — value type bool — wraps ControlledSwitch.

BoF.switchField(onChanged: (v) => print(v));

Controller: SwitchController.

BoF.radioGroupField

BoFRadioGroupField<T> — value type T — wraps ControlledRadioGroup + RadioItem/RadioCard.

BoF.radioGroupField<String>(
  options: const [
    BoFOption(value: 'free', label: Text('Free')),
    BoFOption(value: 'pro', label: Text('Pro')),
  ],
  onChanged: (v) => print(v),
);

Set card: true for card-style items instead of plain radio items. Controller: RadioGroupController<T?>.

BoF.selectField

BoFSelectField<T> — value type T — wraps ControlledSelect. Dropdown, single selection.

BoF.selectField<String>(
  options: const [BoFOption(value: 'us', label: Text('United States'))],
  onChanged: (v) => print(v),
);

Controller: SelectController<T>.

BoF.multiSelectField

BoFMultiSelectField<T> — value type Iterable<T> — wraps ControlledMultiSelect. Dropdown, multiple selection.

BoF.multiSelectField<String>(
  options: const [BoFOption(value: 'us', label: Text('United States'))],
  onChanged: (v) => print(v),
);

Controller: MultiSelectController<T>.

BoF.multipleChoiceField

BoFMultipleChoiceField<T> — value type T — wraps ControlledMultipleChoice. Inline tappable chips, single selection.

BoF.multipleChoiceField<String>(
  options: const [BoFOption(value: 's', label: Text('S'))],
  onChanged: (v) => print(v),
);

Controller: MultipleChoiceController<T>.

BoF.multipleAnswerField

BoFMultipleAnswerField<T> — value type Iterable<T> — wraps ControlledMultipleAnswer. Inline tappable chips, multiple selection.

BoF.multipleAnswerField<String>(
  options: const [BoFOption(value: 's', label: Text('S'))],
  onChanged: (v) => print(v),
);

Controller: MultipleAnswerController<T>.

BoF.datePickerField

BoFDatePickerField — value type DateTime — wraps ControlledDatePicker. Popover/dialog calendar.

BoF.datePickerField(onChanged: (v) => print(v));

Controller: DatePickerController.

BoF.dateInputField

BoFDateInputField — value type DateTime — wraps DateInput. Segmented typed entry (mm/dd/yyyy-style).

BoF.dateInputField(onChanged: (v) => print(v));

Controller: DatePickerController.

BoF.timePickerField

BoFTimePickerField — value type TimeOfDay — wraps ControlledTimePicker. Popover/dialog.

BoF.timePickerField(onChanged: (v) => print(v));

Controller: TimePickerController.

BoF.timeInputField

BoFTimeInputField — value type TimeOfDay — wraps TimeInput. Segmented typed entry.

BoF.timeInputField(onChanged: (v) => print(v));

Controller: ComponentController<TimeOfDay?> (shadcn_flutter has no dedicated time controller class upstream — construct one with ComponentValueController<TimeOfDay?>(...)).

BoF.durationPickerField

BoFDurationPickerField — value type Duration — wraps DurationPicker. Popover/dialog.

BoF.durationPickerField(onChanged: (v) => print(v));

Controller: DurationPickerController. DurationPicker itself has no controller support upstream (it's a plain value-driven widget), so this wraps it in a small internal adapter that syncs to the controller.

BoF.durationInputField

BoFDurationInputField — value type Duration — wraps DurationInput. Segmented typed entry.

BoF.durationInputField(onChanged: (v) => print(v));

Controller: ComponentController<Duration?> (construct one with ComponentValueController<Duration?>(...)).

BoF.colorField

BoFColorField — value type Color — wraps ControlledColorInput.

BoF.colorField(onChanged: (v) => print(v));

Converts to/from shadcn's ColorDerivative internally, so callers only ever deal with plain Color. Controller: ColorInputController — note the controller itself deals in ColorDerivative, not Color (use controller.setColor(color) to update it).

BoF.phoneField

BoFPhoneField — value type PhoneNumber — wraps PhoneInput.

BoF.phoneField(onChanged: (v) => print(v));

The upstream widget has no enabled param, so this can't be disabled. Controller: TextEditingController — it manages the raw number text, not a PhoneNumber, since PhoneInput has no dedicated value controller upstream.

BoF.sliderField

BoFSliderField — value type SliderValue — wraps ControlledSlider.

BoF.sliderField(min: 0, max: 1000, onChanged: (v) => print(v));

Params: min, max, divisions. Controller: SliderController.

BoF.starRatingField

BoFStarRatingField — value type double — wraps ControlledStarRating.

BoF.starRatingField(onChanged: (v) => print(v));

Params: max, step. Controller: StarRatingController.

BoF.otpField

BoFOtpField — value type List<int?> — wraps InputOTP.

BoF.otpField(length: 6, onChanged: (v) => print(v));

Requires length. The upstream widget has no enabled param, so this can't be disabled. It also has no controller of its own, so this field has no controller param — programmatic updates aren't supported.

BoF.autoCompleteField

BoFAutoCompleteField — value type String — wraps AutoComplete + TextField.

BoF.autoCompleteField(
  suggestions: const ['Alice', 'Bob', 'Charlie'],
  onChanged: (v) => print(v),
);

Requires suggestions. Controller: TextEditingController, passed through to the inner TextField (AutoComplete itself has no controller of its own).

BoF.chipInputField

BoFChipInputField<T> — value type List<T> — wraps ChipInput.

BoF.chipInputField<String>(
  chipBuilder: (context, value) => BoF.chip(BoF.text(value)),
  onChipSubmitted: (text) => text,
  onChanged: (v) => print(v),
);

Requires chipBuilder and onChipSubmitted (parses typed text into a chip). Controller: ChipEditingController<T>.