SuperAPI AI Directory

flutter-presentation-mvi

Office & productivity

View repository

Updated 10-15 22:32

About this skill

Flutter Presentation layer (MVI style)

Components

Every screen consists of the following 4 parts, and files are 2only creates:

  1. State — freezed immutable class. All fields needed by the UI.
  2. Action — freezed sealed class. All actions the user can trigger.
  3. ViewModel — ChangeNotifier mixin. Holds State and handles Action.
  4. Root / Screen widget separation — Root handles DI/events/rebuild subscription, Screen handles pure UI.
File Contents
<feature>_view_model.dart Top of file: State → Action, bottom of file: ViewModel
<feature>_screen.dart Top of file: Root, bottom of file: Screen

Optional element: Event (one-time side effect) — one-time signals such as snackbars/navigation that must not be put into state. StreamController emit through.


State

freezed class. Default value is specified as @Default .

// lib/presentation/ingredient/ingredient_view_model.dart (파일 상단)
import 'package:flutter_recipe_app_course/domain/model/ingredient.dart';
import 'package:flutter_recipe_app_course/domain/model/procedure.dart';
import 'package:flutter_recipe_app_course/domain/model/recipe.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

part 'ingredient_view_model.freezed.dart';
part 'ingredient_view_model.g.dart';

@freezed
class IngredientState with _$IngredientState {
  const factory IngredientState({
    Recipe? recipe,
    @Default([]) List<Ingredient> ingredients,
    @Default([]) List<Procedure> procedures,
    @Default(0) int selectedTabIndex,
  }) = _IngredientState;

  factory IngredientState.fromJson(Map<String, Object?> json) =>
      _$IngredientStateFromJson(json);
}

Rules:

  • All UI state exists only in State. Do not scatter UI-related variables as ViewModel instance fields.
  • State updates always create a new instance with copyWith(...) and reassign to _state , then declare with notifyListeners().

Action

freezed + sealed so that switch enforces all cases.

// lib/presentation/ingredient/ingredient_view_model.dart (State 바로 아래)
import 'package:flutter_recipe_app_course/domain/model/recipe.dart';

@freezed
sealed class IngredientAction with _$IngredientAction {
  const factory IngredientAction.onTapFavorite(Recipe recipe) = OnTapFavorite;
  const factory IngredientAction.onTapIngredient() = OnTapIngredient;
  const factory IngredientAction.onTapProcedure() = OnTapProcedure;
  const factory IngredientAction.loadRecipe(int recipeId) = LoadRecipe;
  const factory IngredientAction.onTapShareMenu(String link) = OnTapShareMenu;
}

Naming: on<Trigger> or verb form (load...). Each factory also defines the class type (OnTapFavorite) together, and uses it in switch pattern matching.


ViewModel

// lib/presentation/ingredient/ingredient_view_model.dart (Action 바로 아래)
class IngredientViewModel with ChangeNotifier {
  final IngredientRepository _ingredientRepository;
  final ProcedureRepository _procedureRepository;
  final GetDishesByCategoryUseCase _getDishesByCategoryUseCase;
  final ClipboardService _clipboardService;

  IngredientState _state = const IngredientState();
  IngredientState get state => _state;

  IngredientViewModel({
    required IngredientRepository ingredientRepository,
    required ProcedureRepository procedureRepository,
    required GetDishesByCategoryUseCase getDishesByCategoryUseCase,
    required ClipboardService clipboardService,
  })  : _ingredientRepository = ingredientRepository,
        _procedureRepository = procedureRepository,
        _getDishesByCategoryUseCase = getDishesByCategoryUseCase,
        _clipboardService = clipboardService;

  void onAction(IngredientAction action) async {
    switch (action) {
      case LoadRecipe():
        _loadRecipe(action.recipeId);
      case OnTapIngredient():
        _state = state.copyWith(selectedTabIndex: 0);
        notifyListeners();
      case OnTapProcedure():
        _state = state.copyWith(selectedTabIndex: 1);
        notifyListeners();
      case OnTapShareMenu():
        _clipboardService.copyText(action.link);
      // ... 나머지 케이스
    }
  }
}

Rules:

  • with ChangeNotifier use the mixin. It is Flutter's standard and ListenableBuilder combines naturally with it.
  • Single public method onAction(Action action) dispatches with switch . Do not increase individual public methods — Screen becomes less aware of the ViewModel's type.
  • _state = state.copyWith(...); notifyListeners(); keep the order of. Multiple changes within the same frame are merged into a single copyWith .
  • Receive dependencies only through constructor injection. Do not call getIt inside the ViewModel.
  • StreamSubscription If there is @override void dispose() , in cancel() must

Stream subscription pattern

UseCase가 Stream If returns, subscribe in the ViewModel constructor or action handler, and dispose cancel in.

class SavedRecipesViewModel with ChangeNotifier {
  StreamSubscription? _streamSubscription;

