SuperAPI AI Directory

flutter-widget-ui

General & other

View repository

Updated 10-15 20:04

About this skill

Flutter widget UI patterns

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 / dispose Subscription 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, SmallButton
  • SearchInputField, InputField
  • FilterButton, FilterButtons, RatingButton
  • RecipeCard, NewRecipeCard, DishCard, IngredientRecipeCard, RecipeGridItem
  • TwoTab, 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) → Column This is the project's basic skeleton (ingredient_screen.dart reference). Place horizontal padding SafeArea on the inside to achieve both notch avoidance and consistent margins at the same time.
  • AppBar 의 actions 는 PopupMenuButton to insert the menu, and in each PopupMenuItem 은 onTap call 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: '...') or IconButton(tooltip: '...') to be considerate of screen reader users.
  • To prepare for text size changes, instead of a fixed height use Padding + mainAxisSize / Expanded to compose the layout.
  • Touch targets are at least 48×48 logical pixels. Small icon buttons should be wrapped in IconButton or InkWell + sufficient padding.

Performance tips

  • const Actively use constructors.
  • If there is heavy computation in list items, perform it in advance itemBuilder outside (e.g., formatting is already reflected in the UI model).
  • For animations, use AnimatedBuilder / TweenAnimationBuilder / ImplicitlyAnimatedWidget to 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/cacheHeight to limit the decoding size.

Checklist — new Screen widget

  • StatelessWidget declare it with state, onAction and 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 be IndexedStack , tab switching should be
  • reviewcore/presentation/componentsUse common components (
  • ) first; if none, add them ColorStyles, TextStyles Colors/fonts should use
  • BuildContext Effects 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.
  • ❌ setState Manage 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.
  • ❌ const omitting it causes the same widget to be recreated every frame.