fbae_core 0.1.2 copy "fbae_core: ^0.1.2" to clipboard
fbae_core: ^0.1.2 copied to clipboard

Flutter Base App Engine — pluggable infrastructure package.

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.1

Then run:

flutter pub get

Quick Start #

import 'package:fbae_core/fbae_core.dart';

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

  await FbaeCore.init(
    config: FbaeConfig.dev(baseUrl: 'https://dev.api.example.com'),
  );

  runApp(const MyApp());
}

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},
  ),
);

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 #

test('ProductsCubit emits AsyncData on success', () async {
  final cubit = ProductsCubit(FbaeCore.instance.httpClient);
  
  await cubit.load();
  
  expect(cubit.state, isA<AsyncData<List<Product>>>());
  final data = cubit.state as AsyncData<List<Product>>;
  expect(data.value, hasLength(1));
});

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.1

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.

1
likes
0
points
147
downloads

Publisher

unverified uploader

Weekly Downloads

Flutter Base App Engine — pluggable infrastructure package.

Repository (GitHub)
View/report issues

License

unknown (license)

Dependencies

dio, flutter, flutter_bloc, get_it, go_router, talker, talker_dio_logger, talker_flutter

More

Packages that depend on fbae_core