fbae_core 0.1.9
fbae_core: ^0.1.9 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.
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_coreguaranteesonRefreshTokenis 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
Navigation #
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.