creating-bloc

pariszhuang123's avatarfrom pariszhuang123

Creates Flutter BLoCs with events, states, and tests following Kinly patterns. Use when asked to create a BLoC, add business logic, or implement state management for a feature.

⭐0stars🔀0forks📁View on GitHub🕐Updated Jan 11, 2026⬇️1downloads

When & Why to Use This Skill

This Claude skill automates the creation of Flutter BLoC (Business Logic Component) architectures, including events, states, and unit tests. It enforces standardized patterns such as repository injection, named event handlers, and the copyWith sentinel pattern, significantly reducing boilerplate while ensuring high-quality, testable state management code.

Use Cases

  • Rapid Feature Scaffolding: Quickly generate the complete BLoC layer for new Flutter features, including the directory structure and part-file relationships.
  • Standardized State Management: Implement robust state handling with predefined status enums (initial, loading, success, failure) and domain-to-UI error mapping.
  • Automated Unit Testing: Generate comprehensive test suites using bloc_test and mocktail to verify happy paths, error handling, and state transitions.
  • Architectural Guardrails: Ensure codebase consistency by automatically applying best practices like double-submit prevention and avoiding side effects in BLoCs.
namecreating-bloc
descriptionCreates Flutter BLoCs with events, states, and tests following Kinly patterns. Use when asked to create a BLoC, add business logic, or implement state management for a feature.

Creating BLoCs

Step-by-step workflow for creating Flutter BLoCs in Kinly.

Prerequisites

Before starting, read:

  • AGENTS.md § Boundaries — BLoC consumes repositories only
  • AGENTS.md § Guardrails — named handlers, complexity budget

Workflow Overview

1. Events → 2. States → 3. BLoC → 4. Tests → 5. Verify

File Structure

lib/features/<feature>/bloc/
├── <feature>_bloc.dart      # BLoC class
├── <feature>_event.dart     # part file
└── <feature>_state.dart     # part file

Or for complex features with multiple BLoCs:

lib/features/<feature>/bloc/<bloc_name>/
├── <bloc_name>_bloc.dart
├── <bloc_name>_event.dart
└── <bloc_name>_state.dart

Step 1: Create Events

Create <feature>_event.dart as a part file:

part of '<feature>_bloc.dart';

abstract class <Feature>Event extends Equatable {
  const <Feature>Event();

  @override
  List<Object?> get props => [];
}

/// Triggered when user changes input field
class <Feature><Field>Changed extends <Feature>Event {
  const <Feature><Field>Changed(this.value);

  final String value;

  @override
  List<Object?> get props => [value];
}

/// Triggered when user submits the form
class <Feature>Submitted extends <Feature>Event {
  const <Feature>Submitted();
}

/// Triggered to reset state
class <Feature>Reset extends <Feature>Event {
  const <Feature>Reset();
}

Naming Convention

Pattern Example
<Action><Entity>Event JoinHomeCodeChanged, JoinHomeSubmitted
Field changes <Feature><Field>Changed
Actions <Feature>Submitted, <Feature>Canceled, <Feature>Reset
Data loading <Feature>Loaded, <Feature>Refreshed

Step 2: Create States

Create <feature>_state.dart as a part file:

part of '<feature>_bloc.dart';

enum <Feature>Status { initial, loading, success, failure }

class <Feature>State extends Equatable {
  const <Feature>State({
    this.field = '',
    this.status = <Feature>Status.initial,
    this.errorMessage,
    this.errorType,
  });

  final String field;
  final <Feature>Status status;
  final String? errorMessage;
  final <Feature>ErrorType? errorType;

  /// Computed property for UI
  bool get canSubmit => field.isNotEmpty;

  <Feature>State copyWith({
    String? field,
    <Feature>Status? status,
    Object? errorMessage = _unset,
    Object? errorType = _unset,
  }) {
    return <Feature>State(
      field: field ?? this.field,
      status: status ?? this.status,
      errorMessage:
          errorMessage == _unset ? this.errorMessage : errorMessage as String?,
      errorType:
          errorType == _unset
              ? this.errorType
              : errorType as <Feature>ErrorType?,
    );
  }

  static const _unset = Object();

  @override
  List<Object?> get props => [field, status, errorMessage, errorType];
}

/// Domain-specific error types for UI handling
enum <Feature>ErrorType {
  invalidInput,
  notFound,
  unauthorized,
  unknown,
}

Naming Convention

Pattern Example
Status enum <Feature>Status with initial, loading, success, failure
State class <Feature>State with copyWith
Error types <Feature>ErrorType (UI-only; domain errors in lib/core/.../enums/)

