fbae_core

Flutter Base App Engine — pluggable infrastructure package.

Add fbae_core to your app and call FbaeCore.init() in main() to get navigation, HTTP, theming, env config, and logging out of the box.

pub package License: MIT

Installation

Add to your pubspec.yaml:

dependencies:
  fbae_core: ^0.1.9

Then run:

flutter pub get

Quick Start

import 'package:fbae_core/fbae_core.dart';

final authManager = AuthManager();

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await FbaeCore.init(
    config: FbaeConfig.dev(
      baseUrl: 'https://dev.api.example.com',
      featureFlags: {'new_ui': true, 'dark_mode': false},
    ),
    tokenProvider: authManager.tokenProvider,
    onRefreshToken: authManager.onRefreshToken,
  );

  runApp(const MyApp());
}

FbaeCore.init() parameter reference:

Parameter Type Required Description
config FbaeConfig Environment + HTTP settings
logger AppLogger? Custom logger (default: TalkerAppLogger)
tokenProvider TokenProvider? Returns current access token per request
onRefreshToken RefreshTokenCallback? Called on 401; must return new token
plugins List<FbaePlugin> Plugins configured during bootstrap
httpClientAdapter HttpClientAdapter? Inject mock adapter in tests

Environment Setup

fbae_core does not depend on envied. Environment variables are injected at build time in your app — fbae_core only consumes the resolved values.

1. Add envied to your app

# your_app/pubspec.yaml
dev_dependencies:
  envied: ^1.3.4
  envied_generator: ^1.3.4
  build_runner: ^2.4.0

2. Create .env files

# .env.dev
API_BASE_URL=https://dev.api.example.com
DARK_MODE_ENABLED=true

# .env.prod
API_BASE_URL=https://api.example.com
DARK_MODE_ENABLED=false

Add .env.* files to .gitignore.

3. Define an @Envied class

import 'package:envied/envied.dart';

@Envied(path: '.env.prod', obfuscate: true)
abstract class Env {
  @EnviedField(varName: 'API_BASE_URL')
  static final String baseUrl = _Env.baseUrl;

  @EnviedField(varName: 'DARK_MODE_ENABLED', defaultValue: false)
  static final bool darkModeEnabled = _Env.darkModeEnabled;
}

Run dart run build_runner build to generate env.g.dart.

4. Wire into FbaeConfig

// main.dart
await FbaeCore.init(
  config: FbaeConfig.production(
    baseUrl: Env.baseUrl,
    featureFlags: {'dark_mode': Env.darkModeEnabled},
  ),
);

5. Reading feature flags

Access flags anywhere after FbaeCore.init() via FbaeConfig:

final config = FbaeCore.instance.config;
if (config.featureFlags['dark_mode'] == true) {
  // show dark mode toggle
}

Flags are immutable Map<String, bool> — they are set once at startup and never change at runtime (use your own state management for runtime toggles).

For dev builds, swap the factory:

await FbaeCore.init(
  config: FbaeConfig.dev(
    baseUrl: Env.baseUrl,  // points to .env.dev values
    featureFlags: {'dark_mode': Env.darkModeEnabled},
  ),
);

5. Custom logger (optional)

Swap the default TalkerAppLogger without changing any call sites:

await FbaeCore.init(
  config: FbaeConfig.production(baseUrl: Env.baseUrl),
  logger: MyCrashlyticsLogger(),  // implements AppLogger
);

HTTP Client

fbae_core ships a typed HTTP client backed by Dio. After FbaeCore.init() completes, access it via FbaeCore.instance.httpClient. All methods throw ApiError on non-2xx responses so there is no need to inspect status codes at call sites — just catch ApiError.

Accessing the client

final client = FbaeCore.instance.httpClient;

GET request

final products = await FbaeCore.instance.httpClient.get<List<dynamic>>(
  '/products',
  queryParams: {'limit': '10'},
  fromJson: (data) => (data['products'] as List),
);

POST request

