# fbae_core

Flutter Base App Engine — pluggable infrastructure package for Flutter apps.

## Overview

`fbae_core` provides production-ready infrastructure out of the box: HTTP client with auth + auto token refresh, navigation shell, theming with ThemeExtension, async state management boilerplate, env config, logging, and a plugin system. Designed to be used as a dependency — consumer apps call `FbaeCore.init()` once in `main()` and get everything wired.

## Installation

```yaml
dependencies:
  fbae_core: ^0.1.1
```

## Bootstrap

```dart
import 'package:fbae_core/fbae_core.dart';

await FbaeCore.init(
  config: FbaeConfig.dev(baseUrl: 'https://api.example.com'),
  tokenProvider: () async => await storage.read('accessToken'),
  onRefreshToken: () async { /* refresh and return new token */ },
);
runApp(const MyApp());
```

Bootstrap order is guaranteed: logger → DI (get_it) → HTTP → BlocObserver → plugins.

## Key Classes

### FbaeCore
- `FbaeCore.init({required FbaeConfig config, AppLogger? logger, TokenProvider? tokenProvider, RefreshTokenCallback? onRefreshToken, List<FbaePlugin> plugins})` — bootstrap entry point, call once in main()
- `FbaeCore.instance` — singleton accessor after init
- `FbaeCore.instance.httpClient` — returns the configured FbaeHttpClient
- `FbaeCore.instance.logger` — returns the active AppLogger

### FbaeConfig
- `FbaeConfig.dev(baseUrl:)` — development config, LogLevel.debug, envName: 'dev'
- `FbaeConfig.staging(baseUrl:)` — staging config, LogLevel.info, envName: 'staging'
- `FbaeConfig.production(baseUrl:)` — production config, LogLevel.none, envName: 'production'
- Fields: `baseUrl` (required), `envName`, `timeout` (default 30s), `logLevel`, `featureFlags` (immutable Map<String,bool>), `errorMessageKey` (default 'message'), `errorCodeKey` (default 'code'), `plugins`
- All fields validated in constructor — throws StateError on invalid config

### FbaeHttpClient (interface)
- `get<T>(path, {queryParams, fromJson})` — GET request, throws ApiError on non-2xx
- `post<T>(path, {body, fromJson})` — POST request
- `put<T>(path, {body, fromJson})` — PUT request
- `delete<T>(path, {fromJson})` — DELETE request
- Bearer token auto-injected when `tokenProvider` is passed to `FbaeCore.init()`
- Token refresh on 401: QueuedInterceptor guarantees exactly 1 refresh call even with N concurrent 401s
- After refresh, all queued requests retry with new token automatically

### ApiError
- `statusCode` — HTTP status code (0 for network errors)
- `message` — parsed from response body using `FbaeConfig.errorMessageKey` (default: `'message'`)
- `code` — parsed from response body using `FbaeConfig.errorCodeKey` (default: `'code'`)
- `rawBody` — raw response string when JSON parsing fails
- Implements Exception — catch with `on ApiError catch (e)`

### TokenProvider / RefreshTokenCallback
- `TokenProvider = Future<String?> Function()` — returns current access token or null (header omitted when null)
- `RefreshTokenCallback = Future<String> Function()` — consumer calls refresh endpoint, saves new tokens, returns new accessToken
- fbae_core NEVER stores tokens — consumer owns token storage (memory, flutter_secure_storage, etc.)
- Recommended pattern: AuthManager class with saveTokens(), tokenProvider(), onRefreshToken(), logout()

### AuthManager (recommended consumer pattern)
```dart
class AuthManager {
  String? _accessToken;
  String? _refreshToken;

  void saveTokens({required String accessToken, required String refreshToken}) {
    _accessToken = accessToken;
    _refreshToken = refreshToken;
  }

  Future<String?> tokenProvider() async => _accessToken;

  Future<String> onRefreshToken() async {
    final res = await FbaeCore.instance.httpClient.post('/auth/refresh',
      body: {'refreshToken': _refreshToken});
    saveTokens(accessToken: res['accessToken'], refreshToken: res['refreshToken']);
    return _accessToken!;
  }

  void logout() { _accessToken = null; _refreshToken = null; }
}
```

### AppLogger (interface)
- `debug(message)`, `info(message)`, `warning(message)`, `error(message, {error, stackTrace})`
- Default implementation: `TalkerAppLogger` (Talker-backed, respects LogLevel)
- Inject custom: `FbaeCore.init(logger: MyCrashlyticsLogger())`
- HTTP requests auto-logged via TalkerDioLogger when logLevel is debug/info

### LogLevel enum
- `none` — all logging suppressed (production default)
- `debug` — all messages (dev default)
- `info` — info and above (staging default)
- `warning` — warnings and errors only
- `error` — errors only

