flutterx_application 3.0.0
flutterx_application: ^3.0.0 copied to clipboard
Application foundation for Flutter apps: fragment navigation with optional go_router sync, view models, token handling and common widgets.
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