Skip to content

Commit 739449b

Browse files
committed
rewrite: Added Agents.MD and repository specific skills to facilitate newcomers / new contributors using agentic coding
1 parent a614b6e commit 739449b

8 files changed

Lines changed: 1823 additions & 1 deletion

File tree

Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
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

Comments
 (0)