### AsyncState<T> (sealed)
Four variants — Dart compiler enforces exhaustive switch:
- `AsyncInitial<T>` — not yet started; initial state emitted by BaseCubit
- `AsyncLoading<T>` — operation in progress
- `AsyncData<T>(value: T)` — successful result; holds .value
- `AsyncError<T>(error: Object, stackTrace: StackTrace)` — failure; holds .error and .stackTrace

```dart
switch (state) {
  case AsyncInitial(): return placeholder;
  case AsyncLoading(): return CircularProgressIndicator();
  case AsyncData(:final value): return Text('$value');
  case AsyncError(:final error): return Text('Error: $error');
}
```

### BaseCubit<T>
- Extends `Cubit<AsyncState<T>>`
- Initial state: `AsyncInitial<T>`
- `run(Future<T> Function() fn, {bool silent = false})` — emits Loading → Data/Error; guards against closed cubit
- `silent: true` — skips emitting AsyncLoading (background reload, preserves current AsyncData)
- Exceptions caught and emitted as AsyncError — never rethrown

```dart
class UserCubit extends BaseCubit<User> {
  Future<void> load(String id) => run(() => api.getUser(id));
  Future<void> silentRefresh(String id) => run(() => api.getUser(id), silent: true);
}
```

### BaseBloc<E, S>
- Extends `Bloc<E, S>` — for event-driven flows, no AsyncState wrapper
- Only adds: `onError` logs via AppLogger then calls `super.onError`
- addError(e, st) in event handlers routes to BaseBloc.onError → AppLogger

### FbaeBlocObserver
- Extends `BlocObserver`, auto-wired by `FbaeCore.init()`
- Logs onCreate, onChange (currentState → nextState), onClose, onError via Talker
- LogLevel.debug → all events logged; LogLevel.none → silent; others → errors only
- `FbaeBlocObserver({required Talker talker, LogLevel level = LogLevel.debug})`
- Can be replaced: `Bloc.observer = MyObserver()` after FbaeCore.init()

### FbaeColors (ThemeExtension)
13 color slots + brightness field (all have Material 3 defaults):
`brightness` (Brightness.light default), `primary`, `onPrimary`, `secondary`, `onSecondary`,
`background`, `onBackground`, `surface`, `onSurface`, `error`, `onError`,
`textPrimary`, `textSecondary`, `textDisabled`

- Constructor asserts: primary != onPrimary, background != surface
- `copyWith(...)` — returns new instance with overridden fields
- `lerp()` fully implemented — no visual snap on animated theme transitions
- Access in widgets: `Theme.of(context).extension<FbaeColors>()!`
- background/onBackground NOT mapped to deprecated ColorScheme params — use extension only

### FbaeTheme
- `FbaeTheme.build({required FbaeColors colors})` → `ThemeData`
- Material 3 (useMaterial3: true)
- ColorScheme generated via ColorScheme.fromSeed(seedColor: colors.primary, brightness: colors.brightness)
- FbaeColors registered as ThemeExtension in the returned ThemeData

### ThemeCubit
- State: `ThemeMode`
- `toggle()` — light→dark, dark→light, system→light
- `setMode(ThemeMode mode)` — set explicitly (e.g. restore user preference)
- Initial state: ThemeMode.system (configurable via FbaeThemeProvider)

### FbaeThemeProvider
- `FbaeThemeProvider({ThemeMode initialMode = ThemeMode.system, required Widget child})`
- BlocProvider wrapper — creates and provides ThemeCubit to descendant widgets
- Descendants access via `context.read<ThemeCubit>()` / `context.watch<ThemeCubit>()`
- Must wrap MaterialApp.router (not inside it)

```dart
FbaeThemeProvider(
  child: BlocBuilder<ThemeCubit, ThemeMode>(
    builder: (context, themeMode) => MaterialApp.router(
      theme: FbaeTheme.build(colors: lightColors),
      darkTheme: FbaeTheme.build(colors: darkColors),
      themeMode: themeMode,
      routerConfig: router,
    ),
  ),
)
```

### FbaeShell
- `FbaeShell({required StatefulNavigationShell navigationShell, required ShellNavigationConfig config, Color? backgroundColor, Color? indicatorColor, double? elevation, VoidCallback? onTabReselected})`
- Renders NavigationBar (M3) for TabConfig, Drawer for DrawerConfig
- Stateless — GoRouter owns navigation state
- Tab reselect pops to root of that branch by default

### FbaeRouter
- `FbaeRouter.buildRoutes({required ShellNavigationConfig config, required List<StatefulShellBranch> branches})` → `List<RouteBase>`
- Returns single StatefulShellRoute.indexedStack entry
- Consumer creates and owns `GoRouter(routes: FbaeRouter.buildRoutes(...))`
- Branch order must match TabItemConfig/DrawerItemConfig order