final result = await FbaeCore.instance.httpClient.post<Map<String, dynamic>>(
  '/auth/login',
  body: {'username': 'emilys', 'password': 'emilyspass', 'expiresInMins': 30},
  fromJson: (data) => data as Map<String, dynamic>,
);
final accessToken = result['accessToken'] as String;

Error handling

try {
  final user = await FbaeCore.instance.httpClient.get<User>(
    '/users/1',
    fromJson: User.fromJson,
  );
} on ApiError catch (e) {
  if (e.statusCode == 401) navigateToLogin();
  if (e.statusCode == 404) showNotFound();
}

TokenProvider — attaching Bearer tokens

Pass a TokenProvider callback to FbaeCore.init(). It is called on every outbound request and its return value is attached as Authorization: Bearer <token>. Return null for unauthenticated requests — the header is omitted automatically.

// In-memory token store (replace with your secure storage)
String? _accessToken;

await FbaeCore.init(
  config: FbaeConfig.production(baseUrl: 'https://api.example.com'),
  tokenProvider: () async => _accessToken,
  onRefreshToken: () async {
    // Call your refresh endpoint and persist new tokens
    final response = await http.post(Uri.parse('https://api.example.com/auth/refresh'));
    _accessToken = jsonDecode(response.body)['accessToken'] as String;
    return _accessToken!;
  },
);

onRefreshToken is optional. When provided, fbae_core automatically retries a 401 response with a refreshed token — no manual retry logic needed in your cubits.

AuthManager pattern

In real apps, create a dedicated class to manage tokens. fbae_core only needs two callbacks — it does not dictate how or where tokens are stored:

class AuthManager {
  String? _accessToken;
  String? _refreshToken;

  /// Call this after a successful login response.
  void saveTokens({required String accessToken, required String refreshToken}) {
    _accessToken = accessToken;
    _refreshToken = refreshToken;
  }

  /// Called by fbae_core on every outbound request.
  Future<String?> tokenProvider() async => _accessToken;