Step 3: Create BLoC

Create <feature>_bloc.dart:

import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';

import 'package:kinly/contracts/<domain>/ports/<domain>_repository.dart';
import 'package:kinly/contracts/<domain>/models.dart';
import 'package:kinly/core/supabase/supabase_error_mapper.dart';

part '<feature>_event.dart';
part '<feature>_state.dart';

class <Feature>Bloc extends Bloc<<Feature>Event, <Feature>State> {
  <Feature>Bloc({required <Domain>Repository repository})
    : _repository = repository,
      super(const <Feature>State()) {
    // Register event handlers with named methods (no inline closures)
    on<<Feature><Field>Changed>(_on<Field>Changed);
    on<<Feature>Submitted>(_onSubmitted);
    on<<Feature>Reset>(_onReset);
  }

  final <Domain>Repository _repository;

  void _on<Field>Changed(
    <Feature><Field>Changed event,
    Emitter<<Feature>State> emit,
  ) {
    emit(
      state.copyWith(
        field: event.value.trim(),
        status: <Feature>Status.initial,
        errorMessage: null,
        errorType: null,
      ),
    );
  }

  Future<void> _onSubmitted(
    <Feature>Submitted event,
    Emitter<<Feature>State> emit,
  ) async {
    // Guard: prevent double-submit
    if (!state.canSubmit || state.status == <Feature>Status.loading) return;

    emit(
      state.copyWith(
        status: <Feature>Status.loading,
        errorMessage: null,
        errorType: null,
      ),
    );

    try {
      final result = await _repository.doAction(state.field);
      emit(
        state.copyWith(
          status: <Feature>Status.success,
          errorMessage: null,
          errorType: null,
        ),
      );
    } catch (error) {
      final mapped = _mapError(error);
      emit(
        state.copyWith(
          status: <Feature>Status.failure,
          errorMessage: mapped.message,
          errorType: mapped.type,
        ),
      );
    }
  }

  void _onReset(
    <Feature>Reset event,
    Emitter<<Feature>State> emit,
  ) {
    emit(const <Feature>State());
  }

  _ErrorResult _mapError(Object error) {
    if (error is <Domain>Exception) {
      return _ErrorResult(
        type: _mapErrorType(error.code),
        message: error.message,
      );
    }
    return _ErrorResult(
      type: <Feature>ErrorType.unknown,
      message: error.toString(),
    );
  }

  <Feature>ErrorType _mapErrorType(<Domain>ErrorCode code) {
    return switch (code) {
      <Domain>ErrorCode.invalidInput => <Feature>ErrorType.invalidInput,
      <Domain>ErrorCode.notFound => <Feature>ErrorType.notFound,
      _ => <Feature>ErrorType.unknown,
    };
  }
}

class _ErrorResult {
  const _ErrorResult({this.type, this.message});

  final <Feature>ErrorType? type;
  final String? message;
}

Key Patterns

Rule Pattern
Event registration on<Event>(_onEvent); — named method, no inline closure
Handler naming _on<EventName>
Repository injection Constructor parameter, stored as _repository
Double-submit guard Check state.status == ...Loading before async work
Error mapping Private helper converts domain exceptions to UI error types
No side effects Navigation, analytics belong in UI listeners, not BLoC

Step 4: Write Tests

Create test/features/<feature>/bloc/<feature>_bloc_test.dart:

import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';

import 'package:kinly/contracts/<domain>/ports/<domain>_repository.dart';
import 'package:kinly/contracts/<domain>/models.dart';
import 'package:kinly/core/supabase/supabase_error_mapper.dart';
import 'package:kinly/features/<feature>/bloc/<feature>_bloc.dart';

class _Mock<Domain>Repository extends Mock implements <Domain>Repository {}