### ShellNavigationConfig (sealed)
- `TabConfig(List<TabItemConfig> items)` — bottom NavigationBar; items.length >= 2
- `DrawerConfig({Widget? header, required List<DrawerItemConfig> items})` — side Drawer
- `TabItemConfig({required Widget icon, Widget? activeIcon, required String label, required String initialLocation})`
  - initialLocation must exactly match first route in corresponding StatefulShellBranch
- `DrawerItemConfig({required Widget icon, required String label, required String location})`

### FbaePlugin (interface)
- `configure(FbaePluginContext context)` — called during bootstrap after logger+DI+HTTP ready
- In debug mode: configure() failure rethrows immediately (fail-fast)
- In release/profile: failure is logged and plugin skipped (resilient)

### FbaePluginContext
- `config` — FbaeConfig (read-only)
- `logger` — Talker instance
- `registerCrashReporter(CrashReporter impl)` — replace NoOpCrashReporter
- `addLogObserver(TalkerObserver observer)` — add observer to Talker pipeline

## Barrel Exports

Single import: `import 'package:fbae_core/fbae_core.dart';`

Test utilities only: `import 'package:fbae_core/fbae_core_testing.dart';`
- Exports: `MockHttpClientAdapter` (handler callback controls all responses)

## Testing

```dart
import 'package:fbae_core/fbae_core_testing.dart';

final adapter = MockHttpClientAdapter(handler: (options, _) async =>
  ResponseBody.fromString('{"id":1}', 200,
    headers: {Headers.contentTypeHeader: [Headers.jsonContentType]}));

await FbaeCore.init(config: FbaeConfig.dev(baseUrl: 'https://test'), httpClientAdapter: adapter);
```

## Dependencies

- `flutter_bloc ^9.1.1` — BlocObserver, Cubit, Bloc, BlocProvider, BlocBuilder
- `go_router ^17.2.0` — navigation (StatefulShellRoute, GoRoute)
- `dio ^5.9.2` — HTTP client, QueuedInterceptor
- `get_it ^9.2.1` — service locator (internal only, not exposed to consumers)
- `talker ^5.1.16` + `talker_flutter` + `talker_dio_logger` — structured logging

## Repository

https://github.com/imthanhhai217/fbae


Flutter Base App Engine — pluggable infrastructure package for Flutter apps.

## Overview

`fbae_core` provides production-ready infrastructure out of the box: HTTP client with auth, navigation shell, theming, state management boilerplate, env config, and logging. Designed to be used as a dependency — consumer apps call `FbaeCore.init()` once in `main()` and get everything wired.

## Bootstrap

```dart
import 'package:fbae_core/fbae_core.dart';

await FbaeCore.init(
  config: FbaeConfig.dev(baseUrl: 'https://api.example.com'),
  tokenProvider: () async => await storage.read('accessToken'),
  onRefreshToken: () async { /* refresh and return new token */ },
);
runApp(const MyApp());
```

Bootstrap order is guaranteed: logger → DI (get_it) → HTTP → BlocObserver → plugins.

## Key Classes

### FbaeCore
- `FbaeCore.init({required FbaeConfig config, AppLogger? logger, TokenProvider? tokenProvider, RefreshTokenCallback? onRefreshToken, List<FbaePlugin> plugins})` — bootstrap entry point, call once in main()
- `FbaeCore.instance` — singleton accessor after init
- `FbaeCore.instance.httpClient` — returns the configured FbaeHttpClient
- `FbaeCore.instance.logger` — returns the active AppLogger

### FbaeConfig
- `FbaeConfig.dev(baseUrl:)` — development config, LogLevel.debug
- `FbaeConfig.staging(baseUrl:)` — staging config, LogLevel.info
- `FbaeConfig.production(baseUrl:)` — production config, LogLevel.none
- Fields: `baseUrl`, `envName`, `timeout`, `logLevel`, `featureFlags`, `errorMessageKey`, `errorCodeKey`

### FbaeHttpClient (interface)
- `get<T>(path, {queryParams, fromJson})` — GET request, throws ApiError on non-2xx
- `post<T>(path, {body, fromJson})` — POST request
- `put<T>(path, {body, fromJson})` — PUT request
- `delete<T>(path, {fromJson})` — DELETE request
- Bearer token auto-injected when `tokenProvider` is passed to `FbaeCore.init()`
- Token refresh on 401 via `QueuedInterceptor` (no duplicate refresh calls on concurrent requests)

### ApiError
- `statusCode` — HTTP status code (0 for network errors)
- `message` — parsed from response body using `FbaeConfig.errorMessageKey` (default: `'message'`)
- `code` — parsed from response body using `FbaeConfig.errorCodeKey` (default: `'code'`)
- `rawBody` — raw response string when JSON parsing fails

