LogoSkills

feature-presentation

Clean Architecture 프레젠테이션 레이어 생성 (BLoC, Page, Widget, 테스트, Widgetbook)

/cc-flutter:feature:presentation — 화면(UI) 한 세트 자동 생성#

항목내용
실행 명령/cc-flutter:feature:presentation
분류워크플로우
난이도●●○ 보통
MCP 서버serena, context7, magic

한마디로#

화면을 만들 때 필요한 부품들(화면 틀, 버튼·카드 같은 위젯, 데이터를 화면에 연결하는 로직, 테스트, 미리보기 카탈로그)을 한 번에 자동으로 찍어내는 도구입니다. 가구 조립처럼, 화면을 만들 때마다 매번 손으로 깎던 부품을 정해진 규격대로 한 세트로 뽑아준다고 보면 됩니다.

누가·언제 쓰나요#

  • 새 기능의 화면(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

# 만들 위치를 지정 (application / common / 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 로 어떤 페이지들을 만들지 정할 수 있습니다.

안에서 무슨 일이 벌어지나요#

크게 다음 순서로 화면 부품들을 차례차례 만들어냅니다.

  1. 기존 방식 살펴보기 — 이미 있는 비슷한 화면 코드를 분석해 같은 스타일로 맞춥니다.
  2. 화면 동작 로직 만들기 — 데이터를 불러오고(로딩·성공·실패) 화면 상태를 관리하는 BLoC 부분을 생성합니다.
  3. 로직 테스트 만들기 — 그 동작 로직이 제대로 작동하는지 검증하는 테스트를 함께 만듭니다.
  4. 페이지와 위젯 만들기 — 실제 보이는 화면 틀과, 카드 같은 작은 화면 부품을 생성합니다.
  5. 화면 테스트 만들기 — 화면이 의도대로 그려지는지 검증하는 테스트를 만듭니다.
  6. 화면 이동 경로 만들기 — 이 화면으로 어떻게 이동하는지 경로를 정의합니다.
  7. 미리보기 카탈로그 등록 — 만든 부품을 따로 미리 볼 수 있게 Widgetbook에 등록합니다.
  8. (선택) BDD 시나리오 연동 — 사용자 관점의 동작 시나리오를 별도로 생성합니다.

⚙️ 상세 옵션·실행 명세 (개발자 / AI 에이전트용)

Triggers#

  • When a new Feature Presentation Layer is needed
  • When BLoC, Page, Widget, and Route generation are needed
  • /cc-flutter:feature:create orchestration Step 5

Context Trigger Pattern#

/cc-flutter:feature:presentation {feature_name} {entity_name} [--options]

Parameters#

ParameterRequiredDescriptionExample
feature_nameFeature module name (snake_case)community, chat
entity_nameEntity name (PascalCase)Post, Message
--locationLocationapplication, common, console (default: application)
--pagesPage list to generate"list, detail, create"

Behavioral Flow#

1. Existing Pattern Analysis#

Analyze existing Presentation Layer patterns using Serena MCP:
- feature/application/community/lib/src/presentation/bloc/post_list/post_list_bloc.dart
- feature/application/community/lib/src/presentation/bloc/post_list/post_list_event.dart
- feature/application/community/lib/src/presentation/bloc/post_list/post_list_state.dart

2. 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: CircularProgressIndicator()),
            {Feature}ListLoaded(:final {entity}s) => ListView.builder(...),
            {Feature}ListError(:final message) => Center(child: 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#

use_case 는 이 feature 패키지 안(widgetbook/)에 생성한다. 조립 앱 (app/{{project_name}}_widgetbook)은 스캔해서 진열만 한다 — widgetbook-conventions §1.

// feature/{location}/{feature_name}/widgetbook/{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 (Optional)#

/cc-flutter:bdd:generate generated separately via command:

# Write BDD tests
/cc-flutter:bdd:generate {feature_name} --location {location}

⛔ feature 패키지(feature/{location}/{feature_name}/) 안에는 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.dart

BDD 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/{location}/{feature_name}/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
└── route/
    └── {feature}_route.dart

feature/{location}/{feature_name}/test/
└── presentation/
    ├── bloc/{feature}_list_bloc_test.dart
    └── page/{feature}_page_test.dart

app/{app}/integration_test/                # BDD 테스트 (선택, feature 패키지 밖 — 코드 생성기 없음)
├── features/
│   ├── {feature}_list.feature
│   ├── {feature}_detail.feature
│   └── {feature}_form.feature
├── step/{feature}_steps.dart
└── scenarios/{feature}_{scenario}_test.dart

feature/{location}/{feature_name}/widgetbook/    # 전시물 — feature 소유 (조립 앱 아님)
├── {entity}_card_use_case.dart
└── {feature}_page_use_case.dart

MCP Integration#

  • Serena: Existing Presentation Layer Pattern Analysis
  • Context7: BLoC, GoRouter Document reference
  • Magic (21st.dev): UI 컴포넌트 Generation, 접근성 검사

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 마지막
});