feature-presentation
Clean Architecture 프레젠테이션 레이어 생성 (BLoC, Page, Widget, 테스트, Widgetbook)
| 항목 | 내용 |
|---|---|
| 실행 명령 | /cc-flutter:feature:presentation |
| 분류 | 워크플로우 |
| 난이도 | ●●○ 보통 |
| MCP 서버 | serena, context7 |
한마디로#
화면을 만들 때 필요한 부품들(화면 틀, 버튼·카드 같은 위젯, 데이터를 화면에 연결하는 로직, 테스트, 미리보기 카탈로그)을 한 번에 자동으로 찍어내는 도구입니다. 가구 조립처럼, 화면을 만들 때마다 매번 손으로 깎던 부품을 정해진 규격대로 한 세트로 뽑아준다고 보면 됩니다.
누가·언제 쓰나요#
- 새 기능의 화면(UI) 부분을 새로 만들어야 할 때
- 목록·상세·작성 같은 페이지와, 화면 동작 로직(BLoC), 화면 이동 경로를 한꺼번에 만들고 싶을 때
- 전체 기능을 자동으로 만드는
/cc-flutter:feature:create작업의 5번째 단계로 자동 호출될 때
무엇을 해주나요#
명령 한 번으로 화면 관련 파일들이 정해진 폴더에 자동 생성됩니다. 실제로 만들어지는 것들:
-
화면 동작 로직 —
{feature}_list_bloc.dart,{feature}_list_event.dart,{feature}_list_state.dart(데이터를 불러오고 화면 상태를 관리) - 페이지 —
{feature}_page.dart(실제 보이는 화면 틀) - 위젯 —
{entity}_card.dart(목록의 카드처럼 화면을 구성하는 작은 부품) - 화면 이동 경로 —
{feature}_route.dart - 테스트 — 동작 로직·화면이 제대로 작동하는지 검증하는 코드
-
미리보기 카탈로그 —
{entity}_card_use_case.dart(Widgetbook에서 부품을 따로 미리 볼 수 있게) - (선택) BDD 시나리오 — 사용자 관점의 동작 명세 파일
어떻게 쓰나요#
# 기본: 기능 이름과 데이터 이름을 넣어 호출
/cc-flutter:feature:presentation community Post
# UI 역할 지정 (application → app / console → console, 기본값 application)
/cc-flutter:feature:presentation chat Message --location console
# 만들 페이지 목록을 직접 지정
/cc-flutter:feature:presentation community Post --pages "list, detail, create"
community,chat처럼 기능 이름을 먼저 넣습니다 (소문자+밑줄 형식).Post,Message처럼 데이터(엔티티) 이름을 넣습니다 (첫 글자 대문자 형식).--location으로 어디에 만들지,--pages로 어떤 페이지들을 만들지 정할 수 있습니다.
안에서 무슨 일이 벌어지나요#
크게 다음 순서로 화면 부품들을 차례차례 만들어냅니다.
- 기존 방식 살펴보기 — 이미 있는 비슷한 화면 코드를 분석해 같은 스타일로 맞춥니다.
- 화면 동작 로직 만들기 — 데이터를 불러오고(로딩·성공·실패) 화면 상태를 관리하는 BLoC 부분을 생성합니다.
- 로직 테스트 만들기 — 그 동작 로직이 제대로 작동하는지 검증하는 테스트를 함께 만듭니다.
- 페이지와 위젯 만들기 — 실제 보이는 화면 틀과, 카드 같은 작은 화면 부품을 생성합니다.
- 화면 테스트 만들기 — 화면이 의도대로 그려지는지 검증하는 테스트를 만듭니다.
- 화면 이동 경로 만들기 — 이 화면으로 어떻게 이동하는지 경로를 정의합니다.
- 미리보기 카탈로그 등록 — 만든 부품을 따로 미리 볼 수 있게 Widgetbook에 등록합니다.
- (선택) BDD 시나리오 연동 — 사용자 관점의 동작 시나리오를 별도로 생성합니다.
⚙️ 상세 옵션·실행 명세 (개발자 / AI 에이전트용)
Triggers#
- When a new Feature Presentation Layer is needed
- When BLoC, Page, Widget, and Route generation are needed
/cc-flutter:feature:createorchestration Step 5
Context Trigger Pattern#
/cc-flutter:feature:presentation {feature_name} {entity_name} [--options]Parameters#
| Parameter | Required | Description | Example |
|---|---|---|---|
feature_name | ✅ | Feature module name (snake_case) | community, chat |
entity_name | ✅ | Entity name (PascalCase) | Post, Message |
--location | ❌ | UI role | application → app, console → console (default: application); common은 legacy 전용 |
--pages | ❌ | Page list to generate | "list, detail, create" |
Behavioral Flow#
먼저 .cob/feature-layout.json을 읽고, 없으면 cob의 기존 legacy 트리 판정 → 나머지는 bundle을 따른다. marker를 보존하고 생성 배치 계약을 적용한다. 생성 루트는 feature/{feature_name}/{app,console}/lib/src/presentation/, 패키지는 {feature_name}·console_{feature_name}이다. 번역은 각 역할의 lib/src/i18n/; 위젯/골든 테스트는 같은 역할의 test/, Patrol은 실행 앱의 integration_test/에 둔다.
1. Existing Pattern Analysis#
Analyze existing Presentation Layer patterns using Serena MCP:
- feature/community/app/lib/src/presentation/bloc/post_list/post_list_bloc.dart
- feature/community/app/lib/src/presentation/bloc/post_list/post_list_event.dart
- feature/community/app/lib/src/presentation/bloc/post_list/post_list_state.dart2. BLoC Event Generation (sealed class + private implementation)#
part of '{feature}_list_bloc.dart';
/// {Feature} 목록 이벤트
@immutable
sealed class {Feature}ListEvent {
const {Feature}ListEvent();
// Factory constructors (Public API)
const factory {Feature}ListEvent.loadRequested() = _LoadRequested;
const factory {Feature}ListEvent.refreshRequested() = _RefreshRequested;
const factory {Feature}ListEvent.categoryChanged({
{Entity}Category? category,
}) = _CategoryChanged;
}
// Private implementation classes
@immutable
final class _LoadRequested extends {Feature}ListEvent {
const _LoadRequested();
}
@immutable
final class _RefreshRequested extends {Feature}ListEvent {
const _RefreshRequested();
}
@immutable
final class _CategoryChanged extends {Feature}ListEvent {
const _CategoryChanged({this.category});
final {Entity}Category? category;
}
3. BLoC State Generation#
part of '{feature}_list_bloc.dart';
/// {Feature} 목록 상태
@immutable
sealed class {Feature}ListState {
const {Feature}ListState({
required this.{entity}s,
required this.currentSort,
this.currentCategory,
});
final List<{Entity}> {entity}s;
final {Entity}Category? currentCategory;
final {Entity}SortType currentSort;
}
@immutable
final class {Feature}ListInitial extends {Feature}ListState {
const {Feature}ListInitial()
: super({entity}s: const [], currentSort: {Entity}SortType.latest);
}
@immutable
final class {Feature}ListLoading extends {Feature}ListState { ... }
@immutable
final class {Feature}ListLoaded extends {Feature}ListState {
// ... hasMore, total 추가
// copyWith 메서드 포함
}
@immutable
final class {Feature}ListError extends {Feature}ListState {
// ... message 추가
}
4. BLoC Class Generation (Optional Constructor Injection)#
import 'package:dependencies/dependencies.dart';
part '{feature}_list_event.dart';
part '{feature}_list_state.dart';
/// {Feature} 목록 BLoC
class {Feature}ListBloc extends BlocSignal<{Feature}ListEvent, {Feature}ListState> {
{Feature}ListBloc({
Get{Entity}sUsecase? get{Entity}sUsecase,
}) : _get{Entity}sUsecase = get{Entity}sUsecase ?? const Get{Entity}sUsecase(),
super(initialState: const {Feature}ListInitial()) {
on<_LoadRequested>(_onLoadRequested);
on<_RefreshRequested>(_onRefreshRequested);
}
final Get{Entity}sUsecase _get{Entity}sUsecase;
Future<void> _onLoadRequested(
_LoadRequested event,
void Function({Feature}ListState) emit,
) async {
emit({Feature}ListLoading(...));
// ✅ UseCase call
final result = await _get{Entity}sUsecase(
Get{Entity}sParams(
limit: _pageSize,
offset: _currentOffset,
category: stateValue.currentCategory,
),
);
result.fold(
(failure) {
if (!isClosed) { // ✅ BLoC closed check
emit({Feature}ListError(message: failure.message ?? '오류'));
}
},
({entity}ListResult) {
if (!isClosed) {
emit({Feature}ListLoaded(...));
}
},
);
}
}
5. BLoC Test Generation#
void main() {
late MockGet{Entity}sUsecase mockUsecase;
setUpAll(registerFallbackValues);
setUp(() {
mockUsecase = MockGet{Entity}sUsecase();
});
blocSignalTest<{Feature}ListBloc, {Feature}ListState>(
'로드 성공 시 {Feature}ListLoaded 상태',
build: () => {Feature}ListBloc(
get{Entity}sUsecase: mockUsecase, // ✅ Direct mock injection
),
setUp: () {
when(() => mockUsecase(any()))
.thenAnswer((_) async => Right(testResult));
},
act: (bloc) => bloc.add(const {Feature}ListEvent.loadRequested()),
expect: () => [
isA<{Feature}ListLoading>(),
isA<{Feature}ListLoaded>(),
],
);
}
6. Page Generation (BlocSignalProvider wrapping)#
class {Feature}Page extends StatelessWidget {
const {Feature}Page({super.key});
@override
Widget build(BuildContext context) {
return BlocSignalProvider(
create: (context) =>
{Feature}ListBloc()..add(const {Feature}ListEvent.loadRequested()),
child: const {Feature}View(),
);
}
}
class {Feature}View extends StatelessWidget {
const {Feature}View({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('{Feature}')),
body: BlocSignalBuilder<{Feature}ListBloc, {Feature}ListState>(
builder: (context, state) {
return switch (state) {
{Feature}ListInitial() => const SizedBox.shrink(),
{Feature}ListLoading() => const Center(child: Loading()),
{Feature}ListLoaded(:final {entity}s) => ListView.builder(...),
{Feature}ListError(:final message) => EmptyState(
tone: CoreEmptyStateTone.failure,
title: Text(message),
),
};
},
),
);
}
}
7. Widget Generation (super.key last)#
class {Entity}Card extends StatelessWidget {
const {Entity}Card({
required this.{entity},
this.onTap,
super.key, // ✅ Always last
});
final {Entity} {entity};
final VoidCallback? onTap;
@override
Widget build(BuildContext context) { ... }
}
8. Widget Test Generation#
testWidgets('{엔티티} 목록 표시', (tester) async {
when(() => mockRepository.get{Entity}s(...))
.thenAnswer((_) async => Right(testResult));
await tester.pumpWidget(const MaterialApp(home: {Feature}Page()));
await tester.pumpAndSettle();
expect(find.byType({Entity}Card), findsNWidgets(2));
});
9. Route Generation#
@TypedGoRoute<{Feature}Route>(path: '/{feature}')
class {Feature}Route extends GoRouteData with ${Feature}Route {
const {Feature}Route();
static RouteBase get base => ${feature}Route;
@override
MaterialPage<void> buildPage(BuildContext context, GoRouterState state) {
return const MaterialPage<void>(child: {Feature}Page());
}
}
abstract class {Feature}RouteName {
static const String path = '/{feature}';
}
10. Generate Widgetbook UseCase — 필수 5종 계약#
feature-verification을 먼저 읽는다. unit, widget, golden, Widgetbook, BDD를 기능과 함께 작성·등록·검증한다. 모든 공개 Page와 주요 Widget의 등록, feature 소유 golden 매트릭스·gallery 입력, AC별 BDD 매핑을 확인한다. 부분 레이어 생성만으로 전체 기능 완료를 보고하지 않는다.
use_case 는 이 feature 패키지 안(widgetbook/)에 생성한다. 조립 앱
(app/{{project_name}}_widgetbook)은 실제 생성 범위를 확인해 기능 fragment를 조립한다 —
widgetbook-conventions §1.
// feature/{feature_name}/{app,console}/widgetbook/lib/src/use_case/{entity}_card_use_case.dart
@widgetbook.UseCase(
name: 'Default',
type: {Entity}Card,
path: '[App]/{Feature}', // 대괄호는 세그먼트 하나를 감싼다 ('[App/{Feature}]' 아님)
)
Widget build{Entity}CardUseCase(BuildContext context) {
return {Entity}Card(
{entity}: const {Entity}(id: 1, title: '테스트', ...),
);
}
Page 단위 use_case 는 상태 dropdown Knob + this.bloc 주입이 필요하다(같은 문서 §2·§3).
11. BDD Test Integration — 실제 모드 분기#
co_bdd를 선언한 저장소는 feature test/src/bdd와 라이브러리 widget-only,
실행 앱 both/patrol-only 설정을 따른다. BDD는 전체 기능의 필수 검증이다.
아래 수기 Patrol 명령·경로는 그 방식을 명시한 legacy 저장소에만 적용한다.
/cc-flutter:bdd:generate generated separately via command:
# Write BDD tests
/cc-flutter:bdd:generate {feature_name} --location {location}⛔ 이 Patrol 파이프라인은 feature 역할 패키지(feature/{feature_name}/{app,console}/) 안에 BDD 파일을 생성하지 않습니다 —
전부 app/{app}/integration_test/에 손으로 작성됩니다(build.yaml·코드 생성기 없음):
app/{app}/integration_test/
├── features/
│ ├── {feature}_list.feature
│ ├── {feature}_detail.feature
│ └── {feature}_form.feature
├── step/
│ └── {feature}_steps.dart
└── scenarios/
└── {feature}_{scenario}_test.dartBDD Scenario Examples:
Feature: {feature} List # {feature} 목록
As a user, I want to view the {feature} list. # 사용자로서 {feature} 목록을 보고 싶습니다
@smoke
Scenario: List loads successfully # 목록 로딩 성공
Given the app is running # 앱이 실행 중입니다
When I navigate to the {feature} page # {feature} 페이지로 이동합니다
Then the {feature} list is displayed # {feature} 목록이 보입니다Output Files#
feature/{feature_name}/{app,console}/lib/src/presentation/
├── bloc/{feature}_list/
│ ├── {feature}_list_bloc.dart
│ ├── {feature}_list_event.dart
│ └── {feature}_list_state.dart
├── page/
│ └── {feature}_page.dart
├── widget/
│ └── {entity}_card.dart
feature/{feature_name}/{app,console}/lib/src/route/{feature}_route.dart
feature/{feature_name}/{app,console}/test/src/
├── golden/ # 기준 스냅샷·gallery 수집 배선 포함
├── bdd/ # co_bdd 모드: .feature·step·생성 widget 테스트
└── presentation/
├── bloc/{feature}_list_bloc_test.dart
└── page/{feature}_page_test.dart
app/{app}/integration_test/ # 실행 앱 여정/명시적 수기 Patrol legacy
├── features/
│ ├── {feature}_list.feature
│ ├── {feature}_detail.feature
│ └── {feature}_form.feature
├── step/{feature}_steps.dart
└── scenarios/{feature}_{scenario}_test.dart
feature/{feature_name}/{app,console}/widgetbook/lib/src/use_case/ # 별도 역할 소유 패키지
├── {entity}_card_use_case.dart
└── {feature}_page_use_case.dartMCP Integration#
- Serena: Existing Presentation Layer Pattern Analysis
- Context7: BLoC, GoRouter Document reference
Reference Agents#
Detailed implementation rules in ${CLAUDE_PLUGIN_ROOT}/agents/app/presentation-layer-agent.md reference
Core Rules Summary#
✅ BLoC Pattern#
// Event: sealed class + factory + private implementation
sealed class {Feature}ListEvent {
const factory {Feature}ListEvent.loadRequested() = _LoadRequested;
}
final class _LoadRequested extends {Feature}ListEvent {
const _LoadRequested();
}
// BLoC: Optional Constructor Injection
class {Feature}ListBloc extends BlocSignal<...> {
{Feature}ListBloc({
Get{Entity}sUsecase? get{Entity}sUsecase,
}) : _get{Entity}sUsecase = get{Entity}sUsecase ?? const Get{Entity}sUsecase(),
super(...);
final Get{Entity}sUsecase _get{Entity}sUsecase;
Future<void> _onLoadRequested(...) async {
final result = await _get{Entity}sUsecase(params);
if (!isClosed) { emit(...); } // ✅ Closed check
}
}
❌ Prohibited Patterns#
// ❌ getIt usage prohibited
// create: (_) => getIt<{Feature}Bloc>(),
// ❌ Key? key 패턴 금지
// const {Entity}Card({Key? key}) : super(key: key);
Widget super.key Position#
const {Entity}Card({
required this.{entity}, // required 먼저
this.onTap, // optional 다음
super.key, // ✅ super.key 마지막
});