  SavedRecipesViewModel({required GetSavedRecipesUseCase getSavedRecipesUseCase})
      : _getSavedRecipesUseCase = getSavedRecipesUseCase {
    _streamSubscription = _getSavedRecipesUseCase.execute().listen((recipes) {
      _state = state.copyWith(recipes: recipes);
      notifyListeners();
    });
  }

  @override
  void dispose() {
    _streamSubscription?.cancel();
    super.dispose();
  }
}

One-time events (snackbar, navigation)

If expressed as state, it can be re-executed on every rebuild. StreamController let it flow only once through.

class HomeViewModel with ChangeNotifier {
  final _eventController = StreamController<NetworkError>();
  Stream<NetworkError> get eventStream => _eventController.stream;

  // 실패 시
  _eventController.add(result.error);
}

In the Root widget, instead of StreamBuilder , subscribe with initState 의 listen and show the Snackbar (because it is a side effect outside the Widget tree).


Root / Screen separation

<feature>_screen.dart Two classes coexist in one file:

  • File top: Root — DI + event subscription + ListenableBuilder
  • File Bottom: Screen — pure UI. state 와 onAction received only as props

Root widget

// lib/presentation/ingredient/ingredient_screen.dart (파일 상단)
class IngredientRoot extends StatelessWidget {
  final int recipeId;
  const IngredientRoot({super.key, required this.recipeId});

  @override
  Widget build(BuildContext context) {
    final viewModel = getIt<IngredientViewModel>();
    viewModel.onAction(IngredientAction.loadRecipe(recipeId));

    return ListenableBuilder(
      listenable: viewModel,
      builder: (context, _) {
        if (viewModel.state.recipe == null) {
          return const Center(child: CircularProgressIndicator());
        }
        return IngredientScreen(
          state: viewModel.state,
          onAction: viewModel.onAction,
          onTapMenu: (menu) { /* 다이얼로그·네비게이션 */ },
        );
      },
    );
  }
}
  • getIt<>() calls happen only here.
  • ListenableBuilder(listenable: viewModel, ...) 로 notifyListeners() subscribes to changes.
  • For initial data loading, build inside onAction(... load) calling is the existing convention of this project. However, to prevent reloading on every rebuild, either prevent duplicate requests inside the ViewModel (ignore if already loaded), or raise Root to StatefulWidget and initState calling from is also fine. If there is complex initialization, StatefulWidget promoting to is safer.

Screen widget

// lib/presentation/ingredient/ingredient_screen.dart (파일 하단)
class IngredientScreen extends StatelessWidget {
  final IngredientState state;
  final void Function(IngredientAction action) onAction;
  final void Function(IngredientMenu menu) onTapMenu;

  const IngredientScreen({
    super.key,
    required this.state,
    required this.onAction,
    required this.onTapMenu,
  });

  @override
  Widget build(BuildContext context) { /* 순수 UI */ }
}
  • ViewModel has no reference to. Tests and widget previews are possible independently.
  • Interactions that require navigation or dialogs should be separated into additional callbacks like onTapMenu and handled by Root — Screen should not context.go directly make go_router calls like.

When using callbacks instead of Actions

In Screen, interactions that require context like navigation/dialogs should have a separate onTap* callback and be handled in Root. There are two reasons:

  1. ViewModel 은 BuildContext does not know (testability).
  2. Navigation is not a "state change" but an "effect", so mixing it into Actions makes the model messy.

예: saved_recipes_root.dart 는 onAction Inside the callback, OnTapRecipe intercept context.push(...) and navigate to, passing the remaining Actions to the ViewModel.

onAction: (action) {
  if (action is OnTapRecipe) {
    context.push('/Home/Ingredient/${action.recipe.id}');
    return;
  }
  viewModel.onAction(action);
},

UI model (when needed)

If display formatting ("3일 전", "20 min") is entangled with the domain model, create a Presentation-only UI model. By default, use the domain model as is, and only when formatting becomes complex separate into a UI model.


Checklist — adding a new screen

  • lib/presentation/<feature>/<feature>_view_model.dart — top: freezed State → sealed Action, bottom: with ChangeNotifier ViewModel, onAction single entry
  • lib/presentation/<feature>/<feature>_screen.dart — top: Root(getIt, ListenableBuilder, event subscription), bottom: Screen (pure UI receiving only state + onAction)
  • diSetup() 에 ViewModel을 registerFactory register as
  • If needed, StreamController expose one-time events as
  • build_runner build generate freezed files with

Anti-patterns

  • ❌ Screen widget calls getIt<>() → lift it up to the Root widget.
  • ❌ ViewModel 이 BuildContext 나 GoRouter references → navigation should be a Root callback.
  • ❌ Having multiple public methods (loadRecipe(), onTapBack(), onSelectTab()) and calling them from Screen → onAction(Action) narrow down to one.
  • ❌ After State change, notifyListeners() missing → rebuild does not happen.
  • ❌ StreamSubscription 을 dispose not canceled in → memory leak and ghost callback.
  • ❌ Putting snackbar messages in state and showing them again on every rebuild → StreamController emit as events.