  /// Called by fbae_core when a 401 is received.
  /// Responsibility: call refresh endpoint, persist new tokens, return new accessToken.
  Future<String> onRefreshToken() async {
    final res = await FbaeCore.instance.httpClient.post<Map<String, dynamic>>(
      '/auth/refresh',
      body: {'refreshToken': _refreshToken},
      fromJson: (d) => d as Map<String, dynamic>,
    );
    saveTokens(
      accessToken: res['accessToken'] as String,
      refreshToken: res['refreshToken'] as String,
    );
    return _accessToken!;
  }

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

Wire it during init:

final auth = AuthManager();

await FbaeCore.init(
  config: FbaeConfig.production(baseUrl: Env.baseUrl),
  tokenProvider:  auth.tokenProvider,
  onRefreshToken: auth.onRefreshToken,
);

// After login:
final res = await FbaeCore.instance.httpClient.post('/auth/login', body: {...});
auth.saveTokens(
  accessToken:  res['accessToken'],
  refreshToken: res['refreshToken'],
);

Note: fbae_core guarantees onRefreshToken is called at most once even when multiple requests receive 401 simultaneously. Concurrent 401s are queued — only the first triggers a refresh, the rest retry with the already-refreshed token.

Logging HTTP requests

HTTP requests and responses are automatically logged via TalkerDioLogger when FbaeConfig.logLevel is LogLevel.debug. No configuration needed — set logLevel per environment:

FbaeConfig.dev(baseUrl: '...')         // logLevel: LogLevel.debug  → all requests logged
FbaeConfig.staging(baseUrl: '...')     // logLevel: LogLevel.info   → logged
FbaeConfig.production(baseUrl: '...')  // logLevel: LogLevel.none   → silent

fbae_core provides FbaeShell — a pre-built shell widget that renders either a Material 3 NavigationBar (bottom tabs) or a Drawer based on the ShellNavigationConfig you supply. FbaeRouter.buildRoutes() wires FbaeShell into GoRouter for you. The consumer always owns the GoRouter instance — fbae_core never creates one.

Bottom-tab navigation

Define a TabConfig with one TabItemConfig per branch, then pass it alongside matching StatefulShellBranch entries to FbaeRouter.buildRoutes():

import 'package:fbae_core/fbae_core.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

final router = GoRouter(
  initialLocation: '/products',
  routes: FbaeRouter.buildRoutes(
    config: TabConfig([
      TabItemConfig(
        icon: const Icon(Icons.shopping_bag_outlined),
        activeIcon: const Icon(Icons.shopping_bag),
        label: 'Products',
        initialLocation: '/products',
      ),
      TabItemConfig(
        icon: const Icon(Icons.search_outlined),
        activeIcon: const Icon(Icons.search),
        label: 'Search',
        initialLocation: '/search',
      ),
      TabItemConfig(
        icon: const Icon(Icons.settings_outlined),
        activeIcon: const Icon(Icons.settings),
        label: 'Settings',
        initialLocation: '/settings',
      ),
    ]),
    branches: [
      StatefulShellBranch(routes: [GoRoute(path: '/products', builder: (_, __) => const ProductsPage())]),
      StatefulShellBranch(routes: [GoRoute(path: '/search',   builder: (_, __) => const SearchPage())]),
      StatefulShellBranch(routes: [GoRoute(path: '/settings', builder: (_, __) => const SettingsPage())]),
    ],
  ),
);

Branch order must match TabItemConfig order — the nth branch corresponds to the nth tab item. Each branch uses StatefulShellRoute.indexedStack internally, so tab state is preserved across tab switches without any extra configuration.

Drawer navigation

Swap TabConfig for DrawerConfig — everything else stays the same:

config: DrawerConfig(
  header: const DrawerHeader(child: Text('My App')),
  items: [
    DrawerItemConfig(icon: const Icon(Icons.home), label: 'Home',     location: '/home'),
    DrawerItemConfig(icon: const Icon(Icons.info), label: 'About',    location: '/about'),
  ],
),

Theming

fbae_core ships FbaeColors (a ThemeExtension with 13 colour slots), FbaeTheme.build() (a ThemeData factory), FbaeThemeProvider (a BlocProvider<ThemeCubit> wrapper), and ThemeCubit (a cubit that manages ThemeMode).

Wire up themes in MaterialApp.router

Wrap your MaterialApp.router with FbaeThemeProvider and read ThemeCubit state to drive themeMode:

import 'package:fbae_core/fbae_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

final _lightColors = FbaeColors(brightness: Brightness.light);
final _darkColors  = FbaeColors(
  brightness:  Brightness.dark,
  primary:     const Color(0xFFD0BCFF),
  onPrimary:   const Color(0xFF381E72),
  background:  const Color(0xFF1C1B1F),
  surface:     const Color(0xFF49454F),
  textPrimary: const Color(0xFFE6E1E5),
);

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return FbaeThemeProvider(
      child: BlocBuilder<ThemeCubit, ThemeMode>(
        builder: (context, themeMode) {
          return MaterialApp.router(
            theme:      FbaeTheme.build(colors: _lightColors),
            darkTheme:  FbaeTheme.build(colors: _darkColors),
            themeMode:  themeMode,
            routerConfig: router,
          );
        },
      ),
    );
  }
}

Toggling the theme

Call ThemeCubit.toggle() from any widget that has BuildContext:

ElevatedButton(
  onPressed: () => context.read<ThemeCubit>().toggle(),
  child: const Text('Toggle theme'),
),

toggle() cycles: system → light, light → dark, dark → light. Use setMode(ThemeMode.system) to return to system preference explicitly.

Accessing FbaeColors in widgets

FbaeColors is registered as a ThemeExtension. Access it anywhere below MaterialApp:

final colors = Theme.of(context).extension<FbaeColors>()!;
Text('Hello', style: TextStyle(color: colors.textPrimary));
Container(color: colors.surface);

This keeps custom colours type-safe and co-located with ThemeData — no InheritedWidget needed.

State Management

fbae_core provides AsyncState<T> — a sealed, typed async lifecycle — and BaseCubit<T> which manages the AsyncInitial → AsyncLoading → AsyncData | AsyncError transition for you. FbaeBlocObserver is auto-wired during FbaeCore.init() and logs all BLoC/Cubit transitions.

AsyncState variants

AsyncInitial<T>   — not yet started (initial state)
AsyncLoading<T>   — in-flight async operation
AsyncData<T>      — successful result; holds .value
AsyncError<T>     — failure; holds .error and .stackTrace

Extending BaseCubit

Create a cubit by extending BaseCubit<T> and delegating to run():

import 'package:fbae_core/fbae_core.dart';

class ProductsCubit extends BaseCubit<List<Product>> {
  ProductsCubit(this._client);

  final FbaeHttpClient _client;

  Future<void> load() => run(
    () async {
      final data = await _client.get<List<dynamic>>('/products', fromJson: (d) => d['products']);
      return (data as List).map(Product.fromJson).toList();
    },
  );

  /// Silent reload — preserves the current AsyncData state while fetching.
  Future<void> refresh() => run(
    () async {
      final data = await _client.get<List<dynamic>>('/products', fromJson: (d) => d['products']);
      return (data as List).map(Product.fromJson).toList();
    },
    silent: true,
  );
}

Consuming state in widgets

Use the exhaustive switch pattern — the compiler enforces all four variants:

BlocBuilder<ProductsCubit, AsyncState<List<Product>>>(
  builder: (context, state) => switch (state) {
    AsyncInitial() => const SizedBox.shrink(),
    AsyncLoading() => const CircularProgressIndicator(),
    AsyncData(:final value) => ListView.builder(
        itemCount: value.length,
        itemBuilder: (_, i) => ListTile(title: Text(value[i].title)),
      ),
    AsyncError(:final error) => Text('Error: $error'),
  },
),

FbaeBlocObserver

FbaeBlocObserver is registered automatically during FbaeCore.init(). In debug builds it logs every onCreate, onChange, onClose, and onError event — no configuration required. In profile/release builds only errors are logged, matching the LogLevel from FbaeConfig.

BaseBloc

For event-driven flows, extend BaseBloc<Event, State> instead of BaseCubit. It adds the same onError logging behaviour — no AsyncState wrapper required:

class OrderBloc extends BaseBloc<OrderEvent, OrderState> {
  OrderBloc() : super(const OrderInitial()) {
    on<LoadOrders>(_onLoad);
    on<CancelOrder>(_onCancel);
  }

  Future<void> _onLoad(LoadOrders event, Emitter<OrderState> emit) async {
    emit(const OrderLoading());
    try {
      final orders = await _repo.getOrders();
      emit(OrderLoaded(orders));
    } catch (e, st) {
      addError(e, st); // routes to BaseBloc.onError → AppLogger
      emit(const OrderError());
    }
  }
}

Logging

fbae_core uses an abstract AppLogger interface. The default implementation is TalkerAppLogger backed by the talker package.

Log levels

Level Dev Staging Production
debug
info
warning
error
none ✅ (all silent)

Custom logger

Implement AppLogger to route logs to any backend (Firebase Crashlytics, Sentry, etc.):

class CrashlyticsLogger implements AppLogger {
  @override
  void debug(String message) {}  // suppress in production

  @override
  void info(String message) {}

  @override
  void warning(String message) => FirebaseCrashlytics.instance.log(message);

  @override
  void error(String message, {Object? error, StackTrace? stackTrace}) =>
      FirebaseCrashlytics.instance.recordError(error, stackTrace, reason: message);
}

// Inject during init — no call-site changes needed anywhere:
await FbaeCore.init(
  config: FbaeConfig.production(baseUrl: Env.baseUrl),
  logger: CrashlyticsLogger(),
);

Plugin System

fbae_core has a plugin interface that lets third-party packages (e.g. fbae_firebase) hook into the bootstrap lifecycle.

Implementing a plugin

class AnalyticsPlugin implements FbaePlugin {
  const AnalyticsPlugin();

  @override
  Future<void> configure(FbaePluginContext context) async {
    // context.config — read FbaeConfig values
    // context.logger — log via Talker
    // context.registerCrashReporter(impl) — replace NoOpCrashReporter
    // context.addLogObserver(observer) — add TalkerObserver to the pipeline

    context.logger.debug('AnalyticsPlugin: configured');
    await FirebaseAnalytics.instance.setAnalyticsCollectionEnabled(
      context.config.envName == 'production',
    );
  }
}

Registering plugins

Pass plugins to FbaeConfig or directly to FbaeCore.init():

await FbaeCore.init(
  config: FbaeConfig.production(
    baseUrl: Env.baseUrl,
    plugins: [const AnalyticsPlugin()],
  ),
);

Plugins are configured in declaration order, after logger, DI, and HTTP are ready. Plugin failures in debug mode rethrow immediately. In release/profile mode, failures are logged and the plugin is skipped — a single broken plugin never crashes the app.

FbaeConfig Reference

Parameter Type Default Description
baseUrl String required Base URL for HTTP client
envName String required Environment name ('dev', 'staging', 'production')
timeout Duration 30s HTTP connect/receive/send timeout
logLevel LogLevel debug (dev) / none (prod) Minimum log level
plugins List<FbaePlugin> [] Plugins to configure during bootstrap
featureFlags Map<String, bool> {} Immutable build-time feature flags
errorMessageKey String 'message' JSON key for error message in API responses
errorCodeKey String 'code' JSON key for error code in API responses

Factory constructors

FbaeConfig.dev(baseUrl: '...')         // envName: 'dev',        logLevel: debug
FbaeConfig.staging(baseUrl: '...')     // envName: 'staging',    logLevel: info
FbaeConfig.production(baseUrl: '...')  // envName: 'production', logLevel: none

All fields except baseUrl have sensible defaults per environment.

Testing

fbae_core exports a MockHttpClientAdapter in a separate testing barrel to keep test utilities out of production code:

// In your test file:
import 'package:fbae_core/fbae_core_testing.dart';

Mocking HTTP responses

test('loads products', () async {
  final adapter = MockHttpClientAdapter(
    handler: (options, _) async {
      return ResponseBody.fromString(
        '{"products": [{"id": 1, "title": "Phone", "price": 599.0, "thumbnail": ""}]}',
        200,
        headers: {Headers.contentTypeHeader: [Headers.jsonContentType]},
      );
    },
  );

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

  final products = await FbaeCore.instance.httpClient.get('/products');
  expect(products, isNotEmpty);
});

Testing cubits with bloc_test

Add bloc_test to your dev dependencies:

dev_dependencies:
  bloc_test: ^9.1.0
  flutter_test:
    sdk: flutter

Use blocTest to verify the full state emission sequence:

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

void main() {
  late MockHttpClientAdapter adapter;

  setUp(() async {
    adapter = MockHttpClientAdapter(
      handler: (options, _) async => ResponseBody.fromString(
        '{"products": [{"id": 1, "title": "Phone", "price": 599.0, "thumbnail": ""}]}',
        200,
        headers: {Headers.contentTypeHeader: [Headers.jsonContentType]},
      ),
    );
    await FbaeCore.init(
      config: FbaeConfig.dev(baseUrl: 'https://test.api'),
      httpClientAdapter: adapter,
    );
  });

  blocTest<ProductsCubit, AsyncState<List<Product>>>(
    'emits [AsyncLoading, AsyncData] on successful load',
    build: () => ProductsCubit(FbaeCore.instance.httpClient),
    act: (cubit) => cubit.load(),
    expect: () => [
      isA<AsyncLoading<List<Product>>>(),
      isA<AsyncData<List<Product>>>(),
    ],
    verify: (cubit) {
      final data = cubit.state as AsyncData<List<Product>>;
      expect(data.value, hasLength(1));
      expect(data.value.first.title, 'Phone');
    },
  );

  blocTest<ProductsCubit, AsyncState<List<Product>>>(
    'emits [AsyncLoading, AsyncError] on API failure',
    build: () {
      adapter.handler = (_, __) async => ResponseBody.fromString(
        '{"message": "Not found", "code": "NOT_FOUND"}',
        404,
      );
      return ProductsCubit(FbaeCore.instance.httpClient);
    },
    act: (cubit) => cubit.load(),
    expect: () => [
      isA<AsyncLoading<List<Product>>>(),
      isA<AsyncError<List<Product>>>(),
    ],
  );
}

Testing with specific HTTP error codes

test('handles 401 by emitting AsyncError', () async {
  final adapter = MockHttpClientAdapter(
    handler: (_, __) async => ResponseBody.fromString(
      '{"message": "Unauthorized"}', 401,
      headers: {Headers.contentTypeHeader: [Headers.jsonContentType]},
    ),
  );
  await FbaeCore.init(
    config: FbaeConfig.dev(baseUrl: 'https://test.api'),
    httpClientAdapter: adapter,
  );

  try {
    await FbaeCore.instance.httpClient.get('/protected');
    fail('should have thrown');
  } on ApiError catch (e) {
    expect(e.statusCode, 401);
    expect(e.message, 'Unauthorized');
  }
});

Common Patterns

Checking if user is authenticated

final isLoggedIn = await authManager.tokenProvider() != null;

Silent refresh without loading state

// Background data refresh — won't replace current UI with a spinner
Future<void> refresh() => run(() => fetchData(), silent: true);

Accessing FbaeCore after init anywhere in the app

// No context needed — access singleton directly
final client = FbaeCore.instance.httpClient;
final logger = FbaeCore.instance.logger;
final config = FbaeCore.instance.config;

Error handling by status code

try {
  await FbaeCore.instance.httpClient.post('/order', body: orderData);
} on ApiError catch (e) {
  switch (e.statusCode) {
    case 400: showValidationError(e.message);
    case 401: navigateToLogin();
    case 403: showPermissionDenied();
    case 422: showFieldErrors(e.rawBody);
    default:  showGenericError();
  }
}

Conditional feature flag usage

class SettingsPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final flags = FbaeCore.instance.config.featureFlags;
    return Column(
      children: [
        if (flags['dark_mode'] == true)
          ListTile(
            title: const Text('Dark mode'),
            trailing: Switch(
              value: context.watch<ThemeCubit>().state == ThemeMode.dark,
              onChanged: (_) => context.read<ThemeCubit>().toggle(),
            ),
          ),
      ],
    );
  }
}

Gotchas

FbaeCore.init() must be called before runApp(). Accessing FbaeCore.instance before init throws a StateError.

tokenProvider returning null omits the Authorization header entirely. This is intentional — unauthenticated requests skip the header automatically. No need to check auth state before HTTP calls.

onRefreshToken is called at most once for concurrent 401s. If 5 requests all receive 401 simultaneously, only the first triggers refresh. The other 4 are queued and retried automatically with the new token after refresh completes.

FbaeThemeProvider must wrap MaterialApp.router, not be inside it. ThemeCubit needs to be above the widget that reads themeMode. Placing it inside MaterialApp.router causes it to be rebuilt on route changes.

AsyncInitial is not the same as AsyncLoading. Use AsyncInitial to show an empty/placeholder state before the first load is triggered. Don't call run() in the cubit constructor — let the UI trigger the first load via context.read<MyCubit>().load().

emit() after cubit close is safe in BaseCubit. run() checks isClosed before emitting. Cancelling async operations mid-flight won't throw.


See the example app for a full working demo.

AI Assistant Context

If you use Cursor, GitHub Copilot, or Claude in your project, add llms.txt to give your AI assistant full context about the fbae_core API — all public classes, methods, and signatures in one compact file.

Add to your pubspec.yaml:

dependencies:
  fbae_core: ^0.1.9

Then fetch the context file:

flutter pub get
curl -o llms.txt https://raw.githubusercontent.com/imthanhhai217/fbae/main/llms.txt

Place llms.txt in your project root. AI tools index it automatically alongside your own code.

Libraries

fbae_core
Flutter Base App Engine — Core package.
fbae_core_testing
fbae_core testing utilities.