Back to list
pariszhuang123

creating-bloc

by pariszhuang123

Kinly is a shared-household coordination app that helps people manage chores, repairs, expenses, and gratitude together. Built with Flutter and Supabase, it focuses on creating household harmony through shared visibility, gentle reminders, and collaborative tools for everyday living.

0🍴 0📅 Jan 24, 2026

SKILL.md


name: creating-bloc description: 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.

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

PatternExample
<Action><Entity>EventJoinHomeCodeChanged, 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

PatternExample
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

RulePattern
Event registrationon<Event>(_onEvent); — named method, no inline closure
Handler naming_on<EventName>
Repository injectionConstructor parameter, stored as _repository
Double-submit guardCheck state.status == ...Loading before async work
Error mappingPrivate helper converts domain exceptions to UI error types
No side effectsNavigation, 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

ScenarioRequired
Field change
Happy path (success)
Error path (repository throws)
Domain error mapping
Reset
Double-submit preventionOptional
Edge casesAs 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')

Score

Total Score

60/100

Based on repository quality metrics

SKILL.md

SKILL.mdファイルが含まれている

+20
LICENSE

ライセンスが設定されている

0/10
説明文

100文字以上の説明がある

+10
人気

GitHub Stars 100以上

0/15
最近の活動

3ヶ月以内に更新がある

0/10
フォーク

10回以上フォークされている

0/5
Issue管理

オープンIssueが50未満

+5
言語

プログラミング言語が設定されている

+5
タグ

1つ以上のタグが設定されている

0/5

Reviews

💬

Reviews coming soon