void main() {
  late _Mock<Domain>Repository repository;

  setUp(() {
    repository = _Mock<Domain>Repository();
  });

  <Feature>Bloc buildBloc() => <Feature>Bloc(repository: repository);

  group('<Feature>Bloc', () {
    // Test: field change updates state
    blocTest<<Feature>Bloc, <Feature>State>(
      'updates field when <Feature><Field>Changed is added',
      build: buildBloc,
      act: (bloc) => bloc.add(const <Feature><Field>Changed(' value ')),
      expect: () => const [
        <Feature>State(field: 'value', status: <Feature>Status.initial),
      ],
    );

    // Test: happy path
    blocTest<<Feature>Bloc, <Feature>State>(
      'emits success when repository succeeds',
      build: () {
        when(() => repository.doAction(any()))
            .thenAnswer((_) async => const Result());
        return buildBloc();
      },
      seed: () => const <Feature>State(
        field: 'value',
        status: <Feature>Status.initial,
      ),
      act: (bloc) => bloc.add(const <Feature>Submitted()),
      expect: () => const [
        <Feature>State(field: 'value', status: <Feature>Status.loading),
        <Feature>State(field: 'value', status: <Feature>Status.success),
      ],
      verify: (_) {
        verify(() => repository.doAction('value')).called(1);
      },
    );

    // Test: error path
    blocTest<<Feature>Bloc, <Feature>State>(
      'emits failure when repository throws',
      build: () {
        when(() => repository.doAction(any()))
            .thenThrow(Exception('boom'));
        return buildBloc();
      },
      seed: () => const <Feature>State(
        field: 'value',
        status: <Feature>Status.initial,
      ),
      act: (bloc) => bloc.add(const <Feature>Submitted()),
      expect: () => const [
        <Feature>State(field: 'value', status: <Feature>Status.loading),
        <Feature>State(
          field: 'value',
          status: <Feature>Status.failure,
          errorMessage: 'Exception: boom',
          errorType: <Feature>ErrorType.unknown,
        ),
      ],
      verify: (_) {
        verify(() => repository.doAction('value')).called(1);
      },
    );

    // Test: domain-specific error mapping
    blocTest<<Feature>Bloc, <Feature>State>(
      'maps domain error to UI error type',
      build: () {
        when(() => repository.doAction(any())).thenThrow(
          <Domain>Exception(
            <Domain>ErrorCode.invalidInput,
            'Field is required',
          ),
        );
        return buildBloc();
      },
      seed: () => const <Feature>State(
        field: 'bad',
        status: <Feature>Status.initial,
      ),
      act: (bloc) => bloc.add(const <Feature>Submitted()),
      expect: () => const [
        <Feature>State(field: 'bad', status: <Feature>Status.loading),
        <Feature>State(
          field: 'bad',
          status: <Feature>Status.failure,
          errorMessage: 'Field is required',
          errorType: <Feature>ErrorType.invalidInput,
        ),
      ],
    );

    // Test: reset
    blocTest<<Feature>Bloc, <Feature>State>(
      'resets to initial state',
      build: buildBloc,
      seed: () => const <Feature>State(
        field: 'value',
        status: <Feature>Status.success,
      ),
      act: (bloc) => bloc.add(const <Feature>Reset()),
      expect: () => const [<Feature>State()],
    );
  });
}

Test Coverage Requirements

Scenario Required
Field change ✓
Happy path (success) ✓
Error path (repository throws) ✓
Domain error mapping ✓
Reset ✓
Double-submit prevention Optional
Edge cases As needed

Step 5: Verify

# Run all checks
dart run tool/check_all.dart

# Run BLoC tests
flutter test test/features/<feature>/bloc/

# Run all tests
flutter test

Checklist

  • Events extend Equatable with props
  • Event naming: <Action><Entity>Event
  • States extend Equatable with props
  • State has copyWith with _unset sentinel pattern
  • Status enum: initial, loading/submitting, success, failure
  • BLoC uses named handlers (on<Event>(_onEvent)) — no inline closures
  • BLoC injects repository via constructor
  • BLoC guards against double-submit
  • Error mapping converts domain exceptions to UI error types
  • No side effects in BLoC (navigation, analytics)
  • Tests cover: field change, success, failure, error mapping, reset
  • Tests use blocTest + mocktail
  • dart run tool/check_all.dart passes
  • flutter test passes

Anti-Patterns to Avoid

❌ Inline Event Handlers

// BAD
on<SubmitEvent>((event, emit) async {
  // long handler...
});

// GOOD
on<SubmitEvent>(_onSubmit);

❌ Direct Supabase Calls

// BAD
final data = await supabase.rpc('do_action');

// GOOD
final result = await _repository.doAction();
// BAD
if (state is Success) Navigator.of(context).pop();

// GOOD — UI listens and navigates
// In widget:
context.read<MyBloc>().stream.listen((state) {
  if (state.status == Status.success) Navigator.of(context).pop();
});

❌ UI Logic in State

// BAD
class MyState {
  String get buttonLabel => isLoading ? 'Loading...' : 'Submit';
}

// GOOD — computed in UI
Text(state.status == Status.loading ? 'Loading...' : 'Submit')