flutterx_application
Application foundation for Flutter apps: fragment navigation with optional go_router sync, view models with task tracking, token handling with refresh, and common widgets.
Installation
flutter pub add flutterx_application
import 'package:flutterx_application/flutterx_application.dart';
Fragments
A Fragment is a page of a FragmentHost, which shows one at a time and animates between them. Each fragment has a
constant FragmentKey and a FragmentState:
class HomeFragment extends Fragment {
static const FragmentKey fragmentKey = FragmentKey('home');
const HomeFragment({super.key = fragmentKey});
@override
FragmentState<HomeFragment> createState() => _HomeFragmentState();
}
class _HomeFragmentState extends FragmentState<HomeFragment> {
@override
void onResume() {
super.onResume(); // required
// the fragment is now the shown one
}
@override
void onPause() {
// the fragment is no longer shown, or was removed while shown
super.onPause(); // required
}
@override
Widget build(BuildContext context) => Text('${args<RouteArgs?>()}');
}
onResume and onPause are the only hooks; active tells whether the fragment is between them. args<X>() reads the
arguments of the entry that showed the fragment (null before the first one: use args<RouteArgs?>()).
The host is driven by a FragmentManager, created and kept by the owner of the host:
final manager = FragmentManager(key: 'main');
FragmentHost(
manager: manager,
storage: localStorage, // optional: persists the history
fragments: const [HomeFragment(), SettingsFragment()],
);
Inside a fragment, manager (or FragmentManager.of(context)) navigates:
manager.navigateToKey(SettingsFragment.fragmentKey, args: {'tab': 2});
manager.navigateTo<SettingsFragment>(replace: true); // first fragment of that type
manager.back(); // false if there is no history
manager.forward();
manager.canGoBack and canGoForward are LiveData<bool>. manager.currentFragment is the FragmentStack: the entry
shown plus the history. With a storage, history and index are saved at every change under <manager key>_fragment_stack
and restored when the host starts. The system back pops the history; once empty, the route pops if canPop is true.
Router sync
With a GoRouter above the host and syncLocationFragment: true (the default), the shown fragment is mirrored in the
fragment of the location (/page#settings) and a location with a known fragment selects it. Without GoRouter the flag
does nothing; set it to false to leave the location alone.
Unknown entries
unknownEntryStrategy decides what to do with an entry whose fragment is not in fragments (a restored history of an
older version, a link): FragmentHost.unknownEntryStrategyIdle (default) keeps the current fragment and leaves no trace
in the history, unknownEntryStrategyInitial shows initialFragment or the first fragment, unknownEntryStrategyCrash
throws a StateError. A custom strategy returns the entry to show instead, or null.
View models
ViewModelScope holds the view models of its subtree and clears them when it leaves the tree. Put one above the app:
class CounterViewModel extends ViewModel with TaskViewModel {
final MutableLiveData<int> count = MutableLiveData(value: 0);
CounterViewModel(super.provider);
static CounterViewModel of(BuildContext context) => context.viewModel(CounterViewModel.new);
Future<void> increment() async {
await Future<void>.delayed(const Duration(milliseconds: 800)).notify(this);
count.value++;
}
}
ViewModelScope(child: MaterialApp(home: const HomePage()));
context.viewModel(factory, key:) creates the view model the first time and returns the same instance afterwards;
view models are indexed by type and key, so several of one type coexist with different keys. Pass the constructor
tear-off: the type is inferred from it and must be the exact runtime type.
TaskViewModel tracks futures with future.notify(this, tag:): loading and loadingWithTag are LiveData<bool>
for loaders, activeTasks lists the running tasks, loadingDependsOn(other) makes a view model loading while another
is. The error of a failed task is rethrown to the caller.
TokenHandler
Keeps a token, refreshes it when it expires and turns it into the authorization header value. Reads and updates are serialized, so concurrent requests refresh it once:
final tokens = TokenHandler<Session>.storage(
key: 'session',
storage: localStorage,
toJson: (session) => session.toJson(),
fromJson: Session.fromJson,
isTokenValid: (session) => session.expiresAt.isAfter(DateTime.now()),
authorize: (session) => 'Bearer ${session.accessToken}',
refreshToken: (session) => api.refresh(session.refreshToken),
errorShouldClearToken: (error) => error is XHttpException && error.response?.statusCode == 401,
);
await tokens.updateToken(() => api.login(user, password));
final header = await tokens.authorization; // null when logged out
await tokens.clear();
TokenHandler(...) holds the token in memory only. requireAuthorization throws a TokenException when there is no
token; authorization throws it when the token cannot be renewed. Without refreshToken an expired token is dropped. A
failed refresh keeps the token unless errorShouldClearToken returns true. token and hasToken are LiveData.
Widgets
ActionButton<void>(label: 'Save', onAction: save, buttonBuilder: ActionButton.buttonFilled);
ActionButton<User>.positive(onAction: load, onActionResult: show);
ActionButton runs onAction and stays disabled until it completes; enabled adds an external condition. Define the
content with exactly one of label, child or builder.
NiceDialog<T>: anAlertDialogwith cancel and submit actions;submitFormvalidates (returntrueto close),submitResultis the value the dialog closes with. Show it withshowDialog.Message: a banner,Message.success(title: 'Saved').show(context); alsoinfo,warning,error.FloatingMenu: an anchor (builder) with a menu (overlayBuilder); both receive theOverlayPortalController.Initializer<T>: runsinitializeonce and builds with the result, a placeholder meanwhile and an error state if it fails.SimpleLoader,AppBarLoader(loading: viewModel.loading): circular and linear progress.FooterView: a footer kept at the bottom when the body is short.KeepAliveBuilder: keeps its subtree alive in a lazy list.ScopedData: creates a value once per state and builds with it.SystemUiOverlayStyleTheme: applies aSystemUiOverlayStyleto the system bars and to the app bars below.
Behavior
onResumeof a fragment runs at its firstdidChangeDependencies, after its wholeinitState.FragmentKeyis aLocalKeyequal by name: two hosts can hold fragments with the same name independently.- A navigation superseded by a newer one has no effect: only the last entry is shown.
ActionButtonis disabled whileonActionruns; its errors go toFlutterError.reportError,onActionResultis not called and the button is enabled again.Initializerrestartsinitializeafter an error whenever a new callback is given: pass a tear-off, not an inline closure, or every rebuild of the parent retries.FloatingMenuowns its controller and animation and releases them; whoever creates it releases nothing.- The spacing constants
m4…m80are no longer in the library: the app defines them in its own theme.
Claude Code plugin
The repository ships a plugin with skills that teach Claude Code to integrate the library:
/plugin marketplace add https://gitlab.com/dade.sliep/flutterx_application
/plugin install flutterx-application@flutterx-application
Libraries
- flutterx_application
- Application foundation for Flutter apps: fragment navigation with optional go_router sync, view models with task tracking, token handling and common widgets.