kh_core¶
Core primitives for the khflutterlib foundation: constants, environment
configuration, KhResult/KhFailure types, base entities, exceptions,
and shared extensions/helpers.
Pure Dart — no Flutter dependency — so it can be used and tested without
the Flutter SDK (see PHASE_0_ARCHITECTURE.md §8).
Status¶
Available. Version 0.2.0, shipped in release train khflutterlib-v0.4.3.
Exports:
KhDurations— shared timing constants (network timeout, debounce, retry delay, token refresh buffer)KhLimits— shared numeric limits/defaults (pagination, retries, upload size)KhRegex— shared validation regular expressions (email, E.164 phone, URL)KhEnvironment—development/staging/productionenum with lenient string parsing (fromStringacceptsdev,stage/stg,prod, any case) andisDevelopment/isStaging/isProductionKhEnvironmentConfig— immutable per-environment config value object (optional API base URL, logging flag; extend it for typed app settings), passed through your DI, with an optional global holder (initialize()/current/maybeCurrent)KhResult<T>— sealedKhSuccess<T>/KhFailureResult<T>union withfold/map/flatMap/guard/guardAsync, plusisSuccess/isFailure/valueOrNull/failureOrNullKhFailure— base class for typed failures, plusKhUnknownFailureas a fallbackKhException— base class for infrastructure-layer exceptions, plusKhCacheException,KhParsingException,KhUnexpectedExceptionKhCacheFailure,KhParsingFailure,KhPermissionFailure,KhValidationFailure— general-purposeKhFailuresubclassesKhNetworkFailure,KhTimeoutFailure,KhServerFailure(withstatusCode),KhUnauthorizedFailure,KhNotFoundFailure— backend-neutral remote failures, pluskhFailureFromStatusCodeto map an HTTP-style status code to the right oneKhException.toFailure()— default exception-to-failure mapping extensionKhEntity<I>— base class for domain entities with identity-based equalityKhPage<T>— Freezed-based paginated-list model (copyWith, structural equality,totalPages/hasNextPage/hasPreviousPage,mapItems)KhReadRepository<T, ID>— read-only repository contract (getById,getPage)KhMutableRepository<T, ID>— extendsKhReadRepositorywithcreate/update/deleteKhStringExtensions—isValidEmail/isValidPhone/isValidUrl,isBlank/isNotBlank/nullIfBlank,capitalize()KhDateTimeExtensions—isSameDay,isToday,startOfDay,endOfDayKhIterableExtensions<T>—firstOrNull,lastOrNull,firstWhereOrNull,chunkedKhDebouncer— debounces rapid repeated calls (run,cancel,isActive,dispose)KhRetry— retries aKhResult-returning action on failure, with an optionalshouldRetrypredicate
Note on codegen: as of KhPage, this package depends on
build_runner/freezed. Generated *.freezed.dart files are committed
to the repo (see .gitignore and PHASE_0_ARCHITECTURE.md §10) rather
than regenerated by consumers, since packages are distributed via git
dependency, not pub.dev. Run melos run generate (or dart run
build_runner build --delete-conflicting-outputs from this package's
directory) after changing any @freezed class, and commit the result.
Usage¶
dependencies:
kh_core:
git:
url: https://github.com/msflib/flutter.git
path: packages/kh_core
ref: khflutterlib-v0.4.3 # use the same release tag for every kh_* package
import 'package:kh_core/kh_core.dart';
final timeout = KhDurations.networkTimeout;
final isValidEmail = KhRegex.email.hasMatch('ada@example.com');
Environment configuration¶
KhEnvironmentConfig is a plain, immutable value object. Build one in
main() and pass it through your dependency injection, e.g. a Riverpod
provider:
final configProvider = Provider<KhEnvironmentConfig>(
(ref) => throw UnimplementedError('Overridden in main()'),
);
void main() {
final environment = KhEnvironment.fromString(
const String.fromEnvironment('ENV', defaultValue: 'development'),
);
final config = KhEnvironmentConfig(
environment: environment,
enableLogging: !environment.isProduction,
);
runApp(
ProviderScope(
overrides: [configProvider.overrideWithValue(config)],
child: const MyApp(),
),
);
}
apiBaseUrl is optional, for apps whose backend isn't a plain REST URL.
Put app-specific settings in typed fields on a subclass rather than in
the untyped extra map:
class AppConfig extends KhEnvironmentConfig {
const AppConfig({
required super.environment,
required this.supabaseUrl,
required this.supabaseAnonKey,
super.enableLogging,
});
final String supabaseUrl;
final String supabaseAnonKey;
}
The config has value equality and a copyWith, so tests can build
exactly the config they need.
Optional global holder: KhEnvironmentConfig.initialize(config) sets
a global config for code that can't receive it through DI. kh_utils's
KhLogger reads it when it's set and uses defaults when it isn't.
current throws a StateError if nothing was initialized, and
maybeCurrent returns null instead. Apps that use DI don't need to call
initialize at all.
Result / Failure¶
KhResult<T> is a sealed class, not Freezed (see
PHASE_0_ARCHITECTURE.md §10 for why) —
it pattern-matches with a plain Dart switch:
KhResult<User> fetchUser(String id) { ... }
final message = fetchUser(id).fold(
onSuccess: (user) => 'Hello, ${user.name}',
onFailure: (failure) => 'Error: ${failure.message}',
);
// Or, if you prefer switch directly:
final message2 = switch (fetchUser(id)) {
KhSuccess(:final value) => 'Hello, ${value.name}',
KhFailureResult(:final failure) => 'Error: ${failure.message}',
};
Wrap code that can throw with KhResult.guard/KhResult.guardAsync
rather than a manual try/catch:
If the thrown error is a KhException, guard/guardAsync map it via
its own toFailure() by default (so a KhCacheException still becomes
a KhCacheFailure, not a generic one); anything else falls back to
KhUnknownFailure. Pass onError to override this for a specific call.
Exceptions vs. Failures¶
KhException is the thrown side of error handling; KhFailure is the
returned side. Data sources throw a KhException (e.g.
KhCacheException when a local read fails); repositories catch it and
map it to a KhFailure before returning a KhResult to domain/
presentation code. The toFailure() extension covers the common case:
KhCacheException becomes KhCacheFailure, KhParsingException becomes
KhParsingFailure, and anything else (including KhUnexpectedException)
becomes KhUnknownFailure:
KhResult<User> getCachedUser() {
try {
return KhResult.success(localDataSource.readUser());
} on KhException catch (exception) {
return KhResult.failure(exception.toFailure());
}
}
Reach for an explicit try/on/catch constructing a specific
KhFailure yourself instead of toFailure() when you want to attach
context toFailure() can't infer — e.g. which form field failed
validation, or which permission was denied:
} on PlatformException catch (exception) {
return KhResult.failure(
KhPermissionFailure('Camera access denied', permission: 'camera', cause: exception),
);
}
General-purpose failures (KhCacheFailure, KhParsingFailure,
KhPermissionFailure, KhValidationFailure) live in kh_core.
Remote failures¶
Every data source that talks to a backend maps its errors to the same small set, whatever the backend (REST, Dio, Supabase, Firebase):
| Failure | Meaning | Typical reaction |
|---|---|---|
KhNetworkFailure |
Offline, DNS failure, connection refused or dropped | "You're offline", retry |
KhTimeoutFailure |
The request didn't finish in time (HTTP 408/504) | Retry, carefully for writes |
KhUnauthorizedFailure |
Not signed in, or the session expired (HTTP 401) | Refresh the session or go to sign-in |
KhNotFoundFailure |
The resource doesn't exist (HTTP 404/410) | Show an empty or "gone" state |
KhServerFailure |
Any other error response; statusCode says which |
Check statusCode (e.g. 403, 409) or show a generic error |
khFailureFromStatusCode maps a status code to the right class:
khFailureFromStatusCode(401); // KhUnauthorizedFailure
khFailureFromStatusCode(404); // KhNotFoundFailure
khFailureFromStatusCode(503); // KhServerFailure(statusCode: 503)
Because these are distinct types, callers can switch on them:
final message = switch (failure) {
KhNetworkFailure() => 'Check your connection.',
KhUnauthorizedFailure() => 'Please sign in again.',
KhServerFailure(:final statusCode?) when statusCode >= 500 =>
'Something went wrong on our side.',
_ => 'Something went wrong.',
};
Their message is for developers and logs, and may hold raw backend
text, so don't show it to users directly.
Supabase note: storage, functions and auth errors carry an HTTP
status, so khFailureFromStatusCode works for them. A
PostgrestException's code is a PostgREST or Postgres error code, not
an HTTP status, so map the codes you care about yourself, for example
PGRST116 (.single() matched no rows, or more than one; check
details to tell which) to KhNotFoundFailure, and fall
back to KhServerFailure for the rest.
Domain-specific failures that don't fit these (an AuthFailure with a
reason code, say) belong in the code that produces them, each
extending KhFailure directly.
Base models: KhEntity¶
Extend KhEntity<I> for domain entities — things distinguished by a
stable identity rather than by every field matching:
class User extends KhEntity<String> {
const User({required this.id, required this.name});
@override
final String id;
final String name;
}
// Same id, different name — still considered the same entity:
User(id: '1', name: 'Alice') == User(id: '1', name: 'Bob'); // true
This is deliberately not what you want for value objects/DTOs (where two objects with the same fields should be equal regardless of any notion of identity) — those are a better fit for Freezed, as used below.
Base models: KhPage¶
KhPage<T> wraps a page of paginated results:
KhPage<User> page = await api.fetchUsers(page: 1);
if (page.hasNextPage) {
final next = await api.fetchUsers(page: page.page + 1);
}
// Map DTOs to domain entities while keeping pagination metadata:
final userPage = dtoPage.mapItems((dto) => dto.toEntity());
// Start from an empty page before the first fetch completes:
var state = KhPage<User>.empty();
page is 1-indexed. copyWith, ==/hashCode, and toString are all
generated by Freezed; totalPages, hasNextPage, hasPreviousPage,
isEmpty, and mapItems are hand-written getters/methods on top.
Base repositories¶
KhReadRepository<T, ID> and KhMutableRepository<T, ID> are the
repository contracts — kh_core defines the shape, each feature's
data layer provides the implementation (typically wrapping your own
API client and local cache):
class UserRepository implements KhMutableRepository<User, String> {
UserRepository(this._api, this._cache);
final UserApi _api;
final UserCache _cache;
@override
Future<KhResult<User>> getById(String id) =>
KhResult.guardAsync(() => _api.fetchUser(id));
@override
Future<KhResult<KhPage<User>>> getPage({
int page = 1,
int pageSize = KhLimits.defaultPageSize,
}) => KhResult.guardAsync(() => _api.fetchUsers(page: page, pageSize: pageSize));
@override
Future<KhResult<User>> create(User entity) =>
KhResult.guardAsync(() => _api.createUser(entity));
@override
Future<KhResult<User>> update(User entity) =>
KhResult.guardAsync(() => _api.updateUser(entity));
@override
Future<KhResult<void>> delete(String id) =>
KhResult.guardAsync(() => _api.deleteUser(id));
}
Depend on KhReadRepository instead of KhMutableRepository wherever a
feature only ever reads — a read-only consumer, and its tests, shouldn't
need to implement (or fake) writes it will never call. A
KhMutableRepository is itself a KhReadRepository, so it satisfies
both.
Extensions¶
'user@example.com'.isValidEmail; // true
' '.isBlank; // true
' '.nullIfBlank; // null
'hello'.capitalize(); // 'Hello'
final today = DateTime.now();
today.isToday; // true
today.startOfDay; // today at 00:00:00.000000
[1, 2, 3, 4, 5].chunked(2); // [[1, 2], [3, 4], [5]]
<int>[].firstOrNull; // null, instead of throwing
Helpers¶
KhDebouncer collapses rapid repeated calls into one, e.g. waiting for a
user to stop typing before searching:
final debouncer = KhDebouncer(); // default duration: KhDurations.debounce
void onSearchChanged(String query) {
debouncer.run(() => repository.search(query));
}
// Call debouncer.dispose() when the owning controller/widget is disposed.
KhRetry retries a KhResult-returning action on failure: