# 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

## FbaeCore.init() Full Signature

```dart
FbaeCore.init({
  required FbaeConfig config,
  AppLogger? logger,                        // default: TalkerAppLogger
  TokenProvider? tokenProvider,             // () async => accessToken or null
  RefreshTokenCallback? onRefreshToken,     // () async => newAccessToken
  List<FbaePlugin> plugins = const [],
  HttpClientAdapter? httpClientAdapter,     // inject MockHttpClientAdapter in tests
})
```

Bootstrap order (guaranteed): logger → get_it → DioHttpClient → BlocObserver → plugins.
Calling `FbaeCore.instance` before init throws `StateError: FbaeCore has not been initialized`.

## FbaeConfig Validation Rules

- `baseUrl` must not be empty (throws StateError)
- `timeout` must be > Duration.zero
- `logLevel` defaults: dev=debug, staging=info, production=none
- `featureFlags` is immutable — set at init, cannot be changed at runtime
- `errorMessageKey` / `errorCodeKey` control which JSON fields are parsed for ApiError.message / .code

```dart
// Access config after init:
final flags = FbaeCore.instance.config.featureFlags;
final env   = FbaeCore.instance.config.envName;  // 'dev' | 'staging' | 'production'
```

## Feature Flags Pattern

```dart
// Declare at init:
FbaeConfig.production(
  baseUrl: Env.baseUrl,
  featureFlags: {'dark_mode': true, 'new_checkout': false},
)

// Read anywhere (no context needed):
if (FbaeCore.instance.config.featureFlags['dark_mode'] == true) { ... }
```

## Testing Setup

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

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

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

`MockHttpClientAdapter.handler` can be swapped between tests to simulate different responses.

## bloc_test Pattern

```dart
blocTest<MyCubit, AsyncState<MyData>>(
  'emits [AsyncLoading, AsyncData] on success',
  build: () => MyCubit(FbaeCore.instance.httpClient),
  act: (c) => c.load(),
  expect: () => [isA<AsyncLoading>(), isA<AsyncData<MyData>>()],
);
```

## Common Gotchas

- `tokenProvider` returning `null` → Authorization header OMITTED (safe for public endpoints)
- `onRefreshToken` called at most ONCE even for N concurrent 401s — others queued, retried after
- `FbaeThemeProvider` must be ABOVE MaterialApp.router (not inside)
- `AsyncInitial` ≠ `AsyncLoading` — initial state before first load; let UI trigger `.load()`
- `run()` in BaseCubit guards against closed cubit — safe to call during widget disposal
- `emit()` inside closed cubit is silently ignored by BaseCubit.run()
- feature flags are build-time constants — use bloc/provider for runtime toggleable state
- `FbaeCore.instance.config` is read-only after init
