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>: an AlertDialog with cancel and submit actions; submitForm validates (return true to close), submitResult is the value the dialog closes with. Show it with showDialog.
  • Message: a banner, Message.success(title: 'Saved').show(context); also info, warning, error.
  • FloatingMenu: an anchor (builder) with a menu (overlayBuilder); both receive the OverlayPortalController.
  • Initializer<T>: runs initialize once 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 a SystemUiOverlayStyle to the system bars and to the app bars below.

Behavior

  • onResume of a fragment runs at its first didChangeDependencies, after its whole initState.
  • FragmentKey is a LocalKey equal 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.
  • ActionButton is disabled while onAction runs; its errors go to FlutterError.reportError, onActionResult is not called and the button is enabled again.
  • Initializer restarts initialize after an error whenever a new callback is given: pass a tear-off, not an inline closure, or every rebuild of the parent retries.
  • FloatingMenu owns its controller and animation and releases them; whoever creates it releases nothing.
  • The spacing constants m4…m80 are 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.