### TokenProvider / RefreshTokenCallback
- `TokenProvider = Future<String?> Function()` — returns current access token or null
- `RefreshTokenCallback = Future<String> Function()` — performs refresh, returns new access token

### AppLogger (interface)
- `debug(message)`, `info(message)`, `warning(message)`, `error(message, {error, stackTrace})`
- Default implementation: `TalkerAppLogger` (Talker-backed, respects LogLevel)
- Inject custom: `FbaeCore.init(logger: MyCrashlyticsLogger())`

### AsyncState<T> (sealed)
Four variants — Dart compiler enforces exhaustive switch:
- `AsyncInitial<T>` — not yet started
- `AsyncLoading<T>` — operation in progress
- `AsyncData<T>(value: T)` — success
- `AsyncError<T>(error: Object, stackTrace: StackTrace)` — failure

```dart
switch (state) {
  case AsyncInitial(): return placeholder;
  case AsyncLoading(): return CircularProgressIndicator();
  case AsyncData(:final value): return Text('$value');
  case AsyncError(:final error): return Text('Error: $error');
}
```

### BaseCubit<T>
- Extends `Cubit<AsyncState<T>>`
- `run(Future<T> Function() fn, {bool silent = false})` — handles AsyncLoading → AsyncData/AsyncError lifecycle, guards against closed cubit
- `silent: true` skips emitting AsyncLoading (background reload)

```dart
class UserCubit extends BaseCubit<User> {
  Future<void> load(String id) => run(() => api.getUser(id));
}
```

### BaseBloc<E, S>
- Extends `Bloc<E, S>`
- Only adds: `onError` logs via AppLogger then calls `super.onError`

### FbaeBlocObserver
- Extends `BlocObserver`, auto-wired by `FbaeCore.init()`
- Logs onCreate, onChange, onClose, onError via Talker
- `FbaeBlocObserver({required Talker talker, LogLevel level = LogLevel.debug})`

### FbaeColors (ThemeExtension)
13 color slots + brightness field:
`primary`, `onPrimary`, `secondary`, `onSecondary`, `background`, `onBackground`, `surface`, `onSurface`, `error`, `onError`, `textPrimary`, `textSecondary`, `textDisabled`, `brightness`

- Has sensible Material 3 defaults — override via constructor or `copyWith()`
- `lerp()` fully implemented for smooth animated transitions
- Access in widgets: `Theme.of(context).extension<FbaeColors>()!`

### FbaeTheme
- `FbaeTheme.build({required FbaeColors colors})` → `ThemeData` (Material 3, useMaterial3: true)

### ThemeCubit
- State: `ThemeMode`
- `toggle()` — light↔dark (system→light)
- `setMode(ThemeMode mode)`

### FbaeThemeProvider
- `FbaeThemeProvider({ThemeMode initialMode, required Widget child})` — BlocProvider wrapper
- Descendants access via `context.read<ThemeCubit>()` / `context.watch<ThemeCubit>()`

### FbaeShell
- `FbaeShell({required StatefulNavigationShell navigationShell, required ShellNavigationConfig config})`
- Renders `NavigationBar` for `TabConfig`, `Drawer` for `DrawerConfig`
- Stateless — consumer owns GoRouter

### FbaeRouter
- `FbaeRouter.buildRoutes({required ShellNavigationConfig config, required List<StatefulShellBranch> branches})` → `List<RouteBase>`
- Consumer creates and owns `GoRouter(routes: FbaeRouter.buildRoutes(...))`

### ShellNavigationConfig (sealed)
- `TabConfig(List<TabItemConfig> items)` — bottom navigation bar
- `DrawerConfig({Widget? header, required List<DrawerItemConfig> items})` — side drawer
- `TabItemConfig({required Widget icon, Widget? activeIcon, required String label, required String initialLocation})`
- `DrawerItemConfig({required Widget icon, required String label, required String location})`

### FbaePlugin (interface)
- `configure(FbaePluginContext context)` — called during bootstrap
- `FbaePluginContext` provides: `config`, `logger`, `registerCrashReporter()`, `addLogObserver()`

## Barrel Exports

Single import: `import 'package:fbae_core/fbae_core.dart';`

Test utilities (MockHttpClientAdapter): `import 'package:fbae_core/fbae_core_testing.dart';`

## Dependencies

- `flutter_bloc ^9.1.1` — BlocObserver, Cubit, Bloc
- `go_router ^17.2.0` — navigation
- `dio ^5.9.2` — HTTP client
- `get_it ^9.2.1` — service locator (internal, not exposed to consumers)
- `talker ^5.1.16` + `talker_flutter` + `talker_dio_logger` — logging

## Repository

https://github.com/imthanhhai217/fbae
