Core principles
UI is dumb. The Screen widget receives state 와 onAction and is responsible only for rendering and user interaction. Business logic, data transformation, and network calls all end in the ViewModel/UseCase/Data layer. There should be no logic inside the Screen, which makes testing and design review easier.
Default to StatelessWidget
Almost every screen in this project is StatelessWidget . State is held by the ViewModel (ChangeNotifier) and ListenableBuilder subscribes to it. StatefulWidget is used only in the following cases:
- Local UI state that is meaningful only within the widget lifecycle (
TextEditingController,ScrollController,TabController, animation controllers). initState/disposeSubscription and resource initialization that truly require
All app-wide state is moved to the ViewModel.
const Make the most of
The const before a constructor call allows the same instance to be reused on rebuild. Add SizedBox(height: N) to almost all static nodes, such as static widgets, literal styles, const .
const SizedBox(height: 10),
const ChefProfile(),
const Icon(Icons.share, size: 20),
Have child widgets accept const constructors to open reuse paths.
ViewModel subscription — ListenableBuilder
The standard pattern for subscribing to ViewModel changes at the Root widget is to read ListenableBuilder. builder only inside viewModel.state to reduce the scope of unnecessary rebuilds.
return ListenableBuilder(
listenable: viewModel,
builder: (context, _) {
final state = viewModel.state;
if (state.isLoading) return const Center(child: CircularProgressIndicator());
return IngredientScreen(state: state, onAction: viewModel.onAction);
},
);
Screen 은 ListenableBuilder must not know about it. That responsibility belongs to Root.
Lists — ListView.builder
Even if the number of items is small, for fixed lists use Column instead of ListView.builder to prepare for expansion.
ListView.builder(
itemCount: state.ingredients.length,
itemBuilder: (context, index) {
return Column(
children: [
IngredientItem(ingredient: state.ingredients[index]),
const SizedBox(height: 10),
],
);
},
)
For large lists or when keys are needed, put itemBuilder into the widget returned by Key(item.id.toString()) so that state does not get mixed up on reordering/deletion.
Switching between tabsIf you want to preserve state in IndexedStack is cleanest:
IndexedStack(
index: state.selectedTabIndex,
children: [IngredientList(state: state), ProcedureList(state: state)],
)
Reusing shared components
In this project, design-system-grade widgets are gathered in lib/core/presentation/components/ . When a new screen needs a similar button/card/input, search this directory first. 예:
BigButton,MediumButton,SmallButtonSearchInputField,InputFieldFilterButton,FilterButtons,RatingButtonRecipeCard,NewRecipeCard,DishCard,IngredientRecipeCard,RecipeGridItemTwoTab,ChefProfile,IngredientItem,ProcedureItem
If not, add a new component in the same directory. Only UI that will be used more than once is promoted to shared, and if it is dedicated to one screen, put it as a helper widget in that feature folder.
Design tokens are in lib/ui/color_styles.dart, lib/ui/text_styles.dart , so reuse tokens instead of using raw colors/fonts.
Text('1 serve', style: TextStyles.smallerTextRegular.copyWith(color: ColorStyles.gray3)),
Dialogs and snackbars
Dialogs/snackbars need BuildContext , so show them from the Root widget's interaction callback. The ViewModel only needs to know the Action that "a certain menu was selected".
// ingredient_root.dart
IngredientScreen(
state: viewModel.state,
onAction: viewModel.onAction,
onTapMenu: (menu) {
switch (menu) {
case IngredientMenu.share:
showDialog(
context: context,
builder: (_) => ShareDialog(
link: 'app.Recipe.co/jollof_rice',
onTapCopyLink: (link) {
viewModel.onAction(IngredientAction.onTapShareMenu(link));
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Link Copied', textAlign: TextAlign.center)),
);
},
),
);
// ...
}
},
),
Tip: If you need to close the dialog and reflect state at the same time, inside the callback do viewModel.onAction(...) → Navigator.pop(context) → show follow-up UI in that order. viewModel.onAction(...) → Navigator.pop(context) → in the order they are shown in the subsequent UI.
Scaffold composition conventions
Scaffold→AppBar→SafeArea→Padding(horizontal: 30)→ColumnThis is the project's basic skeleton (ingredient_screen.dartreference). Place horizontal paddingSafeAreaon the inside to achieve both notch avoidance and consistent margins at the same time.AppBar의actions는PopupMenuButtonto insert the menu, and in eachPopupMenuItem은onTapcall the callback the Screen received (onTapMenu).
Text field
TextField / TextFormField values are local controller + ViewModel Action dualized. Dispatch an Action on every user input to record it in state. If it is purely local and does not need persistence, StatefulWidget + TextEditingController alone is sufficient.
TextField(
onChanged: (value) => onAction(SearchAction.onQueryChange(value)),
)
Accessibility
- For meaningful images/icons, add
Semantics(label: '...')orIconButton(tooltip: '...')to be considerate of screen reader users. - To prepare for text size changes, instead of a fixed
heightusePadding+mainAxisSize/Expandedto compose the layout. - Touch targets are at least 48×48 logical pixels. Small icon buttons should be wrapped in
IconButtonorInkWell+ sufficient padding.
Performance tips
constActively use constructors.- If there is heavy computation in list items, perform it in advance
itemBuilderoutside (e.g., formatting is already reflected in the UI model). - For animations, use
AnimatedBuilder/TweenAnimationBuilder/ImplicitlyAnimatedWidgetto narrow the rebuild scope. Separate the animation area into a small widget so the parent Scaffold is not rebuilt every frame. - For large images, use
cacheWidth/cacheHeightto limit the decoding size.
Checklist — new Screen widget
-
StatelessWidgetdeclare it withstate,onActionand receive only - as a parameter
- If there are navigation/dialog callbacks passed from Root, receive them as additional parameters
const - All static children should be
ListView.builderlists should beIndexedStack, tab switching should be - review
core/presentation/componentsUse common components ( - ) first; if none, add them
ColorStyles,TextStylesColors/fonts should use -
BuildContextEffects that require (dialogs, snackbars, navigation) are handled in Root
Anti-patterns
- ❌ Inside a Screen,
getIt<...>()call → Root must inject it. - ❌ Inside a Screen,
context.push(...)call directly → delegate via callback. - ❌
setStateManage app state with → move it up to the ViewModel. - ❌ Use magic number colors/fonts directly → use
ColorStyles/TextStyles. - ❌
Column+ manual scrolling — if items may increase,ListView.builder. - ❌
constomitting it causes the same widget to be recreated every frame.