|
| 1 | +--- |
| 2 | +name: unyo-bloc-state-management |
| 3 | +description: How to create cubits, define states, use the EffectMixin pattern, and handle side effects in the Unyo Flutter app. Use this skill whenever creating a new cubit, defining a new state class, adding navigation/dialog/snackbar effects, handling errors in cubits, or wiring cubits to screens with BlocProvider and BlocListener. Also use when modifying existing cubit behavior or debugging state-related issues. |
| 4 | +--- |
| 5 | + |
| 6 | +# Unyo BLoC State Management (Cubit + Effect Pattern) |
| 7 | + |
| 8 | +Unyo uses **Cubit** (from `flutter_bloc`) for state management, enhanced with a custom **EffectMixin** that handles side effects (navigation, dialogs, snackbars) through the state itself. This is the core pattern for all business logic in the app. |
| 9 | + |
| 10 | +## Architecture Overview |
| 11 | + |
| 12 | +``` |
| 13 | +lib/application/ |
| 14 | +├── cubits/ |
| 15 | +│ ├── home_cubit.dart # Cubit classes |
| 16 | +│ ├── anime_cubit.dart |
| 17 | +│ ├── anime_details_cubit.dart |
| 18 | +│ ├── video_cubit.dart |
| 19 | +│ ├── effect_mixin.dart # Mixin for side effects |
| 20 | +│ └── ... |
| 21 | +├── states/ |
| 22 | +│ ├── home_state.dart # Freezed state classes |
| 23 | +│ ├── anime_state.dart |
| 24 | +│ ├── anime_details_state.dart |
| 25 | +│ └── ... |
| 26 | +└── effects/ |
| 27 | + └── app_effects.dart # Effect type definitions |
| 28 | +``` |
| 29 | + |
| 30 | +## The Three-Part Pattern |
| 31 | + |
| 32 | +Every feature's state management consists of: |
| 33 | + |
| 34 | +1. **State** — Immutable Freezed class carrying all data + a list of effects |
| 35 | +2. **Cubit** — Business logic that emits new states, mixes in `EffectMixin` |
| 36 | +3. **Effect Handler** — UI layer that reads effects from state and executes them |
| 37 | + |
| 38 | +## Pattern: Defining a State |
| 39 | + |
| 40 | +States are Freezed classes that implement `HasEffects`. Every state must include a `List<AppEffect> effects` field. |
| 41 | + |
| 42 | +```dart |
| 43 | +// lib/application/states/home_state.dart |
| 44 | +import 'package:freezed_annotation/freezed_annotation.dart'; |
| 45 | +import 'package:unyo/application/cubits/effect_mixin.dart'; |
| 46 | +import 'package:unyo/application/effects/app_effects.dart'; |
| 47 | +import 'package:unyo/core/enums/selected_menu_option.dart'; |
| 48 | +import 'package:unyo/domain/entities/anime.dart'; |
| 49 | +import 'package:unyo/domain/entities/manga.dart'; |
| 50 | +import 'package:unyo/domain/entities/user.dart'; |
| 51 | +
|
| 52 | +part 'home_state.freezed.dart'; |
| 53 | +
|
| 54 | +@freezed |
| 55 | +abstract class HomeState with _$HomeState implements HasEffects { |
| 56 | + const factory HomeState({ |
| 57 | + required User loggedUser, |
| 58 | + required SelectedMenuOption selectedMenuOption, |
| 59 | + required List<Anime> continueWatching, |
| 60 | + required List<Manga> continueReading, |
| 61 | + required List<String> mediaCoverImages, |
| 62 | + required bool isLoading, |
| 63 | + required bool userLoaded, |
| 64 | + @Default(<AppEffect>[]) List<AppEffect> effects, // ALWAYS include this |
| 65 | + }) = _HomeState; |
| 66 | +
|
| 67 | + const HomeState._(); |
| 68 | +
|
| 69 | + @override |
| 70 | + List<AppEffect> get stateEffects => effects; // ALWAYS implement this |
| 71 | +} |
| 72 | +``` |
| 73 | + |
| 74 | +### State conventions |
| 75 | + |
| 76 | +1. **All fields `required` or have `@Default()`** — no nullable fields in states. Use sensible defaults (empty lists, `false`, `Model.empty()`). |
| 77 | +2. **`effects` always defaults to `<AppEffect>[]`** — this is the mechanism for side effects. Without it, the UI cannot show snackbars, navigate, or open dialogs. |
| 78 | +3. **`implements HasEffects`** — required for `EffectMixin` to work. |
| 79 | +4. **`const HomeState._()`** — private constructor needed when you have method overrides alongside Freezed. |
| 80 | +5. **Never have computed getters in the state** — states are pure data. Put computed logic in the cubit or utility methods. |
| 81 | + |
| 82 | +### Initial state pattern |
| 83 | + |
| 84 | +States are created with all required fields in the cubit's `super()` call: |
| 85 | + |
| 86 | +```dart |
| 87 | +: super(HomeState( |
| 88 | + loggedUser: UserModel.empty(), |
| 89 | + selectedMenuOption: SelectedMenuOption.home, |
| 90 | + continueWatching: [], |
| 91 | + continueReading: [], |
| 92 | + mediaCoverImages: [], |
| 93 | + isLoading: true, |
| 94 | + userLoaded: false, |
| 95 | + )); |
| 96 | +``` |
| 97 | + |
| 98 | +Use `Model.empty()` factory constructors for initial values, not null. |
| 99 | + |
| 100 | +## Pattern: Creating a Cubit |
| 101 | + |
| 102 | +Cubits extend `Cubit<State>` and mix in `EffectMixin<State>`: |
| 103 | + |
| 104 | +```dart |
| 105 | +// lib/application/cubits/home_cubit.dart |
| 106 | +class HomeCubit extends Cubit<HomeState> with EffectMixin<HomeState> { |
| 107 | + // Repositories |
| 108 | + final UserRepositoryAnilist _userRepositoryAnilist; |
| 109 | + final AnimeRepositoryAnilist _animeRepositoryAnilist; |
| 110 | + // Notifiers |
| 111 | + final UserNotifier _loggedUserNotifier; |
| 112 | + final AnimeNotifier _selectedAnimeNotifier; |
| 113 | + final MangaNotifier _selectedMangaNotifier; |
| 114 | + final MediaListNotifier _selectedMediaListNotifier; |
| 115 | + final ReloadNotifier _reloadNotifier; |
| 116 | + // Subscriptions (for notifiers) |
| 117 | + late StreamSubscription<User> _newLoggedUserSubscription; |
| 118 | + late StreamSubscription<ReloadType> _reloadSubscription; |
| 119 | + // Logger |
| 120 | + final Logger _logger = sl<Logger>(); |
| 121 | +
|
| 122 | + HomeCubit( |
| 123 | + this._loggedUserNotifier, |
| 124 | + this._selectedAnimeNotifier, |
| 125 | + this._selectedMangaNotifier, |
| 126 | + this._selectedMediaListNotifier, |
| 127 | + this._userRepositoryAnilist, |
| 128 | + this._animeRepositoryAnilist, |
| 129 | + this._menuBarNotifier, |
| 130 | + this._reloadNotifier, |
| 131 | + ) : super(HomeState( |
| 132 | + loggedUser: UserModel.empty(), |
| 133 | + selectedMenuOption: SelectedMenuOption.home, |
| 134 | + continueWatching: [], |
| 135 | + continueReading: [], |
| 136 | + mediaCoverImages: [], |
| 137 | + isLoading: true, |
| 138 | + userLoaded: false, |
| 139 | + )) { |
| 140 | + _init(); |
| 141 | + } |
| 142 | +
|
| 143 | + @override |
| 144 | + State copyStateWithEffects(State state, List<AppEffect> effects) { |
| 145 | + return state.copyWith(effects: effects); |
| 146 | + } |
| 147 | +
|
| 148 | + @override |
| 149 | + Logger get logger => _logger; |
| 150 | +
|
| 151 | + void _init() { |
| 152 | + _newLoggedUserSubscription = _loggedUserNotifier.userStream.listen((user) { |
| 153 | + emit(state.copyWith(loggedUser: user)); |
| 154 | + if (!state.userLoaded) { |
| 155 | + _getUserInfo(user); |
| 156 | + emit(state.copyWith(userLoaded: true, isLoading: false)); |
| 157 | + } |
| 158 | + }); |
| 159 | + _reloadSubscription = _reloadNotifier.reloadStream.listen((reloadType) async { |
| 160 | + if (reloadType == ReloadType.homeMediaListEntryUpdated) { |
| 161 | + await _getUserInfo(state.loggedUser, ignoreCacheAnime: true); |
| 162 | + } |
| 163 | + }); |
| 164 | + } |
| 165 | +
|
| 166 | + @override |
| 167 | + Future<void> close() { |
| 168 | + _newLoggedUserSubscription.cancel(); |
| 169 | + _reloadSubscription.cancel(); |
| 170 | + return super.close(); |
| 171 | + } |
| 172 | +} |
| 173 | +``` |
| 174 | + |
| 175 | +### Cubit conventions |
| 176 | + |
| 177 | +1. **Mixin `EffectMixin<State>`** — this is non-optional. Every cubit needs it for navigation and user feedback. |
| 178 | +2. **Implement `copyStateWithEffects()` and `logger`** — required by `EffectMixin`. The `copyStateWithEffects` implementation always uses `state.copyWith(effects: effects)`. |
| 179 | +3. **Constructor injection** — all dependencies (repositories, notifiers) come through the constructor. The DI container provides them when creating the cubit. |
| 180 | +4. **Private `_init()` method** — called from constructor to set up stream subscriptions after fields are initialized. |
| 181 | +5. **Always cancel subscriptions in `close()`** — prevents memory leaks. |
| 182 | + |
| 183 | +## Pattern: EffectMixin — Side Effects in Cubits |
| 184 | + |
| 185 | +The `EffectMixin` provides methods for navigation, dialogs, and feedback without directly accessing `BuildContext` (which cubits should never hold): |
| 186 | + |
| 187 | +### Available effect methods |
| 188 | + |
| 189 | +```dart |
| 190 | +// Navigation effects |
| 191 | +pushRouteEffect(path: "/animedetails"); // Push onto stack |
| 192 | +replaceRouteEffect(path: "/login"); // Replace current route |
| 193 | +navigateRouteEffect(path: "/tabs"); // Navigate within tabs |
| 194 | +changeRouteTabEffect(context, path: "/anime"); // Switch tab in AutoTabsRouter |
| 195 | +popRouteEffect(context); // Pop current route |
| 196 | +
|
| 197 | +// Dialog effects |
| 198 | +showWidgetDialogEffect(dialog: MyDialog()); // Show arbitrary widget dialog |
| 199 | +showDrawerDialogEffect( |
| 200 | + drawerDialog: MyDrawer(), |
| 201 | + backgroundColor: Colors.black54, |
| 202 | + startPosition: AxisDirection.right, |
| 203 | +); // Show slide-in drawer dialog |
| 204 | +closeDialogEffect(context); // Close current dialog |
| 205 | +
|
| 206 | +// Feedback effects |
| 207 | +showSnackBarEffect("Title", message: "Details", contentType: ContentType.failure); |
| 208 | +showSnackBarEffect("Success!", message: "Saved", contentType: ContentType.success); |
| 209 | +
|
| 210 | +// Error handling (combines logging + snackbar) |
| 211 | +handleError("Error fetching data: $e", stackTrace: stackTrace); |
| 212 | +``` |
| 213 | + |
| 214 | +### How effects flow |
| 215 | + |
| 216 | +1. **Cubit calls** `pushRouteEffect(path: "/animedetails")` |
| 217 | +2. **EffectMixin creates** a `PushRouteEffect("/animedetails")` and adds it to the state's effects list via `emit(copyStateWithEffects(state, [...currentEffects, effect]))` |
| 218 | +3. **BlocListener in the UI** detects `state.effects.isNotEmpty` and calls `sl<AppEffectHandler>().handleEffects(context, state.effects, cubit.clearEffects)` |
| 219 | +4. **AppEffectHandler** pattern-matches on the effect type and calls `AutoRouter.of(context).pushPath(...)` |
| 220 | +5. **clearEffects()** removes all effects from state after processing |
| 221 | + |
| 222 | +This pattern keeps cubits free of `BuildContext` while still enabling navigation and UI feedback. |
| 223 | + |
| 224 | +### Why effects instead of direct navigation? |
| 225 | + |
| 226 | +Cubits should not hold `BuildContext` references (they outlive the widget tree). Effects let cubits express intent ("navigate to anime details") without knowing how or when it happens. The UI layer handles the actual navigation. |
| 227 | + |
| 228 | +## Pattern: Emitting State Changes |
| 229 | + |
| 230 | +```dart |
| 231 | +// Simple property update |
| 232 | +emit(state.copyWith(isLoading: true)); |
| 233 | +
|
| 234 | +// Conditional logic |
| 235 | +if (!state.userLoaded) { |
| 236 | + await _getUserInfo(user); |
| 237 | + emit(state.copyWith(userLoaded: true, isLoading: false)); |
| 238 | +} |
| 239 | +
|
| 240 | +// Error handling with effect |
| 241 | +try { |
| 242 | + final data = await _repository.getData(); |
| 243 | + emit(state.copyWith(data: data, isLoading: false)); |
| 244 | +} catch (e, stackTrace) { |
| 245 | + handleError("Failed to load data: $e", stackTrace: stackTrace); |
| 246 | + replaceRouteEffect(path: "/login"); |
| 247 | +} |
| 248 | +``` |
| 249 | + |
| 250 | +### Key emission rules |
| 251 | + |
| 252 | +- **Always use `state.copyWith()`** — never mutate state directly. Freezed enforces this since all fields are immutable. |
| 253 | +- **Chain related emissions** — it's fine to emit multiple times in one method. Each emission triggers a rebuild in `BlocBuilder`. |
| 254 | +- **Don't emit after async gaps without checking** — after an `await`, the cubit might be closed. Wrap post-async logic in try/catch. |
| 255 | + |
| 256 | +## Pattern: Subscribing to Notifiers in Cubits |
| 257 | + |
| 258 | +See the `unyo-reactive-notifiers` skill for the full pattern. The key steps: |
| 259 | + |
| 260 | +1. Declare `late StreamSubscription<T>` fields |
| 261 | +2. Subscribe in `_init()` called from constructor |
| 262 | +3. Call `.cancel()` on all subscriptions in `close()` |
| 263 | + |
| 264 | +```dart |
| 265 | +late StreamSubscription<User> _userSub; |
| 266 | +
|
| 267 | +void _init() { |
| 268 | + _userSub = _userNotifier.userStream.listen((user) { |
| 269 | + emit(state.copyWith(loggedUser: user)); |
| 270 | + }); |
| 271 | +} |
| 272 | +
|
| 273 | +@override |
| 274 | +Future<void> close() { |
| 275 | + _userSub.cancel(); |
| 276 | + return super.close(); |
| 277 | +} |
| 278 | +``` |
| 279 | + |
| 280 | +## Creating a New Cubit+State: Complete Workflow |
| 281 | + |
| 282 | +1. **Create the state file** at `lib/application/states/<feature>_state.dart`: |
| 283 | + - Freezed class implementing `HasEffects` |
| 284 | + - Include `@Default(<AppEffect>[]) List<AppEffect> effects` |
| 285 | + - All required fields with sensible defaults |
| 286 | + - Part directives for `*.freezed.dart` |
| 287 | + |
| 288 | +2. **Create the cubit file** at `lib/application/cubits/<feature>_cubit.dart`: |
| 289 | + - Extends `Cubit<State>` with `EffectMixin<State>` |
| 290 | + - Implements `copyStateWithEffects` and `logger` |
| 291 | + - Constructor injection of all dependencies |
| 292 | + - `_init()` for stream subscriptions |
| 293 | + - `close()` for cleanup |
| 294 | + |
| 295 | +3. **Register in DI** at `lib/core/di/locator.dart`: |
| 296 | + ```dart |
| 297 | + sl.registerFactory<FeatureCubit>(() => FeatureCubit( |
| 298 | + sl<FeatureNotifier>(), |
| 299 | + sl<FeatureRepositoryAnilist>(), |
| 300 | + )); |
| 301 | + ``` |
| 302 | + |
| 303 | +4. **Wire to screen** — see `unyo-ui-presentation` skill for BlocProvider/BlocListener/BlocBuilder pattern. |
| 304 | + |
| 305 | +5. **Run code generation**: |
| 306 | + ```sh |
| 307 | + flutter pub run build_runner build --delete-conflicting-outputs |
| 308 | + ``` |
| 309 | + |
| 310 | +6. **Run analysis**: |
| 311 | + ```sh |
| 312 | + flutter analyze |
| 313 | + ``` |
| 314 | + |
| 315 | +## Common Pitfalls |
| 316 | + |
| 317 | +- **Forgetting `implements HasEffects`**: The state must implement this interface for `EffectMixin` to work. Without it, `_currentEffects` throws a `StateError`. |
| 318 | +- **Not calling `clearEffects()` from the UI**: `BlocListener` must call `cubit.clearEffects()` after processing effects, otherwise effects will keep firing on every rebuild. |
| 319 | +- **Storing `BuildContext` in a cubit**: Never do this. Cubits outlive widget trees. Use effects instead. |
| 320 | +- **Mutating state directly**: Freezed states are immutable. Always use `state.copyWith(...)`. |
| 321 | +- **Missing `copyStateWithEffects` implementation**: Every cubit that mixes in `EffectMixin` must implement this. It always looks like `state.copyWith(effects: effects)`. |
| 322 | +- **Not canceling subscriptions**: Will cause memory leaks and phantom updates on disposed cubits. |
| 323 | +- **Registering cubits as singletons**: Cubits must be `registerFactory`, not `registerSingleton` or `registerLazySingleton`. Each screen needs its own instance. |
| 324 | + |
| 325 | +## Cross-references |
| 326 | + |
| 327 | +- **Effect types and handler**: Examined in `unyo-ui-presentation` skill |
| 328 | +- **Notifier subscription pattern**: See `unyo-reactive-notifiers` skill |
| 329 | +- **DI registration**: See `unyo-dependency-injection` skill |
| 330 | +- **State entities used in states**: See `unyo-domain-data-layer` skill |
0 commit comments