Skip to content

kh_utils

Stateless helpers for khflutterlib: form validators, value formatters (date/currency/number/phone), country-aware phone numbers, and a leveled logger.

Pure Dart — no Flutter dependency — so it can be used and tested without the Flutter SDK (see PHASE_0_ARCHITECTURE.md §19).

Why you'd reach for this: every one of these is boilerplate you'd otherwise write per-screen — a required-field check, a "2 days ago" timestamp, a print() you meant to remove before shipping. kh_utils gives you the same handful of well-tested implementations everywhere instead of a slightly different version in every screen/app. If you're skimming this for a hackathon: KhValidators + KhFormatters cover almost every form/display need you'll hit — jump to their sections below, or run the packages/example gallery app's "Validators (kh_utils)"/"Formatters (kh_utils)" screens to see them live.

Status

Available. Version 0.1.2+1, shipped in release train khflutterlib-v0.4.3. Exports:

  • KhValidator — the validator function shape (String? Function(String? value))
  • KhValidators — required, email, phone, url, minLength, maxLength, exactLength, numeric, min, max, range, positive, alphanumeric, alpha, noWhitespace, pattern, combine
  • KhFormatters — shortDate, longDate, relative, currency, number, phone
  • KhPhoneRegion — country-aware phone validation, normalization to E.164, and display formatting (ng, us, or your own)
  • KhLogger / KhLogLevel / KhLogSink — leveled logging facade (KhLogger.instance)

Usage

dependencies:
  kh_utils:
    git:
      url: https://github.com/msflib/flutter.git
      path: packages/kh_utils
      ref: khflutterlib-v0.4.3 # use the same release tag for every kh_* package

Validators

Every KhValidators method returns a KhValidator (String? Function(String? value)) — the same shape as Flutter's FormFieldValidator<String>, so it plugs directly into TextFormField.validator or KhTextField with no wrapping:

import 'package:kh_utils/kh_utils.dart';

TextFormField(
  validator: KhValidators.combine([
    KhValidators.required(),
    KhValidators.email(),
  ]),
)

Most validators pass on an empty value by design (e.g. email, minLength, numeric, pattern) — they check the shape of a value that's present, not whether one was provided. Combine with required() first in a combine([...]) chain to also enforce non-emptiness:

final validatePassword = KhValidators.combine([
  KhValidators.required(message: 'Password is required.'),
  KhValidators.minLength(8, message: 'Use at least 8 characters.'),
]);

Each validator accepts an optional message (or a required one for pattern, since there's no sensible generic default):

KhValidators.pattern(
  RegExp(r'^\d{5}$'),
  message: 'Enter a 5-digit ZIP code.',
);

min/max/range/positive also treat a non-numeric value as a failure (there's no sensible bound check on something that isn't a number) — combine with numeric() first if you want a message that distinguishes "not a number" from "out of range":

final validateAge = KhValidators.combine([
  KhValidators.required(),
  KhValidators.numeric(),
  KhValidators.range(18, 120, message: 'Enter an age between 18 and 120.'),
]);

Formatters

import 'package:kh_utils/kh_utils.dart';

final date = DateTime(2026, 9, 29);
KhFormatters.shortDate(date, locale: 'en_US');        // '9/29/2026'
KhFormatters.longDate(date, locale: 'en_US');         // 'September 29, 2026'
KhFormatters.relative(someDate);                      // '2 days ago' / 'in 3 hours' / 'Just now'
KhFormatters.currency(1234.5, locale: 'en_US', symbol: r'$'); // '$1,234.50'
KhFormatters.currency(1250000, locale: 'en_NG', symbol: '₦',
    decimalDigits: 0);                                // '₦1,250,000' (no kobo)
KhFormatters.currency(1250000, locale: 'en_NG', symbol: '₦',
    compact: true);                                   // '₦1.25M'
KhFormatters.currency(1250000, locale: 'en_NG', symbol: '₦',
    compact: true, lowercaseSuffix: true);            // '₦1.25m'
KhFormatters.number(1234567);                         // '1,234,567'
KhFormatters.phone('5551234567');                     // '(555) 123-4567'

Every formatter takes an optional locale; without one, intl's current default locale is used. Pass symbol to currency as well: without it, intl prints the currency's ISO code (for example USD1,234.50) instead of a symbol.

relative falls back to shortDate once the difference is a week or more, in either direction — "47 days ago" is less useful than an actual date at that point. phone is a display convenience for common 10/11 digit lengths only; anything else is returned unchanged. Neither currency nor number perform currency conversion — they format a number you already have in the target currency's units.

Compact currency rounds to about three significant digits (intl's behaviour), so decimalDigits has no effect there: 1,255,000 shows as ₦1.25M. Use the full format wherever the exact amount matters, such as checkout totals.

Phone numbers by country

KhPhoneRegion knows how one country writes, validates and stores phone numbers. KhPhoneRegion.ng (Nigerian mobile numbers) and KhPhoneRegion.us are built in:

// Validate what people actually type: 0803 123 4567, 08031234567,
// +234 803 123 4567, 2348031234567, ...
KhTextField(
  keyboardType: TextInputType.phone,
  validator: KhValidators.phone(region: KhPhoneRegion.ng),
);

// Store one canonical form (E.164):
KhPhoneRegion.ng.normalize('0803 123 4567'); // '+2348031234567'
KhPhoneRegion.ng.normalize('0603 123 4567'); // null (not a mobile range)

// Display it back:
KhFormatters.phone('+2348031234567', region: KhPhoneRegion.ng);
// '0803 123 4567'
KhFormatters.phone('+2348031234567', region: KhPhoneRegion.ng,
    international: true); // '+234 803 123 4567'

KhPhoneRegion.ng is meant for the 070, 080, 081, 090 and 091 mobile ranges. Its pattern ([789][01] followed by eight digits, after the trunk 0) also accepts 071, so don't rely on it to reject that prefix. Landlines have variable lengths and aren't accepted. For another country, use the constructor: a country code, an optional trunk prefix, a pattern for the national number, and two display patterns where each # is a digit. Without a region, KhValidators.phone still requires E.164 and KhFormatters.phone keeps its US-style grouping, so existing code is unaffected.

Logger

import 'package:kh_utils/kh_utils.dart';

KhLogger.instance.debug('Fetching users...');
KhLogger.instance.info('Signed in', tag: 'Auth');
KhLogger.instance.warning('Retrying request', error: exception);
KhLogger.instance.error('Failed to load profile', error: exception, stackTrace: stackTrace);

By default, debug/info are hidden once kh_core's KhEnvironmentConfig.current.enableLogging is false (typically your production config) — warning/error always show. Override the threshold or where output goes at bootstrap:

KhLogger.instance.configure(
  minLevel: KhLogLevel.info,
  sink: (level, message) {
    // Forward to a crash-reporting service in addition to the console,
    // for example — the default sink uses dart:developer's log().
  },
);

Each line reaches the sink as [LEVEL] [tag] message, followed by separate error: ... and stack-trace lines when you pass them.