Contributing to Forui
July 9, 2026 · View on GitHub
This document outlines how to contribute code to Forui. It assumes that you're familiar with Flutter, writing golden tests, MDX, and Trunk-Based Development.
There are many ways to contribute beyond writing code. For example, you can report bugs, provide feedback, and enhance existing documentation.
In general, contributing code involves the adding/updating of:
- Widgets.
- Relevant unit/golden tests.
- Relevant examples under the docs_snippets project.
- Relevant documentation under the docs project.
- CHANGELOG.md.
Before You Contribute
Before starting work on a PR, please check if a similar issue/ PR exists.
Unsolicited PRs that introduce significant changes, e.g. a new widget, without a prior issue may be closed without review. Trivial fixes such as typos, broken links, and documentation improvements are always welcome and don't require an issue.
If an issue doesn't exist, create one to discuss the proposed changes. After which, please comment on the issue to indicate that you're working on it.
This helps to:
- Avoid duplicate efforts by informing other contributors about ongoing work.
- Ensure that the proposed changes align with the project's goals and direction.
- Provide a platform for maintainers and the community to offer feedback and suggestions.
First time contributors
We recommend that first time contributors start with
existing issues that are labeled with difficulty: easy and/or duration: tiny.
It is not recommended for first time contributors to implement new widgets or other large features. The hard part is often not the implementation but rather than API design. Naming, control semantics, which configuration knobs exist, and the style surface all become long-term commitments.
If you're stuck or unsure about anything, feel free to ask for help in our discord.
Configuring the Development Environment
Our git history is pretty large due to golden images. We recommend performing a shallow clone:
git clone --depth 1 https://github.com/forus-labs/forui.git
Bootstrap the project:
make bootstrap
make bs # shorthand
This installs dependencies and generates the required files.
Run make help to see all available commands.
Guidelines
Prefix publicly exported widgets and styles with F
For example, FScaffold instead of Scaffold.
Avoid double negatives
Avoid double negatives when naming things, i.e. a boolean field should
be named enabled instead of disabled.
Avoid past tense when naming callbacks
Prefer present tense instead.
✅ Prefer this:
final VoidCallback onPress;
❌ Instead of:
final VoidCallback onPressed;
Be agnostic about state management
There is a wide variety of competing state management packages. Picking one may discourage users of the other packages
from adopting Forui. Use InheritedWidget instead.
Be conservative when exposing configuration knobs
Additional knobs can always be introduced later if there's sufficient demand. Changing these knobs is time-consuming and constitute a breaking change.
✅ Prefer this:
class Foo extends StatelessWidget {
final int _someKnobWeDontKnowIfUsefulToUsers = 42;
const Foo() {}
@override
void build(BuildContext context) {
return Placeholder();
}
}
❌ Instead of:
class Foo extends StatelessWidget {
final int someKnobWeDontKnowIfUsefulToUsers = 42;
const Foo(this.someKnobWeDontKnowIfUsefulToUsers) {}
@override
void build(BuildContext context) {
return Placeholder();
}
}
Initialize lifecycle objects in initState()
Do not use late final field initializers for AnimationController, CurvedAnimation, FocusNode, or similar
lifecycle objects in State classes. Declare them as late and initialize them explicitly in initState().
Late field initialization may only occur in the dispose() method and try to access unmounted State objects, causing
runtime errors.
class _State extends State<Foo> with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(vsync: this);
@override
Widget build(BuildContext context) {
if (someCondition) {
return Foo(controller: _controller);
} else {
return Bar();
}
}
@override
void dispose() {
// _controller's late final initializer only runs on first access. If someCondition is always false, _controller
// is first accessed here in dispose(), initializing AnimationController(vsync: this) with an already unmounted State.
_controller.dispose();
super.dispose();
}
}
Late final fields may be accidentally re-assigned in didChangeDependencies() or didUpdateWidget(). They are not checked
at compile-time, hence causing runtime errors.
class _State extends State<Foo> with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Compiles fine, but throws a runtime error on the second call since _controller is already assigned.
_controller = AnimationController(vsync: this);
}
}
Extend FChangeNotifier instead of ChangeNotifier
This subclass have additional life-cycle tracking capabilities baked-in.
Minimize dependency on Cupertino/Material
Cupertino and Material specific widgets should be avoided when possible.
Minimize dependency on 3rd party packages
3rd party packages introduce uncertainty. It is difficult to predict whether a package will be maintained in the future. Furthermore, if a new major version of a 3rd party package is released, applications that depend on both Forui and the 3rd party package may be forced into dependency hell.
In some situations, it is unrealistic to implement things ourselves. In these cases, we should prefer packages that:
- Are authored by Forus Labs.
- Are maintained by a group/community rather than an individual.
- Have a "pulse", i.e. maintainers responding to issues in the past month at the time of introduction.
Lastly, types from 3rd party packages should not be publicly exported by Forui.
Prefer AlignmentGeometry/BorderRadiusGeometry/EdgeInsetsGeometry over Alignment/BorderRadius/EdgeInsets
Prefer the Geometry variants when possible because they are more flexible.
Hide, not Show
Prefer hiding internal members (blacklisting) rather than showing public members (whitelisting) in barrel files.
✅ Prefer this:
library forui.widgets.calendar;
export '../src/widgets/calendar/calendar.dart';
export '../src/widgets/calendar/day/day_picker.dart' hide DayPicker;
export '../src/widgets/calendar/shared/entry.dart' hide Entry;
export '../src/widgets/calendar/shared/header.dart' hide Header, Navigation;
export '../src/widgets/calendar/calendar_controller.dart';
❌ Instead of:
export '../src/widgets/calendar/calendar.dart';
export '../src/widgets/calendar/day/day_picker.dart' show FCalendarDayPickerStyle;
export '../src/widgets/calendar/shared/entry.dart' show FCalendarDayData, FCalendarEntryStyle;
export '../src/widgets/calendar/shared/header.dart' show FCalendarHeaderStyle, FCalendarPickerType;
export '../src/widgets/calendar/calendar_controller.dart';
This prevents accidental omission of generated public members.
Changelog organization
Entries should be grouped by widgets in alphabetical order. Each widget section should be a level 3 heading with the widget name in backticks.
Within each widget section, order entries as follows:
- Additions (start with "Add")
- Changes (start with "Change", "Rename", or similar)
- Removals (start with "Remove")
- Fixes (start with "Fix")
Separate each category with a blank line. Breaking changes must start with **Breaking**.
Example:
### `FSelect`
* Add `FSelect.search(...)`.
* **Breaking** Rename `FSelectStyle.selectFieldStyle` to `FSelectStyle.fieldStyle`.
* **Breaking** Remove `FSelectStyle.iconStyle`. Use `FSelectStyle.fieldStyle.iconStyle` instead.
* Fix `FSelect` still allowing tags to be removed when disabled.
Use ### Others for changes that don't belong to a specific widget.
Data Driven Fixes Organization
Where possible, provide data driven fixes.
Fixes are located in the <package>/lib/fix_data folder. Each public widget should have one file containing fixes for
its related classes (e.g., FButton, FButtonStyle, FButtonController). Fixes inside each file should be grouped by class
in alphabetical order.
# Example: button.yaml - All FButton-related fixes in one file
version: 1
transforms:
# FButton
- title: 'Rename FButton(onStateChange: ...) to FButton(onVariantChange: ...)'
date: 2026-01-26
element:
uris: [ 'package:forui/forui.dart' ]
constructor: ''
inClass: FButton
changes:
- kind: renameParameter
oldName: 'onStateChange'
newName: 'onVariantChange'
- title: 'Rename FButton.icon(onStateChange: ...) to FButton.icon(onVariantChange: ...)'
date: 2026-01-26
element:
uris: [ 'package:forui/forui.dart' ]
constructor: 'icon'
inClass: FButton
changes:
- kind: renameParameter
oldName: 'onStateChange'
newName: 'onVariantChange'
# FButtonController
- title: 'Rename FButtonController.state to FButtonController.variant'
date: 2026-01-26
element:
uris: [ 'package:forui/forui.dart' ]
getter: 'state'
inClass: FButtonController
changes:
- kind: rename
newName: 'variant'
# FButtonStyle
- title: 'Remove FButtonStyle(iconStyle: ...)'
date: 2026-01-26
element:
uris: [ 'package:forui/forui.dart' ]
constructor: ''
inClass: FButtonStyle
changes:
- kind: removeParameter
name: 'iconStyle'
Code Generation
Widget Controls
Controls define how a widget is controlled. They follow a pattern using sealed classes with two variants:
- Managed: The widget manages its own controller internally while exposing parameters for common configurations.
- Lifted: The widget uses external state management, with the parent providing expanded/collapsed state and callbacks.
The control pattern is code-generated using forui_internal_gen. Files are organized as follows:
lib/src/widgets/my_widget/
├── my_widget_controller.dart (Controller + Control definition)
├── my_widget_controller.control.dart (GENERATED)
├── my_widget.dart (Widget + Style + Motion)
└── my_widget.design.dart (GENERATED)
Proxy Controllers
Flutter is a controller-centric framework. Therefore, widgets that support lifted state require a proxy controller that delegates operations to external callbacks given by the user instead of managing state internally.
For example, when a user expands an accordion item using FAccordionControl.lifted(...), the proxy controller:
- Receives the expansion request via the controller's public API (e.g.,
expand(index)) - Delegates to the parent's
onChangecallback instead of updating internal state - Reads current state from the parent's
expandedpredicate
@internal
class ProxyMyWidgetController extends FMyWidgetController {
bool Function(int index) _supply;
void Function(int index, bool value) _onChange;
ProxyMyWidgetController(this._supply, this._onChange);
void update(bool Function(int index) supply, void Function(int index, bool value) onChange) {
_supply = supply;
_onChange = onChange;
}
@override
Future<bool> toggle(int index) async {
_onChange(index, !_supply(index));
return true;
}
}
This allows the widget to use a consistent controller-based internal API regardless of whether state is managed internally or lifted to a parent widget.
Control Sealed Class
The control sealed class should:
- Mix in
Diagnosticableand_$FMyWidgetControlMixin(generated). - Define
managedandliftedfactory constructors. - Define an
_updatemethod signature that returns(FMyWidgetController, bool).
sealed class FMyWidgetControl with Diagnosticable, _$FMyWidgetControlMixin {
const factory FMyWidgetControl.managed({
FMyWidgetController? controller,
// ... other managed parameters
}) = FMyWidgetManagedControl;
const factory FMyWidgetControl.lifted({
// ... lifted state parameters
}) = _Lifted;
const FMyWidgetControl._();
(FMyWidgetController, bool) _update(
FMyWidgetControl old,
FMyWidgetController controller,
VoidCallback callback,
// ... other parameters passed to createController
);
}
Control Subclasses
The managed control should be a public class that mixes in the generated _$FMyWidgetManagedControlMixin:
class FMyWidgetManagedControl extends FMyWidgetControl with _$FMyWidgetManagedControlMixin {
@override
final FMyWidgetController? controller;
// ... other @override fields
const FMyWidgetManagedControl({this.controller, ...}) : super._();
@override
FMyWidgetController createController(/* parameters */) =>
controller ?? FMyWidgetController(/* ... */);
}
The lifted control should be a private class that mixes in the generated _$_LiftedMixin:
class _Lifted extends FMyWidgetControl with _$_LiftedMixin {
@override
final /* lifted state fields */;
const _Lifted({required /* ... */}) : super._();
@override
FMyWidgetController createController(/* parameters */) => /* ... */;
@override
void _updateController(FMyWidgetController controller, /* parameters */) { /* ... */ }
}
Using Control in Widget
Use the generated extension methods in your widget's State:
class _FMyWidgetState extends State<FMyWidget> {
late FMyWidgetController _controller;
@override
void initState() {
super.initState();
_controller = widget.control.create(_handleChange, /* ... */);
}
@override
void didUpdateWidget(covariant FMyWidget old) {
super.didUpdateWidget(old);
final (controller, _) = widget.control.update(old.control, _controller, _handleChange, /* ... */);
_controller = controller;
}
@override
void dispose() {
widget.control.dispose(_controller, _handleChange);
super.dispose();
}
}
See FAccordionController for a reference implementation.
Widget Styles
@Variants(FWidget, {'hovered': 'The hovered state', 'pressed': 'The pressed state'}) // --- (1)
@SentinelValues(FWidgetStyle, {'someDouble': 'double.infinity', 'color': 'Sentinels.color'}) // --- (2)
part 'widget.design.dart'; // --- (3)
class FWidget { /* ... */ }
class FWidgetStyle with Diagnosticable, _$FWidgetStyleFunctions { // --- (4) (5)
final double someDouble;
final Color color;
FWidgetStyle({required this.someDouble, required this.color});
FWidgetStyle.inherit({FTypoegraphy typography, FColors colors}):
someDouble = 16,
color = colors.primary; // --- (6)
}
They should:
@Variants- Declares widget-specific variants (states). Maps variant names to documentation strings. GeneratesFWidgetVariantandFWidgetVariantConstraintextension types.@SentinelValues- Specifies sentinel values for delta merges. Maps field names to their sentinel values (e.g.,'double.infinity','Sentinels.color'). Used to distinguish "no change" from actual values in generated delta classes.- Include a generated part file (
*.design.dart) containing_$FWidgetStyleFunctions, delta classes, and variant types. - Mix-in Diagnosticable.
- Mix-in
_$FWidgetStyleFunctions(generated utility functions). - Provide constructors including
inherit(...)for theme-based defaults.
To generate files, run dart run build_runner build in the forui/forui directory.
Platform variants (touch, desktop, android, iOS, etc.) are automatically included in generated variant types.
Leak Tracking
To detect memory leaks, leak_tracker_flutter_testing
is enabled by default in all tests.
Leak tracking results currently are not shown when running tests via an IntelliJ run configuration. As a workaround, run the tests via the terminal.
It is recommended to wrap disposable objects created in tests with autoDispose(...), i.e. final focus = autoDispose(FocusNode()).
Writing Golden Tests
Golden images are generated in the test/golden directory instead of relative to the test file.
Golden tests should follow these guidelines:
- Golden test files should be suffixed with
golden_test, i.e.button_golden_test.dart. - Widgets under test should be tested against all themes specified in
TestScaffold.themes. - Widgets under test should be wrapped in a
TestScaffold.
The CI pipeline will automatically generate golden images for all golden tests on Windows & macOS. Contributors on Linux should not* commit locally generated golden images.
Blue Screen Tests
Blue screen tests are a special type of golden tests. All widgets should have a blue screen test. It uses a special theme that is all blue. This allows us to verify that custom/inherited themes are being applied correctly. The resultant image should be completely blue if applied correctly, hence the name.
Example
testWidgets('blue screen', (tester) async {
await tester.pumpWidget(
TestScaffold.blue( // (1) Always use the TestScaffold.blue(...) constructor.
child: FSelectGroup(
style: TestScaffold.blueScreen.selectGroupStyle, // (2) Always use the TestScaffold.blueScreen theme.
children: [
FSelectGroupItem.checkbox(value: 1),
],
),
),
);
// (3) Always use expectBlueScreen.
await expectBlueScreen(find.byType(TestScaffold));
});
Writing Documentation
In addition to the API reference, you should also update forui.dev/docs if necessary.
forui.dev is split into two parts:
- The doc snippets website, which is a Flutter webapp that provides the example widgets & other code blocks.
- The documentation website, which provides overviews and examples of widgets from the docs snippets website embedded in MDX files.
A widget's documentation page typically consists of the following sections:
- Introduction: A brief description of the widget along with an interactive code block of basic usage.
- CLI: The command to generate and customize the widget's style.
- Usage: Constructor/Function signatures with selectable categories (e.g., core, control, accessibility).
- Examples: Various use cases organized by appearance, content, and behavior.
See the docs snippets README for detailed information on the three snippet types (usages, examples, and snippets) and their syntax.
Updating Localizations
In most cases, you will not need to update localizations. However, if you do, please read Internationalizing Flutter apps. before continuing.
Each ARB file in the lib/l10n represents a localization for a specific language. We try to maintain parity with the
languages Flutter natively supports. To add a missing language, run the fetch_arb script in the tool directory.
After adding the necessary localization messages, run the following command in the forui project directory which will
generate the localization files in lib/src/localizations:
flutter gen-l10n
Inside the generated localizations.dart file, change:
static FLocalizations of(BuildContext context) {
return Localizations.of<FLocalizations>(context, FLocalizations);
}
To:
static FLocalizations of(BuildContext context) {
return Localizations.of<FLocalizations>(context, FLocalizations) ?? DefaultLocalizations();
}