Loading...
Loading...
Flutter 프로젝트의 Presentation 레이어 패턴 — `ChangeNotifier` 기반 ViewModel, freezed `State`와 sealed `Action`, Root/Screen 위젯 분리, `ListenableBuilder`로 관찰, 1회성 이벤트는 `StreamController`로 전달. "ViewModel 만들기", "State/Action", "MVI", "ChangeNotifier", "Root와 Screen 분리", "onAction", "notifyListeners", "ListenableBuilder" 같은 표현에 트리거합니다.
npx skill4agent add junsuk5/survival-flutter-skills flutter-presentation-mviChangeNotifier| 파일 | 포함 내용 |
|---|---|
| 파일 상단: State → Action, 파일 하단: ViewModel |
| 파일 상단: Root, 파일 하단: Screen |
StreamController@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';
class IngredientState with _$IngredientState {
const factory IngredientState({
Recipe? recipe,
([]) List<Ingredient> ingredients,
([]) List<Procedure> procedures,
(0) int selectedTabIndex,
}) = _IngredientState;
factory IngredientState.fromJson(Map<String, Object?> json) =>
_$IngredientStateFromJson(json);
}copyWith(...)_statenotifyListeners()freezedsealedswitch// lib/presentation/ingredient/ingredient_view_model.dart (State 바로 아래)
import 'package:flutter_recipe_app_course/domain/model/recipe.dart';
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;
}on<Trigger>load...OnTapFavoriteswitch// 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);
// ... 나머지 케이스
}
}
}with ChangeNotifierListenableBuilderonAction(Action action)switch_state = state.copyWith(...); notifyListeners();copyWithgetItStreamSubscription@override void dispose()cancel()Streamdisposeclass SavedRecipesViewModel with ChangeNotifier {
StreamSubscription? _streamSubscription;
SavedRecipesViewModel({required GetSavedRecipesUseCase getSavedRecipesUseCase})
: _getSavedRecipesUseCase = getSavedRecipesUseCase {
_streamSubscription = _getSavedRecipesUseCase.execute().listen((recipes) {
_state = state.copyWith(recipes: recipes);
notifyListeners();
});
}
void dispose() {
_streamSubscription?.cancel();
super.dispose();
}
}StreamControllerclass HomeViewModel with ChangeNotifier {
final _eventController = StreamController<NetworkError>();
Stream<NetworkError> get eventStream => _eventController.stream;
// 실패 시
_eventController.add(result.error);
}StreamBuilderinitStatelisten<feature>_screen.dartRootListenableBuilderScreenstateonAction// lib/presentation/ingredient/ingredient_screen.dart (파일 상단)
class IngredientRoot extends StatelessWidget {
final int recipeId;
const IngredientRoot({super.key, required this.recipeId});
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<>()ListenableBuilder(listenable: viewModel, ...)notifyListeners()buildonAction(... load)StatefulWidgetinitStateStatefulWidget// 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,
});
Widget build(BuildContext context) { /* 순수 UI */ }
}ViewModelonTapMenucontext.goonTap*BuildContextsaved_recipes_root.dartonActionOnTapRecipecontext.push(...)onAction: (action) {
if (action is OnTapRecipe) {
context.push('/Home/Ingredient/${action.recipe.id}');
return;
}
viewModel.onAction(action);
},"3일 전""20 min"lib/presentation/<feature>/<feature>_view_model.dartwith ChangeNotifieronActionlib/presentation/<feature>/<feature>_screen.dartgetItListenableBuilderdiSetup()registerFactoryStreamControllerbuild_runner buildgetIt<>()BuildContextGoRouterloadRecipe()onTapBack()onSelectTab()onAction(Action)notifyListeners()StreamSubscriptiondisposeStreamController