From 7a926f8425dc45d104cbee304757af85ed0b571f Mon Sep 17 00:00:00 2001 From: Job Guldemeester Date: Thu, 18 Jun 2026 11:55:47 +0200 Subject: [PATCH 1/3] :sparkles: Added skills --- skills/dcc-create-blocful-page/SKILL.md | 587 ++++++++++++++++++ skills/dcc-create-paginated-cubit/SKILL.md | 470 ++++++++++++++ skills/dcc-setup-bolt-logger/SKILL.md | 317 ++++++++++ skills/dcc-setup-kleurplaat-boekwerk/SKILL.md | 538 ++++++++++++++++ 4 files changed, 1912 insertions(+) create mode 100644 skills/dcc-create-blocful-page/SKILL.md create mode 100644 skills/dcc-create-paginated-cubit/SKILL.md create mode 100644 skills/dcc-setup-bolt-logger/SKILL.md create mode 100644 skills/dcc-setup-kleurplaat-boekwerk/SKILL.md diff --git a/skills/dcc-create-blocful-page/SKILL.md b/skills/dcc-create-blocful-page/SKILL.md new file mode 100644 index 0000000..bbec17f --- /dev/null +++ b/skills/dcc-create-blocful-page/SKILL.md @@ -0,0 +1,587 @@ +--- +name: dcc-create-blocful-page +description: Create a full page using BlocfulWidget with BlocPresentationMixin for one-shot events, Cubit state management, and native dialogs. Use when creating a new screen with BLoC, adding presentation events, wiring up a Cubit to a page, or showing platform-adaptive dialogs. +metadata: + last_modified: 2025-06-18 +--- + +# Create a BlocfulWidget Page + +## Contents + +- [Overview](#overview) +- [Prerequisites](#prerequisites) +- [Core Concepts](#core-concepts) +- [Workflow](#workflow) +- [Complete Example](#complete-example) +- [Common Patterns](#common-patterns) +- [Feedback Loop](#feedback-loop) + +## Overview + +`BlocfulWidget` is the DCC toolkit's opinionated page widget that combines three BLoC concepts into one class: + +1. **BlocProvider** -- optionally creates and provides the Cubit +2. **BlocPresentationListener** -- listens for one-shot "presentation events" (snackbars, navigation, dialogs) +3. **BlocConsumer** -- rebuilds UI on state changes + provides a listener for side effects + +This eliminates the boilerplate of nesting `BlocProvider` > `BlocPresentationListener` > `BlocConsumer` manually. + +Additionally, the toolkit provides `showNativeDialog()` for platform-adaptive dialogs (Cupertino on iOS, Material on Android). + +## Prerequisites + +```dart +import 'package:dcc_toolkit/dcc_toolkit.dart'; // BlocfulWidget, showNativeDialog, DialogAction +``` + +Dependencies required in the consuming project: +- `flutter_bloc` (for `Cubit`, `BlocPresentationMixin`) +- `bloc_presentation` (for `BlocPresentationMixin`, `emitPresentation()`) +- `dcc_toolkit` + +Optional but common: +- `freezed_annotation` + `freezed` (for state classes) +- `injectable` (for DI) + +## Core Concepts + +| Class | Role | +|-------|------| +| `BlocfulWidget` | Abstract `StatelessWidget`. Combines `BlocProvider` + `BlocPresentationListener` + `BlocConsumer` | +| `BlocPresentationMixin` | Mixin from `bloc_presentation`. Adds `emitPresentation(event)` for one-shot events | +| `showNativeDialog()` | Shows `CupertinoAlertDialog` on iOS, `AlertDialog` on Android. Auto-closes after action tap | +| `DialogAction` | Action for native dialog: `text`, `onTap` (returns `FutureOr`), `isDestructiveAction`, `textStyle` | + +### BlocfulWidget API + +| Method | Purpose | Required | +|--------|---------|----------| +| `builder(context, bloc, state)` | Build the page UI. Called on every state change | Yes | +| `onPresentationEvent(context, event)` | Handle one-shot events (show snackbar, navigate, show dialog) | No (default is no-op) | +| `listener(context, bloc, state)` | BlocConsumer listener for state-based side effects | No (default is no-op) | + +### Constructor Parameter + +| Parameter | Purpose | +|-----------|---------| +| `onCreateBloc` | `BLOC Function(BuildContext)?`. If provided, wraps the widget in `BlocProvider`. If null, expects the Bloc to already exist in the widget tree | + +### Presentation Events vs State + +| Concept | Use For | Delivery | +|---------|---------|----------| +| **State** (emit) | UI that should rebuild: loading indicators, data display, form fields | Persistent -- last emitted state is always available | +| **Presentation Events** (emitPresentation) | One-shot side effects: show snackbar, navigate, show dialog, trigger animation | Fire-and-forget -- only delivered once to active listeners | + +## Workflow + +**Task Progress:** +- [ ] 1. Create presentation event class(es) +- [ ] 2. Create Cubit state (with freezed or manual) +- [ ] 3. Create Cubit with `BlocPresentationMixin` +- [ ] 4. Create page extending `BlocfulWidget` +- [ ] 5. Override `builder()` for UI +- [ ] 6. Override `onPresentationEvent()` for one-shot actions +- [ ] 7. (Optional) Add `showNativeDialog()` for confirmations +- [ ] 8. Verify with `dart analyze` + +### Step 1: Create Presentation Events + +Define a sealed class hierarchy for events: + +```dart +// user_event.dart +sealed class UserEvent { + String get message; +} + +class UserLoaded implements UserEvent { + const UserLoaded(); + + @override + String get message => 'User loaded successfully'; +} + +class UserSaveFailed implements UserEvent { + const UserSaveFailed(this.reason); + + final String reason; + + @override + String get message => 'Save failed: $reason'; +} + +class NavigateToProfile implements UserEvent { + const NavigateToProfile(this.userId); + + final String userId; + + @override + String get message => ''; +} +``` + +### Step 2: Create Cubit State + +Using freezed (recommended): + +```dart +// user_state.dart +part of 'user_cubit.dart'; + +@freezed +sealed class UserState with _$UserState { + const factory UserState({ + User? user, + @Default(false) bool isLoading, + @Default(false) bool hasError, + }) = _UserState; +} +``` + +Or manually: + +```dart +import 'package:flutter/foundation.dart'; + +@immutable +class UserState { + const UserState({this.user, this.isLoading = false, this.hasError = false}); + + final User? user; + final bool isLoading; + final bool hasError; + + UserState copyWith({User? user, bool? isLoading, bool? hasError}) => UserState( + user: user ?? this.user, + isLoading: isLoading ?? this.isLoading, + hasError: hasError ?? this.hasError, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is UserState && + other.user == user && + other.isLoading == isLoading && + other.hasError == hasError; + + @override + int get hashCode => Object.hash(user, isLoading, hasError); +} +``` + +### Step 3: Create Cubit with BlocPresentationMixin + +```dart +// user_cubit.dart +import 'package:bloc_presentation/bloc_presentation.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'user_cubit.freezed.dart'; +part 'user_state.dart'; + +class UserCubit extends Cubit + with BlocPresentationMixin { + UserCubit(this._repository) : super(const UserState()); + + final UserRepository _repository; + + Future loadUser(String id) async { + emit(state.copyWith(isLoading: true)); + + final result = await _repository.getUser(id); + result.when( + success: (user) { + emit(state.copyWith(user: user, isLoading: false)); + emitPresentation(const UserLoaded()); + }, + error: (error) { + emit(state.copyWith(hasError: true, isLoading: false)); + emitPresentation(UserSaveFailed(error.toString())); + }, + ); + } + + Future deleteUser() async { + final result = await _repository.deleteUser(state.user!.id); + result.when( + success: (_) => emitPresentation(NavigateToProfile(state.user!.id)), + error: (error) => emitPresentation(UserSaveFailed(error.toString())), + ); + } +} +``` + +### Step 4: Create the Page with BlocfulWidget + +```dart +// user_page.dart +import 'package:dcc_toolkit/ui/blocful_widget.dart'; +import 'package:flutter/material.dart'; + +class UserPage extends BlocfulWidget { + const UserPage({required this.onCreateCubit, super.key}) + : super(onCreateBloc: onCreateCubit); + + final UserCubit Function(BuildContext)? onCreateCubit; + + @override + void onPresentationEvent(BuildContext context, UserEvent event) { + switch (event) { + case UserLoaded(): + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(event.message)), + ); + case UserSaveFailed(): + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(event.message), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + case NavigateToProfile(:final userId): + Navigator.of(context).pop(userId); + } + } + + @override + Widget builder(BuildContext context, UserCubit bloc, UserState state) { + if (state.isLoading) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + + return Scaffold( + appBar: AppBar(title: Text(state.user?.name ?? 'User')), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(state.user?.email ?? ''), + Text(state.user?.phone ?? ''), + const SizedBox(height: 24), + ElevatedButton( + onPressed: () => _confirmDelete(context, bloc), + child: const Text('Delete User'), + ), + ], + ), + ), + ); + } + + void _confirmDelete(BuildContext context, UserCubit bloc) { + showNativeDialog( + context, + title: 'Delete User', + content: 'Are you sure you want to delete this user? This action cannot be undone.', + actions: [ + DialogAction( + text: 'Cancel', + onTap: () {}, + ), + DialogAction( + text: 'Delete', + onTap: () => bloc.deleteUser(), + isDestructiveAction: true, + textStyle: const TextStyle(color: Colors.red), + ), + ], + ); + } +} +``` + +### Step 5: Navigate to the Page + +```dart +// From another widget: +Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => UserPage( + onCreateCubit: (context) => getIt()..loadUser('123'), + ), + ), +); +``` + +Or if the Cubit is already provided higher in the tree: + +```dart +// No onCreateBloc needed -- Cubit already exists in widget tree +class UserDetailPage extends BlocfulWidget { + const UserDetailPage({super.key}) : super(onCreateBloc: null); + + @override + Widget builder(BuildContext context, UserCubit bloc, UserState state) { + return Text(state.user?.name ?? ''); + } +} +``` + +## Complete Example + +A full feature with Cubit, events, state, and page: + +```dart +// feature/profile/presentation/cubit/profile_event.dart +sealed class ProfileEvent { + String get message; +} + +class ProfileLoaded implements ProfileEvent { + const ProfileLoaded(); + @override + String get message => 'Profile loaded'; +} + +class ProfileUpdateSuccess implements ProfileEvent { + const ProfileUpdateSuccess(); + @override + String get message => 'Profile updated successfully'; +} + +class ProfileError implements ProfileEvent { + const ProfileError(this.error); + final String error; + @override + String get message => error; +} +``` + +```dart +// feature/profile/presentation/cubit/profile_cubit.dart +import 'package:bloc_presentation/bloc_presentation.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'profile_cubit.freezed.dart'; +part 'profile_state.dart'; + +class ProfileCubit extends Cubit + with BlocPresentationMixin { + ProfileCubit(this._repository) : super(const ProfileState()); + + final ProfileRepository _repository; + + Future loadProfile() async { + emit(state.copyWith(isLoading: true)); + final result = await _repository.getProfile(); + result.when( + success: (profile) { + emit(state.copyWith(profile: profile, isLoading: false)); + emitPresentation(const ProfileLoaded()); + }, + error: (error) { + emit(state.copyWith(isLoading: false)); + emitPresentation(ProfileError(error.toString())); + }, + ); + } + + Future updateName(String name) async { + final result = await _repository.updateProfile(name: name); + result.when( + success: (profile) { + emit(state.copyWith(profile: profile)); + emitPresentation(const ProfileUpdateSuccess()); + }, + error: (error) => emitPresentation(ProfileError(error.toString())), + ); + } +} +``` + +```dart +// feature/profile/presentation/cubit/profile_state.dart +part of 'profile_cubit.dart'; + +@freezed +sealed class ProfileState with _$ProfileState { + const factory ProfileState({ + Profile? profile, + @Default(false) bool isLoading, + }) = _ProfileState; +} +``` + +```dart +// feature/profile/presentation/profile_page.dart +import 'package:dcc_toolkit/dcc_toolkit.dart'; +import 'package:dcc_toolkit/ui/blocful_widget.dart'; +import 'package:dcc_toolkit/ui/native_dialog.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class ProfilePage extends BlocfulWidget { + const ProfilePage({super.key}) + : super(onCreateBloc: _createCubit); + + static ProfileCubit _createCubit(BuildContext context) => + context.read()..loadProfile(); + + @override + void onPresentationEvent(BuildContext context, ProfileEvent event) { + switch (event) { + case ProfileLoaded(): + // No action needed for initial load + break; + case ProfileUpdateSuccess(): + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(event.message)), + ); + case ProfileError(:final error): + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(error), + backgroundColor: context.colors.error, + ), + ); + } + } + + @override + Widget builder(BuildContext context, ProfileCubit bloc, ProfileState state) { + if (state.isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + final profile = state.profile; + if (profile == null) { + return const Scaffold(body: Center(child: Text('No profile data'))); + } + + return Scaffold( + appBar: AppBar(title: const Text('Profile')), + body: Padding( + padding: Paddings.all16, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(profile.name, style: context.katjasBoekwerk.titleLarge), + Margins.vertical8, + Text(profile.email, style: context.katjasBoekwerk.bodyMedium), + Margins.vertical24, + ElevatedButton( + onPressed: () => _showEditDialog(context, bloc, profile), + child: const Text('Edit Name'), + ), + ], + ), + ), + ); + } + + void _showEditDialog(BuildContext context, ProfileCubit bloc, Profile profile) { + final controller = TextEditingController(text: profile.name); + + showNativeDialog( + context, + title: 'Edit Name', + content: 'Enter a new name:', + actions: [ + DialogAction(text: 'Cancel', onTap: () {}), + DialogAction( + text: 'Save', + onTap: () => bloc.updateName(controller.text), + ), + ], + ); + } +} +``` + +## Common Patterns + +### showNativeDialog with Return Values + +`showNativeDialog` returns `Future`, so you can get results from dialog actions: + +```dart +final confirmed = await showNativeDialog( + context, + title: 'Confirm', + content: 'Proceed with payment?', + actions: [ + DialogAction(text: 'No', onTap: () => false), + DialogAction(text: 'Yes', onTap: () => true), + ], +); + +if (confirmed == true) { + bloc.processPayment(); +} +``` + +### Testing Presentation Events + +Use the toolkit's `catchEventIn()` helper: + +```dart +import 'package:dcc_toolkit/dcc_toolkit.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('emits UserLoaded on successful fetch', () async { + final cubit = UserCubit(mockRepository); + final events = []; + catchEventIn(cubit, events); + + await cubit.loadUser('123'); + + expect(events, contains(isA())); + }); +} +``` + +### Combining BlocfulWidget with BlocfulWidget (Nested Blocs) + +If a page needs multiple Cubits, nest `BlocProvider`s above the `BlocfulWidget`: + +```dart +class OrderPage extends StatelessWidget { + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: [ + BlocProvider(create: (_) => getIt()), + ], + child: const _OrderContent(), + ); + } +} + +class _OrderContent extends BlocfulWidget { + const _OrderContent() : super(onCreateBloc: _create); + + static OrderCubit _create(BuildContext context) => getIt()..load(); + + @override + Widget builder(BuildContext context, OrderCubit bloc, OrderState state) { + // Can also access CartCubit here via context.read() + return Text('Order: ${state.orderId}'); + } +} +``` + +## Feedback Loop + +After implementing: + +1. Run `dart analyze` -- ensure no lint errors. +2. Verify the type parameters are correct: `BlocfulWidget`. +3. Run the app: + - Confirm the page renders with the initial state. + - Trigger a presentation event (e.g., load data) -- verify snackbar/navigation fires. + - Verify events are one-shot (navigating away and back doesn't replay them). +4. If using `showNativeDialog()`: + - Test on iOS simulator -- confirm `CupertinoAlertDialog` appears. + - Test on Android emulator -- confirm `AlertDialog` appears. + - Verify dialog auto-closes after tapping an action. +5. Run unit tests for the Cubit: + - Test state emissions with `bloc_test` or manual `expect` on `cubit.state`. + - Test presentation events with `catchEventIn()`. diff --git a/skills/dcc-create-paginated-cubit/SKILL.md b/skills/dcc-create-paginated-cubit/SKILL.md new file mode 100644 index 0000000..9828dff --- /dev/null +++ b/skills/dcc-create-paginated-cubit/SKILL.md @@ -0,0 +1,470 @@ +--- +name: dcc-create-paginated-cubit +description: Scaffold a paginated Cubit using PaginationMixin and PaginationState with PaginatedScrollView and PaginationStateView widgets. Use when adding pagination, implementing infinite scroll, loading more items on scroll, or creating a paginated list. +metadata: + last_modified: 2025-06-18 +--- + +# Create a Paginated Cubit + +## Contents + +- [Overview](#overview) +- [Prerequisites](#prerequisites) +- [Core Concepts](#core-concepts) +- [Workflow](#workflow) +- [Complete Example](#complete-example) +- [Common Patterns](#common-patterns) +- [Feedback Loop](#feedback-loop) + +## Overview + +The DCC pagination module provides a complete solution for paginated lists: + +1. **`PaginationState`** -- immutable state holding items, page info, loading/error flags +2. **`PaginationInterface`** -- interface your Cubit state must implement +3. **`PaginationMixin`** -- mixin on `Cubit` that handles `loadNextPage()` logic +4. **`PaginatedScrollView`** -- widget that detects scroll-to-bottom and triggers loading +5. **`PaginationStateView`** -- widget that switches between loading/empty/error/success states + +The mixin handles page advancement, item appending, and loading state automatically. You only implement `fetchPageItems()` and `initializeState()`. + +## Prerequisites + +```dart +// From the main barrel file +import 'package:dcc_toolkit/dcc_toolkit.dart'; + +// PaginationStateView is NOT exported from the barrel -- import directly +import 'package:dcc_toolkit/pagination/pagination_state_view.dart'; +``` + +Dependencies required in the consuming project: +- `flutter_bloc` (for `Cubit`) +- `dcc_toolkit` + +## Core Concepts + +| Class/Mixin | Role | +|-------------|------| +| `PaginationState` | Immutable state: `items`, `currentPage`, `lastPage`, `isLoading`, `loadingInitialPage`, `hasError`, `total`, `searchQuery`. Getter: `hasNextPage` | +| `PaginationInterface` | Interface with single getter: `PaginationState get paginationState` | +| `PaginationMixin` | Mixin on `Cubit>`. Provides `loadNextPage()`. Requires implementing `fetchPageItems()` and `initializeState()` | +| `PaginatedScrollView` | `CustomScrollView` with scroll detection. Props: `state`, `itemBuilder`, `onLoadMore`, `topWidget`, `bottomWidget` | +| `PaginationStateView` | State-switching widget. Props: `state`, `loadingWidget`, `emptyWidget`, `errorWidget`, `builder`, `onRefresh` | + +### PaginationState Fields + +| Field | Type | Default | Purpose | +|-------|------|---------|---------| +| `items` | `List` | `[]` | Accumulated items from all loaded pages | +| `currentPage` | `int` | `1` | Current loaded page number | +| `lastPage` | `int` | `1` | Total number of pages available | +| `isLoading` | `bool` | `false` | Whether the next page is currently loading | +| `loadingInitialPage` | `bool` | `true` | Whether the first page is loading | +| `hasError` | `bool` | `false` | Whether an error occurred | +| `total` | `int` | `0` | Total item count from the API | +| `searchQuery` | `String?` | `null` | Active search filter | + +### How `loadNextPage()` Works + +The mixin's `loadNextPage(emitState)` method: +1. Checks `currentPage < lastPage` -- if not, does nothing +2. Sets `isLoading: true` via the `emitState` callback +3. Calls your `fetchPageItems(page: nextPage, searchQuery: ...)` +4. If items returned are non-empty, appends them to existing items, advances `currentPage`, sets `isLoading: false` + +**Important**: `loadNextPage()` takes an `emitState` callback (not `emit()` directly) because it updates the pagination sub-state, not the full Cubit state. You must bridge this to your actual `emit()`. + +## Workflow + +**Task Progress:** +- [ ] 1. Create Cubit state class implementing `PaginationInterface` +- [ ] 2. Create Cubit with `PaginationMixin` +- [ ] 3. Implement `fetchPageItems()` to call the repository/API +- [ ] 4. Implement `initializeState()` to load the first page +- [ ] 5. Build UI with `PaginationStateView` and `PaginatedScrollView` +- [ ] 6. Wire `onLoadMore` to the Cubit's `loadNextPage()` +- [ ] 7. Verify with `dart analyze` and tests + +### Step 1: Create State Class + +Your Cubit state must implement `PaginationInterface`: + +```dart +import 'package:dcc_toolkit/dcc_toolkit.dart'; +import 'package:flutter/foundation.dart'; + +@immutable +class ProductListState implements PaginationInterface { + const ProductListState({ + this.paginationState = const PaginationState(), + }); + + @override + final PaginationState paginationState; + + ProductListState copyWith({ + PaginationState? paginationState, + }) { + return ProductListState( + paginationState: paginationState ?? this.paginationState, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ProductListState && other.paginationState == paginationState; + + @override + int get hashCode => paginationState.hashCode; +} +``` + +### Step 2: Create Cubit with PaginationMixin + +```dart +import 'package:dcc_toolkit/dcc_toolkit.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class ProductListCubit extends Cubit + with PaginationMixin { + ProductListCubit(this._repository) : super(const ProductListState()); + + final ProductRepository _repository; + + @override + Future?> fetchPageItems({ + required int page, + String? searchQuery, + }) async { + final result = await _repository.getProducts(page: page, query: searchQuery); + return result.when( + success: (response) => response.items, + error: (_) => null, // Return null to signal failure + ); + } + + @override + Future initializeState({String? searchQuery}) async { + emit(state.copyWith( + paginationState: const PaginationState(loadingInitialPage: true), + )); + + final result = await _repository.getProducts(page: 1, query: searchQuery); + result.when( + success: (response) { + emit(state.copyWith( + paginationState: PaginationState( + items: response.items, + currentPage: 1, + lastPage: response.lastPage, + total: response.total, + loadingInitialPage: false, + searchQuery: searchQuery, + ), + )); + }, + error: (_) { + emit(state.copyWith( + paginationState: const PaginationState( + hasError: true, + loadingInitialPage: false, + ), + )); + }, + ); + } + + void onLoadMore() { + loadNextPage((paginationState) { + emit(state.copyWith(paginationState: paginationState)); + }); + } + + void search(String query) { + initializeState(searchQuery: query); + } +} +``` + +### Step 3: Build the UI + +```dart +import 'package:dcc_toolkit/dcc_toolkit.dart'; +import 'package:dcc_toolkit/pagination/pagination_state_view.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class ProductListPage extends StatelessWidget { + const ProductListPage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => ProductListCubit(context.read()) + ..initializeState(), + child: const _ProductListView(), + ); + } +} + +class _ProductListView extends StatelessWidget { + const _ProductListView(); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Products')), + body: BlocBuilder( + builder: (context, state) { + return PaginationStateView( + state: state.paginationState, + loadingWidget: const Center(child: CircularProgressIndicator()), + emptyWidget: const Center(child: Text('No products found')), + errorWidget: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('Something went wrong'), + ElevatedButton( + onPressed: () => context.read().initializeState(), + child: const Text('Retry'), + ), + ], + ), + ), + onRefresh: () => context.read().initializeState(), + builder: (context) => PaginatedScrollView( + state: state.paginationState, + onLoadMore: () => context.read().onLoadMore(), + itemBuilder: (context, product) => ListTile( + title: Text(product.name), + subtitle: Text(product.description), + ), + ), + ); + }, + ), + ); + } +} +``` + +## Complete Example + +Full feature from state to UI: + +```dart +// product_list_state.dart +import 'package:dcc_toolkit/dcc_toolkit.dart'; +import 'package:flutter/foundation.dart'; + +@immutable +class ProductListState implements PaginationInterface { + const ProductListState({ + this.paginationState = const PaginationState(), + }); + + @override + final PaginationState paginationState; + + ProductListState copyWith({PaginationState? paginationState}) => + ProductListState(paginationState: paginationState ?? this.paginationState); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ProductListState && other.paginationState == paginationState; + + @override + int get hashCode => paginationState.hashCode; +} +``` + +```dart +// product_list_cubit.dart +import 'package:dcc_toolkit/dcc_toolkit.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class ProductListCubit extends Cubit + with PaginationMixin { + ProductListCubit(this._repository) : super(const ProductListState()); + + final ProductRepository _repository; + + @override + Future?> fetchPageItems({ + required int page, + String? searchQuery, + }) async { + final result = await _repository.getProducts(page: page, query: searchQuery); + return result.getOrNull?.items; + } + + @override + Future initializeState({String? searchQuery}) async { + emit(state.copyWith( + paginationState: const PaginationState(loadingInitialPage: true), + )); + + final result = await _repository.getProducts(page: 1, query: searchQuery); + result.when( + success: (response) => emit(state.copyWith( + paginationState: PaginationState( + items: response.items, + currentPage: 1, + lastPage: response.lastPage, + total: response.total, + loadingInitialPage: false, + searchQuery: searchQuery, + ), + )), + error: (_) => emit(state.copyWith( + paginationState: const PaginationState(hasError: true, loadingInitialPage: false), + )), + ); + } + + void onLoadMore() { + loadNextPage((paginationState) { + emit(state.copyWith(paginationState: paginationState)); + }); + } +} +``` + +```dart +// product_list_page.dart +import 'package:dcc_toolkit/dcc_toolkit.dart'; +import 'package:dcc_toolkit/pagination/pagination_state_view.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class ProductListPage extends StatelessWidget { + const ProductListPage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => ProductListCubit(context.read())..initializeState(), + child: Scaffold( + appBar: AppBar(title: const Text('Products')), + body: BlocBuilder( + builder: (context, state) => PaginationStateView( + state: state.paginationState, + loadingWidget: const Center(child: CircularProgressIndicator()), + emptyWidget: const Center(child: Text('No products found')), + errorWidget: const Center(child: Text('Error loading products')), + onRefresh: () => context.read().initializeState(), + builder: (context) => PaginatedScrollView( + state: state.paginationState, + onLoadMore: () => context.read().onLoadMore(), + itemBuilder: (context, product) => ListTile( + title: Text(product.name), + subtitle: Text('\$${product.price}'), + leading: CircleAvatar(child: Text(product.name[0])), + ), + ), + ), + ), + ), + ); + } +} +``` + +## Common Patterns + +### Adding Search to Pagination + +Use `searchQuery` in `PaginationState` and re-initialize when the query changes: + +```dart +// In your Cubit: +void search(String query) { + initializeState(searchQuery: query.isEmpty ? null : query); +} + +// The searchQuery is automatically passed to fetchPageItems() by loadNextPage() +``` + +Debounce search input using the toolkit's `Debouncer`: + +```dart +import 'package:dcc_toolkit/debouncer/debouncer.dart'; + +class ProductListCubit extends Cubit + with PaginationMixin { + ProductListCubit(this._repository) : super(const ProductListState()); + + final ProductRepository _repository; + final _debouncer = Debouncer(); // 400ms default + + void onSearchChanged(String query) { + _debouncer.run(() => initializeState(searchQuery: query.isEmpty ? null : query)); + } + + @override + Future close() { + _debouncer.dispose(); + return super.close(); + } + // ... rest of implementation +} +``` + +### Adding a Top or Bottom Widget to PaginatedScrollView + +```dart +PaginatedScrollView( + state: state.paginationState, + onLoadMore: () => cubit.onLoadMore(), + topWidget: Padding( + padding: Paddings.all16, + child: Text('${state.paginationState.total} products'), + ), + bottomWidget: const SizedBox(height: 80), // Extra space above FAB + itemBuilder: (context, product) => ProductTile(product: product), +) +``` + +### Using PaginationState with Freezed + +If your state uses `freezed`, implement `PaginationInterface` on the generated class: + +```dart +@freezed +sealed class ProductListState with _$ProductListState implements PaginationInterface { + const factory ProductListState({ + @Default(PaginationState()) PaginationState paginationState, + }) = _ProductListState; +} +``` + +### Scroll Detection Behavior + +`PaginatedScrollView` triggers `onLoadMore` when: +- `metrics.extentAfter == metrics.minScrollExtent` (user reached the bottom) +- `state.hasNextPage` is `true` +- `state.isLoading` is `false` + +It automatically shows a `CircularProgressIndicator` at the bottom when `hasNextPage` is true. + +## Feedback Loop + +After implementing: + +1. Run `dart analyze` -- ensure no lint errors. +2. Run `flutter test` -- verify pagination logic with unit tests: + - Test `loadNextPage()` appends items and advances page + - Test `loadNextPage()` does nothing when `currentPage >= lastPage` + - Test `initializeState()` resets state and loads first page +3. Run the app -- verify: + - Initial loading state appears + - Items render after first page loads + - Scrolling to bottom triggers next page load + - Progress indicator appears while loading next page + - Pull-to-refresh works (if `onRefresh` is set) + - Empty and error states display correctly diff --git a/skills/dcc-setup-bolt-logger/SKILL.md b/skills/dcc-setup-bolt-logger/SKILL.md new file mode 100644 index 0000000..ca27531 --- /dev/null +++ b/skills/dcc-setup-bolt-logger/SKILL.md @@ -0,0 +1,317 @@ +--- +name: dcc-setup-bolt-logger +description: Set up and configure BoltLogger for structured logging with charges (DebugConsole, File, Memory). Use when adding logging, setting up error tracking, adding an in-app log viewer, or bootstrapping a Flutter app with error handling. +metadata: + last_modified: 2025-06-18 +--- + +# Set Up BoltLogger + +## Contents + +- [Overview](#overview) +- [Prerequisites](#prerequisites) +- [Core Concepts](#core-concepts) +- [Workflow](#workflow) +- [Complete Example](#complete-example) +- [Common Patterns](#common-patterns) +- [Feedback Loop](#feedback-loop) + +## Overview + +BoltLogger is the DCC toolkit's structured logging system built on top of the `logging` package. It uses a "charge" architecture where output backends (charges) can be plugged in independently. The system supports: + +- **DebugConsoleCharge** -- prints to the debug console with ANSI color for errors (only in debug mode) +- **FileCharge** -- writes logs to a file with buffering and periodic flushing +- **MemoryCharge** -- stores logs in memory for display via `BoltLoggerView` +- **ZapExtension** -- adds `zap()` and `shock()` methods to any object +- **runAppBootstrap()** -- wraps your app in error handling zones that auto-log with BoltLogger + +## Prerequisites + +The project must depend on `dcc_toolkit`. BoltLogger is exported from the main barrel file: + +```dart +import 'package:dcc_toolkit/dcc_toolkit.dart'; +``` + +For `runAppBootstrap()` (not in the barrel), import directly: + +```dart +import 'package:dcc_toolkit/common/run_app_bootstrap.dart'; +``` + +## Core Concepts + +| Class | Role | +|-------|------| +| `BoltLogger` | Singleton logger. Static methods: `charge()`, `zap()`, `shock()`, `discharge()`, `getCharge()` | +| `BoltCharge` | Interface for log output backends. Requires `name`, `logOutput(ZapEvent)`, `discharge()` | +| `DebugConsoleCharge` | Prints logs via `debugPrint`. ANSI red for errors. Only active in `kDebugMode` | +| `FileCharge` | Writes to `{path}/{yyyy-MM-dd}.log`. Buffers up to `bufferSize` lines, flushes every `writeDelay` | +| `MemoryCharge` | Stores up to `maxItems` events in memory. Exposes `stream` and `items` for UI display | +| `ZapEvent` | Wraps a `LogRecord` with pre-formatted `lines` (List) | +| `ZapExtension` | Extension on `Object` adding `zap()` and `shock()` using `runtimeType` as tag | +| `ZapStackTraceExtension` | Extension on `StackTrace` with `strike` getter for cleaned formatting | +| `BoltLoggerView` | Widget that renders in-app logs from a `MemoryCharge` via `StreamBuilder` + `ListView` | +| `runAppBootstrap()` | Runs app inside `runZonedGuarded` with `FlutterError.onError`, defaults to `BoltLogger.shock()` | + +### Log Levels + +- `BoltLogger.zap(message)` -- logs at `Level.INFO` (general information) +- `BoltLogger.shock(message)` -- logs at `Level.SEVERE` (errors, exceptions) + +### Message Types + +The `message` parameter accepts: +- `String` -- simple text +- `Exception` or `Error` -- logged as the error field +- `StackTrace` -- logged as the stacktrace field +- `List` -- a list containing one of each (Object?, Exception/Error, StackTrace) + +## Workflow + +**Task Progress:** +- [ ] 1. Set up `runAppBootstrap()` in `main.dart` +- [ ] 2. Configure charges based on environment +- [ ] 3. Add logging calls (`zap`/`shock`) to business logic +- [ ] 4. (Optional) Add `BoltLoggerView` for in-app log viewing +- [ ] 5. Verify logs appear in console/file/viewer + +### Step 1: Set Up `runAppBootstrap()` + +Replace your `main()` function to use `runAppBootstrap()`. This wraps the app in `runZonedGuarded` and catches all Flutter errors automatically. + +```dart +import 'package:dcc_toolkit/common/run_app_bootstrap.dart'; +import 'package:dcc_toolkit/dcc_toolkit.dart'; +import 'package:flutter/material.dart'; + +Future main() async { + await runAppBootstrap(() async { + // Initialize charges BEFORE building the app + BoltLogger.charge([ + const DebugConsoleCharge(), + // Add more charges as needed + ]); + + // Any other initialization (DI, etc.) + return const MyApp(); + }); +} +``` + +If you need custom error handling in addition to BoltLogger: + +```dart +Future main() async { + await runAppBootstrap( + () async { + BoltLogger.charge([const DebugConsoleCharge()]); + return const MyApp(); + }, + onError: (error, stackTrace) { + BoltLogger.shock([error, stackTrace]); + // Additional error reporting (e.g., Sentry, Crashlytics) + }, + ); +} +``` + +### Step 2: Configure Charges + +Choose charges based on the app's needs: + +**Debug only (most common starting point):** +```dart +BoltLogger.charge([const DebugConsoleCharge()]); +``` + +**Debug + file logging (for QA builds):** +```dart +BoltLogger.charge([ + const DebugConsoleCharge(), + FileCharge( + '/path/to/logs', // e.g., from path_provider: getApplicationDocumentsDirectory() + bufferSize: 500, + writeDelay: const Duration(seconds: 10), + ), +]); +``` + +**Debug + in-app viewer (for dev builds):** +```dart +BoltLogger.charge([ + const DebugConsoleCharge(), + MemoryCharge(maxItems: 500), +]); +``` + +**All three (full setup):** +```dart +BoltLogger.charge([ + const DebugConsoleCharge(), + FileCharge(documentsPath), + MemoryCharge(), +]); +``` + +### Step 3: Add Logging Calls + +**Using static methods (anywhere):** +```dart +BoltLogger.zap('User logged in', tag: 'AuthService'); +BoltLogger.shock(['Payment failed', exception, stackTrace], tag: 'PaymentService'); +``` + +**Using the ZapExtension (inside classes):** +```dart +class UserRepository { + Future fetchUser() async { + zap('Fetching user...'); // tag = 'UserRepository' (from runtimeType) + try { + // ... + } catch (e, s) { + shock([e, s]); // tag = 'UserRepository' + } + } +} +``` + +### Step 4: Add BoltLoggerView (Optional) + +Create a debug page that shows logs in-app: + +```dart +class DebugLogPage extends StatelessWidget { + const DebugLogPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Logs')), + body: const BoltLoggerView(), + ); + } +} +``` + +`BoltLoggerView` automatically finds the registered `MemoryCharge`. If one does not exist, it creates and registers one. + +### Step 5: Cleanup on App Disposal + +Call `discharge()` when the app is shutting down (e.g., in a top-level widget's `dispose`): + +```dart +BoltLogger.discharge(); // Cancels subscription, flushes FileCharge, closes MemoryCharge stream +``` + +## Complete Example + +```dart +// main.dart +import 'package:dcc_toolkit/common/run_app_bootstrap.dart'; +import 'package:dcc_toolkit/dcc_toolkit.dart'; +import 'package:flutter/material.dart'; +import 'package:path_provider/path_provider.dart'; + +Future main() async { + await runAppBootstrap(() async { + final docsDir = await getApplicationDocumentsDirectory(); + + BoltLogger.charge([ + const DebugConsoleCharge(), + FileCharge('${docsDir.path}/logs'), + MemoryCharge(maxItems: 1000), + ]); + + BoltLogger.zap('App starting up', tag: 'Bootstrap'); + + return const MyApp(); + }); +} +``` + +```dart +// some_cubit.dart +import 'package:dcc_toolkit/dcc_toolkit.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SomeCubit extends Cubit { + SomeCubit(this._repository) : super(const SomeState.initial()); + + final SomeRepository _repository; + + Future loadData() async { + zap('Loading data...'); // Uses ZapExtension, tag = 'SomeCubit' + + final result = await _repository.getData(); + result.when( + success: (data) { + zap('Loaded ${data.length} items'); + emit(state.copyWith(items: data)); + }, + error: (error) { + shock(['Failed to load data', error]); + emit(state.copyWith(hasError: true)); + }, + ); + } +} +``` + +## Common Patterns + +### Custom BoltCharge Implementation + +Create a custom charge to send logs to an external service: + +```dart +class CrashlyticsCharge implements BoltCharge { + @override + String get name => 'CrashlyticsCharge'; + + @override + void logOutput(ZapEvent event) { + if (event.origin.level.value >= Level.SEVERE.value) { + FirebaseCrashlytics.instance.recordError( + event.origin.error, + event.origin.stackTrace, + ); + } + } + + @override + void discharge() {} +} +``` + +### Retrieving a Specific Charge + +```dart +final memoryCharge = BoltLogger.getCharge('MemoryCharge') as MemoryCharge?; +final allLogs = memoryCharge?.items ?? []; +``` + +### Formatted Stack Traces + +Use the `ZapStackTraceExtension` to clean up stack traces: + +```dart +try { + // ... +} catch (e, stackTrace) { + final cleanTrace = stackTrace.strike; // Formatted, single-spaced lines + BoltLogger.shock([e, stackTrace]); +} +``` + +## Feedback Loop + +After implementing: + +1. Run `dart analyze` -- ensure no lint errors from the new logging code. +2. Run the app in debug mode -- confirm logs appear in the debug console with the `⚡[HH:mm] I/Tag: message` format. +3. If using `FileCharge` -- verify the log file is created at the expected path. +4. If using `BoltLoggerView` -- navigate to the debug page and confirm logs are streaming. +5. Trigger an error -- confirm `shock()` messages appear in red (ANSI terminals) and include error + stack trace. diff --git a/skills/dcc-setup-kleurplaat-boekwerk/SKILL.md b/skills/dcc-setup-kleurplaat-boekwerk/SKILL.md new file mode 100644 index 0000000..283a6d4 --- /dev/null +++ b/skills/dcc-setup-kleurplaat-boekwerk/SKILL.md @@ -0,0 +1,538 @@ +--- +name: dcc-setup-kleurplaat-boekwerk +description: Create and configure a custom design system using KatjasKleurplaat (colors) and KatjasBoekwerk (typography) theme extensions. Use when setting up theming, adding a color palette, configuring typography, or integrating the DCC design system into a Flutter app. +metadata: + last_modified: 2025-06-18 +--- + +# Set Up Kleurplaat & Boekwerk Design System + +## Contents + +- [Overview](#overview) +- [Prerequisites](#prerequisites) +- [Core Concepts](#core-concepts) +- [Workflow](#workflow) +- [Complete Example](#complete-example) +- [Common Patterns](#common-patterns) +- [Feedback Loop](#feedback-loop) + +## Overview + +The DCC design system uses a two-part architecture with Dutch naming: + +- **Kleurplaat** ("coloring page") -- the color system. `KatjasKleurplaat` is a `ThemeExtension` that defines color groups (primary, error, success, content, surface) with contrast/subtle variants. +- **Boekwerk** ("literary work") -- the typography system. `KatjasBoekwerk` is a `ThemeExtension` that defines a full typography scale using `Handschrift` (extended `TextStyle` with built-in `.bold` and `.link` variants). +- **BoekwerkDecorator** -- combines both systems, producing pre-colored text styles via `HandschriftDecorator`. + +Both are registered as Flutter `ThemeExtension`s and accessed via `BuildContext` extensions. + +## Prerequisites + +```dart +import 'package:dcc_toolkit/dcc_toolkit.dart'; +``` + +For `KatjasBoekwerk` and `Handschrift` (not in barrel file), import directly: + +```dart +import 'package:dcc_toolkit/style/text_style/katjas_boekwerk.dart'; +import 'package:dcc_toolkit/style/text_style/handschrift.dart'; +``` + +## Core Concepts + +### Color System + +| Class | Role | +|-------|------| +| `KatjasKleurplaat` | `ThemeExtension`. Holds all color groups. Supports `copyWith()`, `lerp()`, `toColorScheme()` | +| `ColorGroup` | A group of 2-3 colors: `color` (the color itself), `onColorContrast` (foreground), `onColorSubtle` (optional subtle foreground) | +| `SurfaceGroup` | Surface colors with 5 contrast levels, 5 container levels, link color, and optional error/success/primary surface colors | + +#### Required Color Groups + +| Group | Purpose | +|-------|---------| +| `primary` / `primaryFill` | Brand primary color and its filled variant | +| `content` / `contentFill` | Main content/text colors | +| `error` / `errorFill` | Error state colors | +| `success` / `successFill` | Success state colors | +| `surface` | Background and container colors | + +#### Optional Color Groups + +| Group | Purpose | +|-------|---------| +| `secondary` / `secondaryFill` | Secondary brand color | +| `tertiary` / `tertiaryFill` | Tertiary accent | +| `accent` / `accentFill` | Additional accent | +| `surfaceInverse` | Inverse surface (e.g., dark mode surface in light theme) | + +### Typography System + +| Class | Role | +|-------|------| +| `Handschrift` | Extended `TextStyle` with `.bold` and `.link` getters that merge variant styles | +| `KatjasBoekwerk` | `ThemeExtension`. Full typography scale (display/subtitle/headline/title/body/label/navbar) using `Handschrift` | +| `BoekwerkDecorator` | Combines `KatjasBoekwerk` + `KatjasKleurplaat` to produce `HandschriftDecorator` instances | +| `HandschriftDecorator` | A single `Handschrift` colored by all palette groups. Access: `.primary.color`, `.surface.onColorContrast`, etc. | + +### BuildContext Extensions + +| Extension | Access | +|-----------|--------| +| `context.theme` | `ThemeData` | +| `context.colors` | `ColorScheme` | +| `context.katjasKleurPlaat` | `KatjasKleurplaat` instance | +| `context.katjasBoekwerk` | `KatjasBoekwerk` instance | +| `context.katjasBoekwerkDecorator` | `BoekwerkDecorator` (pre-colored text styles) | + +## Workflow + +**Task Progress:** +- [ ] 1. Define color palette (`KatjasKleurplaat`) +- [ ] 2. Define typography scale (`KatjasBoekwerk`) +- [ ] 3. Register both as `ThemeExtension`s on `ThemeData` +- [ ] 4. Access colors and typography in widgets via `BuildContext` extensions +- [ ] 5. (Optional) Use `BoekwerkDecorator` for pre-colored text styles +- [ ] 6. Verify with `dart analyze` + +### Step 1: Define Color Palette + +Create a file (e.g., `lib/core/theme/app_kleurplaat.dart`): + +```dart +import 'package:dcc_toolkit/dcc_toolkit.dart'; +import 'package:flutter/material.dart'; + +final appKleurplaatLight = KatjasKleurplaat( + primary: const ColorGroup( + color: Color(0xFF1A73E8), + onColorContrast: Color(0xFFFFFFFF), + onColorSubtle: Color(0xFFE8F0FE), + ), + primaryFill: const ColorGroup( + color: Color(0xFFE8F0FE), + onColorContrast: Color(0xFF1A73E8), + ), + content: const ColorGroup( + color: Color(0xFF1C1B1F), + onColorContrast: Color(0xFFFFFFFF), + onColorSubtle: Color(0xFF49454F), + ), + contentFill: const ColorGroup( + color: Color(0xFFE6E1E5), + onColorContrast: Color(0xFF1C1B1F), + ), + error: const ColorGroup( + color: Color(0xFFB3261E), + onColorContrast: Color(0xFFFFFFFF), + ), + errorFill: const ColorGroup( + color: Color(0xFFF9DEDC), + onColorContrast: Color(0xFFB3261E), + ), + success: const ColorGroup( + color: Color(0xFF198754), + onColorContrast: Color(0xFFFFFFFF), + ), + successFill: const ColorGroup( + color: Color(0xFFD1E7DD), + onColorContrast: Color(0xFF198754), + ), + surface: const SurfaceGroup( + color: Color(0xFFFFFBFE), + onColorContrast: Color(0xFF1C1B1F), + onColorContrastDim: Color(0xFF49454F), + onColorSubtle: Color(0xFF79747E), + onColorSubtleDim: Color(0xFFADA8B2), + containerLowest: Color(0xFFFFFFFF), + containerLow: Color(0xFFF7F2FA), + container: Color(0xFFF3EDF7), + containerHigh: Color(0xFFECE6F0), + containerHighest: Color(0xFFE6E0E9), + link: Color(0xFF1A73E8), + onColorError: Color(0xFFB3261E), + onColorSuccess: Color(0xFF198754), + onColorPrimary: Color(0xFF1A73E8), + ), + surfaceInverse: const SurfaceGroup( + color: Color(0xFF313033), + onColorContrast: Color(0xFFE6E1E5), + onColorContrastDim: Color(0xFFCAC4D0), + onColorSubtle: Color(0xFF938F99), + onColorSubtleDim: Color(0xFF79747E), + containerLowest: Color(0xFF1C1B1F), + containerLow: Color(0xFF2B2930), + container: Color(0xFF313033), + containerHigh: Color(0xFF3B383E), + containerHighest: Color(0xFF484549), + link: Color(0xFF8AB4F8), + ), +); +``` + +### Step 2: Define Typography Scale + +Create a file (e.g., `lib/core/theme/app_boekwerk.dart`): + +```dart +import 'package:dcc_toolkit/style/text_style/handschrift.dart'; +import 'package:dcc_toolkit/style/text_style/katjas_boekwerk.dart'; +import 'package:flutter/material.dart'; + +final appBoekwerk = KatjasBoekwerk( + displayLarge: const Handschrift( + fontSize: 57, + fontWeight: FontWeight.w400, + letterSpacing: -0.25, + height: 1.12, + boldStyle: TextStyle(fontWeight: FontWeight.w700), + linkStyle: TextStyle(decoration: TextDecoration.underline), + ), + displayMedium: const Handschrift( + fontSize: 45, + fontWeight: FontWeight.w400, + height: 1.16, + boldStyle: TextStyle(fontWeight: FontWeight.w700), + linkStyle: TextStyle(decoration: TextDecoration.underline), + ), + displaySmall: const Handschrift( + fontSize: 36, + fontWeight: FontWeight.w400, + height: 1.22, + boldStyle: TextStyle(fontWeight: FontWeight.w700), + linkStyle: TextStyle(decoration: TextDecoration.underline), + ), + headlineLarge: const Handschrift( + fontSize: 32, + fontWeight: FontWeight.w400, + height: 1.25, + boldStyle: TextStyle(fontWeight: FontWeight.w700), + linkStyle: TextStyle(decoration: TextDecoration.underline), + ), + headlineMedium: const Handschrift( + fontSize: 28, + fontWeight: FontWeight.w400, + height: 1.29, + boldStyle: TextStyle(fontWeight: FontWeight.w700), + linkStyle: TextStyle(decoration: TextDecoration.underline), + ), + headlineSmall: const Handschrift( + fontSize: 24, + fontWeight: FontWeight.w400, + height: 1.33, + boldStyle: TextStyle(fontWeight: FontWeight.w700), + linkStyle: TextStyle(decoration: TextDecoration.underline), + ), + titleLarge: const Handschrift( + fontSize: 22, + fontWeight: FontWeight.w500, + height: 1.27, + boldStyle: TextStyle(fontWeight: FontWeight.w700), + linkStyle: TextStyle(decoration: TextDecoration.underline), + ), + titleMedium: const Handschrift( + fontSize: 16, + fontWeight: FontWeight.w500, + letterSpacing: 0.15, + height: 1.5, + boldStyle: TextStyle(fontWeight: FontWeight.w700), + linkStyle: TextStyle(decoration: TextDecoration.underline), + ), + titleSmall: const Handschrift( + fontSize: 14, + fontWeight: FontWeight.w500, + letterSpacing: 0.1, + height: 1.43, + boldStyle: TextStyle(fontWeight: FontWeight.w700), + linkStyle: TextStyle(decoration: TextDecoration.underline), + ), + bodyLarge: const Handschrift( + fontSize: 16, + fontWeight: FontWeight.w400, + letterSpacing: 0.5, + height: 1.5, + boldStyle: TextStyle(fontWeight: FontWeight.w700), + linkStyle: TextStyle(decoration: TextDecoration.underline), + ), + bodyMedium: const Handschrift( + fontSize: 14, + fontWeight: FontWeight.w400, + letterSpacing: 0.25, + height: 1.43, + boldStyle: TextStyle(fontWeight: FontWeight.w700), + linkStyle: TextStyle(decoration: TextDecoration.underline), + ), + bodySmall: const Handschrift( + fontSize: 12, + fontWeight: FontWeight.w400, + letterSpacing: 0.4, + height: 1.33, + boldStyle: TextStyle(fontWeight: FontWeight.w700), + linkStyle: TextStyle(decoration: TextDecoration.underline), + ), + labelLarge: const Handschrift( + fontSize: 14, + fontWeight: FontWeight.w500, + letterSpacing: 0.1, + height: 1.43, + boldStyle: TextStyle(fontWeight: FontWeight.w700), + linkStyle: TextStyle(decoration: TextDecoration.underline), + ), + labelMedium: const Handschrift( + fontSize: 12, + fontWeight: FontWeight.w500, + letterSpacing: 0.5, + height: 1.33, + boldStyle: TextStyle(fontWeight: FontWeight.w700), + linkStyle: TextStyle(decoration: TextDecoration.underline), + ), + labelSmall: const Handschrift( + fontSize: 11, + fontWeight: FontWeight.w500, + letterSpacing: 0.5, + height: 1.45, + boldStyle: TextStyle(fontWeight: FontWeight.w700), + linkStyle: TextStyle(decoration: TextDecoration.underline), + ), + navbar: const Handschrift( + fontSize: 12, + fontWeight: FontWeight.w500, + height: 1.33, + boldStyle: TextStyle(fontWeight: FontWeight.w700), + ), +); +``` + +### Step 3: Register on ThemeData + +In your app's theme configuration: + +```dart +import 'package:flutter/material.dart'; + +ThemeData buildAppTheme() { + final kleurplaat = appKleurplaatLight; + final boekwerk = appBoekwerk; + + return ThemeData( + colorScheme: kleurplaat.toColorScheme(brightness: Brightness.light), + textTheme: boekwerk.asTextTheme( + bodyColor: kleurplaat.surface.onColorContrast, + displayColor: kleurplaat.surface.onColorContrast, + ), + extensions: [ + kleurplaat, + boekwerk, + ], + ); +} +``` + +### Step 4: Access in Widgets + +```dart +@override +Widget build(BuildContext context) { + // Access colors + final colors = context.katjasKleurPlaat; + final primaryColor = colors.primary.color; + final surfaceBg = colors.surface.color; + final errorColor = colors.error.color; + + // Access typography + final typography = context.katjasBoekwerk; + final titleStyle = typography.titleLarge; // Handschrift + + return Container( + color: surfaceBg, + child: Text( + 'Hello', + style: titleStyle, // or titleStyle?.bold, titleStyle?.link + ), + ); +} +``` + +### Step 5: Use BoekwerkDecorator for Pre-Colored Text + +The `BoekwerkDecorator` combines typography + colors into a single access point: + +```dart +@override +Widget build(BuildContext context) { + final textThemes = context.katjasBoekwerkDecorator; + + return Column( + children: [ + // Body text with primary color applied + Text('Primary text', style: textThemes.bodyMedium?.primary.color), + + // Title with surface contrast color + Text('Title', style: textThemes.titleLarge?.surface.onColorContrast), + + // Error text + Text('Error!', style: textThemes.bodySmall?.error.color), + + // Bold variant with primary color + Text('Bold primary', style: textThemes.labelLarge?.primary.color.bold), + ], + ); +} +``` + +## Complete Example + +```dart +// lib/core/theme/app_theme.dart +import 'package:dcc_toolkit/dcc_toolkit.dart'; +import 'package:dcc_toolkit/style/text_style/handschrift.dart'; +import 'package:dcc_toolkit/style/text_style/katjas_boekwerk.dart'; +import 'package:flutter/material.dart'; + +class AppTheme { + static ThemeData get light { + final kleurplaat = _lightKleurplaat; + final boekwerk = _boekwerk; + + return ThemeData( + useMaterial3: true, + colorScheme: kleurplaat.toColorScheme(brightness: Brightness.light), + textTheme: boekwerk.asTextTheme( + bodyColor: kleurplaat.surface.onColorContrast, + ), + extensions: [kleurplaat, boekwerk], + ); + } + + static final _lightKleurplaat = KatjasKleurplaat( + primary: const ColorGroup(color: Color(0xFF6750A4), onColorContrast: Color(0xFFFFFFFF)), + primaryFill: const ColorGroup(color: Color(0xFFEADDFF), onColorContrast: Color(0xFF6750A4)), + content: const ColorGroup(color: Color(0xFF1D1B20), onColorContrast: Color(0xFFFFFFFF)), + contentFill: const ColorGroup(color: Color(0xFFE6E0E9), onColorContrast: Color(0xFF1D1B20)), + error: const ColorGroup(color: Color(0xFFB3261E), onColorContrast: Color(0xFFFFFFFF)), + errorFill: const ColorGroup(color: Color(0xFFF9DEDC), onColorContrast: Color(0xFFB3261E)), + success: const ColorGroup(color: Color(0xFF198754), onColorContrast: Color(0xFFFFFFFF)), + successFill: const ColorGroup(color: Color(0xFFD1E7DD), onColorContrast: Color(0xFF198754)), + surface: const SurfaceGroup( + color: Color(0xFFFEF7FF), + onColorContrast: Color(0xFF1D1B20), + onColorContrastDim: Color(0xFF49454F), + onColorSubtle: Color(0xFF79747E), + onColorSubtleDim: Color(0xFFCAC4D0), + containerLowest: Color(0xFFFFFFFF), + containerLow: Color(0xFFF7F2FA), + container: Color(0xFFF3EDF7), + containerHigh: Color(0xFFECE6F0), + containerHighest: Color(0xFFE6E0E9), + link: Color(0xFF6750A4), + ), + surfaceInverse: null, + ); + + static final _boekwerk = KatjasBoekwerk( + displayLarge: const Handschrift(fontSize: 57, fontWeight: FontWeight.w400, boldStyle: TextStyle(fontWeight: FontWeight.w700)), + headlineMedium: const Handschrift(fontSize: 28, fontWeight: FontWeight.w400, boldStyle: TextStyle(fontWeight: FontWeight.w700)), + titleLarge: const Handschrift(fontSize: 22, fontWeight: FontWeight.w500, boldStyle: TextStyle(fontWeight: FontWeight.w700)), + titleMedium: const Handschrift(fontSize: 16, fontWeight: FontWeight.w500, boldStyle: TextStyle(fontWeight: FontWeight.w700)), + bodyLarge: const Handschrift(fontSize: 16, fontWeight: FontWeight.w400, boldStyle: TextStyle(fontWeight: FontWeight.w700)), + bodyMedium: const Handschrift(fontSize: 14, fontWeight: FontWeight.w400, boldStyle: TextStyle(fontWeight: FontWeight.w700)), + bodySmall: const Handschrift(fontSize: 12, fontWeight: FontWeight.w400, boldStyle: TextStyle(fontWeight: FontWeight.w700)), + labelLarge: const Handschrift(fontSize: 14, fontWeight: FontWeight.w500, boldStyle: TextStyle(fontWeight: FontWeight.w700)), + labelMedium: const Handschrift(fontSize: 12, fontWeight: FontWeight.w500, boldStyle: TextStyle(fontWeight: FontWeight.w700)), + labelSmall: const Handschrift(fontSize: 11, fontWeight: FontWeight.w500, boldStyle: TextStyle(fontWeight: FontWeight.w700)), + ); +} +``` + +```dart +// Usage in a widget +class ProfileCard extends StatelessWidget { + const ProfileCard({required this.name, required this.email, super.key}); + + final String name; + final String email; + + @override + Widget build(BuildContext context) { + final kleurplaat = context.katjasKleurPlaat; + final textThemes = context.katjasBoekwerkDecorator; + + return Container( + padding: Paddings.all16, + decoration: BoxDecoration( + color: kleurplaat.surface.containerLow, + borderRadius: Radiuses.px12, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(name, style: textThemes.titleLarge?.surface.onColorContrast), + Margins.vertical4, + Text(email, style: textThemes.bodyMedium?.surface.onColorSubtle), + ], + ), + ); + } +} +``` + +## Common Patterns + +### Dark Theme Support + +Create a separate `KatjasKleurplaat` for dark mode and use `lerp()` for smooth transitions: + +```dart +static ThemeData get dark { + final kleurplaat = _darkKleurplaat; + return ThemeData( + brightness: Brightness.dark, + colorScheme: kleurplaat.toColorScheme(brightness: Brightness.dark), + extensions: [kleurplaat, _boekwerk], + ); +} +``` + +### Using `toColorScheme()` for Material Widgets + +`KatjasKleurplaat.toColorScheme()` maps the palette to Flutter's `ColorScheme`: +- `primary` -> `ColorScheme.primary` +- `content` -> `ColorScheme.secondary` +- `error` -> `ColorScheme.error` +- `surface` -> `ColorScheme.surface` + +This ensures Material widgets (buttons, cards, etc.) respect your palette automatically. + +### Using Handschrift's `.bold` and `.link` Variants + +```dart +// Bold text +Text('Important', style: context.katjasBoekwerk.bodyLarge?.bold); + +// Link-styled text +Text('Tap here', style: context.katjasBoekwerk.bodyMedium?.link); +``` + +### Converting Handschrift from an Existing TextStyle + +```dart +final existing = TextStyle(fontSize: 16, color: Colors.black); +final handschrift = Handschrift.fromTextStyle( + existing, + boldStyle: const TextStyle(fontWeight: FontWeight.w700), + linkStyle: const TextStyle(decoration: TextDecoration.underline, color: Colors.blue), +); +``` + +## Feedback Loop + +After implementing: + +1. Run `dart analyze` -- ensure no lint errors. +2. Run the app -- verify text renders with correct sizes, weights, and colors. +3. Check that `context.katjasKleurPlaat` and `context.katjasBoekwerk` do not throw (they use `!` assertion, so missing extensions will crash at runtime). +4. If using dark mode -- toggle themes and verify `lerp()` animates smoothly. +5. Verify Material widgets (buttons, AppBar, etc.) pick up colors from `toColorScheme()`. From dc8eee4a863d70f7c7b48c16534e60529f7c7394 Mon Sep 17 00:00:00 2001 From: Job Guldemeester Date: Thu, 18 Jun 2026 11:56:13 +0200 Subject: [PATCH 2/3] :wastebasket: deprecated confusing name --- lib/common/extensions/build_context.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/common/extensions/build_context.dart b/lib/common/extensions/build_context.dart index f53764d..326b64c 100644 --- a/lib/common/extensions/build_context.dart +++ b/lib/common/extensions/build_context.dart @@ -14,7 +14,10 @@ extension ThemingExtensions on BuildContext { KatjasBoekwerk get katjasBoekwerk => theme.extension()!; /// Get [BoekwerkDecorator] from [BuildContext]. + @Deprecated('Use katjasBoekwerkDecorator instead') BoekwerkDecorator get textThemesDecorator => BoekwerkDecorator(katjasBoekwerk, katjasKleurPlaat); + /// Get [BoekwerkDecorator] from [BuildContext]. + BoekwerkDecorator get katjasBoekwerkDecorator => BoekwerkDecorator(katjasBoekwerk, katjasKleurPlaat); /// Get [KatjasKleurplaat] from [BuildContext]. KatjasKleurplaat get katjasKleurPlaat => theme.extension()!; From 089e36e2b75313dcdccbae2c06d0a846944db46f Mon Sep 17 00:00:00 2001 From: Job Guldemeester Date: Thu, 18 Jun 2026 14:55:25 +0200 Subject: [PATCH 3/3] :art: format --- lib/common/extensions/build_context.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/common/extensions/build_context.dart b/lib/common/extensions/build_context.dart index 326b64c..a7ed4b2 100644 --- a/lib/common/extensions/build_context.dart +++ b/lib/common/extensions/build_context.dart @@ -16,6 +16,7 @@ extension ThemingExtensions on BuildContext { /// Get [BoekwerkDecorator] from [BuildContext]. @Deprecated('Use katjasBoekwerkDecorator instead') BoekwerkDecorator get textThemesDecorator => BoekwerkDecorator(katjasBoekwerk, katjasKleurPlaat); + /// Get [BoekwerkDecorator] from [BuildContext]. BoekwerkDecorator get katjasBoekwerkDecorator => BoekwerkDecorator(katjasBoekwerk, katjasKleurPlaat);