fbae_core 0.1.0
fbae_core: ^0.1.0 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.
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.
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 onChange, onTransition, and onError event through AppLogger — no configuration
required. In profile/release builds the verbosity is reduced to errors only, matching the
LogLevel from FbaeConfig.
See the example app for a full working demo.