Skip to content

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/production enum with lenient string parsing (fromString accepts dev, stage/stg, prod, any case) and isDevelopment/isStaging/isProduction
  • KhEnvironmentConfig — 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> — sealed KhSuccess<T>/KhFailureResult<T> union with fold/map/flatMap/guard/guardAsync, plus isSuccess/isFailure/valueOrNull/failureOrNull
  • KhFailure — base class for typed failures, plus KhUnknownFailure as a fallback
  • KhException — base class for infrastructure-layer exceptions, plus KhCacheException, KhParsingException, KhUnexpectedException
  • KhCacheFailure, KhParsingFailure, KhPermissionFailure, KhValidationFailure — general-purpose KhFailure subclasses
  • KhNetworkFailure, KhTimeoutFailure, KhServerFailure (with statusCode), KhUnauthorizedFailure, KhNotFoundFailure — backend-neutral remote failures, plus khFailureFromStatusCode to map an HTTP-style status code to the right one
  • KhException.toFailure() — default exception-to-failure mapping extension
  • KhEntity<I> — base class for domain entities with identity-based equality
  • KhPage<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> — extends KhReadRepository with create/update/delete
  • KhStringExtensions — isValidEmail/isValidPhone/isValidUrl, isBlank/isNotBlank/nullIfBlank, capitalize()
  • KhDateTimeExtensions — isSameDay, isToday, startOfDay, endOfDay
  • KhIterableExtensions<T> — firstOrNull, lastOrNull, firstWhereOrNull, chunked
  • KhDebouncer — debounces rapid repeated calls (run, cancel, isActive, dispose)
  • KhRetry — retries a KhResult-returning action on failure, with an optional shouldRetry predicate

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:

final result = await KhResult.guardAsync(() => api.fetchUser(id));

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:

final result = await KhRetry.run(
  () => KhResult.guardAsync(() => api.fetchUser(id)),
  // maxAttempts and delay default to KhLimits.maxRetryAttempts /
  // KhDurations.retryDelay.
  shouldRetry: (failure) => failure is! KhValidationFailure,
);