Skip to content

Latest commit

 

History

History
403 lines (322 loc) · 14.5 KB

README.md

File metadata and controls

403 lines (322 loc) · 14.5 KB

Query Stack

A simple yet powerful state management that makes fetching, caching, synchronizing and updating state in your Flutter applications a breeze.

Inspired by React Query

This package is inspired by the TanStack Query.

Features

  • Integrated dependency injection system.
  • Easy to use.
  • Promotes SOLID design patterns and Clean Architecture.
  • Promotes DRY pattern and the creation of domain plugins (i.e.: once an authentication plugin is created, it's highly reusable in other apps).

Usage

Query Stack has only 3 parts: dependency injection, stream builders and future builders.

Dependency Injection

Dependency injection in Query Stack works by creating environments.

You can create a DebugEnvironment with api keys or remote urls pointing to development resources and a ProductionEnvironment for real usage, pointing to real servers and api keys.

You can also create different classes that inherit Environment for flavours.

Usually, you will write an abstract BaseEnvironment that registers all common dependencies and only specialize in differences in your concrete DebugEnvironment and ProductionEnvironment.

Here is what a real-world environment looks like:

import 'package:flutter/foundation.dart';

import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:query_stack/query_stack.dart';
import 'package:query_stack_firebase_authentication/query_stack_firebase_authentication.dart';

import 'authentication/db_authentication_service.dart';
import 'companies/companies_service.dart';
import 'firebase_options.dart';

// This class is abstract because it contains common registrations
// between debug and production environments
@immutable
abstract class BaseEnvironment extends Environment {
  const BaseEnvironment();

  // This will be overriden by my concrete classes because
  // each environment will point to a different server url
  String get serverBaseUrl => throw UnimplementedException();

  // This method will be called when your app run
  //
  // Here you will configure what class will return when someone asks
  // for a specific type (that's called service locator)
  //
  // Since you have a `get` argument, you can inject other services into
  // the services you are registering (because one depends on another, that's
  // called dependency inversion principle)
  //
  // Just be careful about circular dependencies!
  @override
  void registerDependencies(RegisterDependenciesDelegate when, PlatformInfo platformInfo) {
    // This `AuthenticationService` is a Query Stack plugin provided by the
    // query_stack_firebase_authentication_service
    //
    // You can create plugins that are common for all your apps and reuse them
    when<AuthenticationService>(
      // Since I'm persisting my authenticated user in a database, I can inherit the
      // `AuthenticationService` and specialize it
      (get) => DBAuthenticationService(
        appleRedirectUrl: platformInfo.nativePlatform.when(
          onAndroid: () => "use this url on android",
          onWeb: () => "use this url on web",
          orElse: () => null,
        ),
        appleServiceId: "apple sign in service id",
        googleClientId: platformInfo.nativePlatform.when(
          onAndroid: () => "use this google client id on android",
          oniOS: () => "use this google client id on ios",
          onWeb: () => "use this google client id on web",
          orElse: () => throw UnsupportedError("${platformInfo.nativePlatform} is not supported"),
        ),
      ),
    );

    // This is a service of my app that uses the `AuthenticationService` to get the
    // authenticated user id
    //
    // Notice how I request `<AuthenticationService>` but registered a `DBAuthenticationService`
    // above. That will work because `CompaniesService` knows how to handle `AuthenticationService`
    // and inheriting that class won't change its behavior in this context (that's called Open-closed
    // principal and Liskov substitution principal)
    when<CompaniesService>(
      (get) => CompaniesService(
        authenticationService: get<AuthenticationService>(),
        // Since my service will call some remote API,
        // I need to know which server to use, and that base
        // url is different between debug and production environments
        serverBaseURL: serverBaseURL,
      ),
    );
  }

  // Each service registered inherits `BaseService` that has a
  // `void initialize()` and a `Future<void> initializeAsync()`.
  // You can override any of those methods if your service needs
  // an initialization (you can override none, one or both of them,
  // initializeAsync will be awaited, so if your initialization has
  // async methods, that's your guy)
  //
  // Just after this method, all services `initialize` and `initializeAsync`
  // will be called (if you didn't override them, they will be empty and
  // will have no effect)
  @override
  Future<void> initializeAsync(GetDelegate get) async {
    await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
    FirebaseAnalytics.instance.logAppOpen().ignore();
  }
}

// Since my development and production environment are the same, I need only
// to change specific settings (in my case, point to a local or remote API
// server, depending on the environment)
@immutable
class DevelopmentEnvironment extends BaseEnvironment {
  const DevelopmentEnvironment();

  @override
  String get serverBaseUrl => "http://localhost:8888";
}

@immutable
class ProductionEnvironment extends BaseEnvironment {
  const ProductionEnvironment();

  @override
  String get serverBaseUrl => "https://my-real-api-server.com";  
}

That being done, just use your environment on your main:

Future<void> main() async {
  await Environment.use(
    kDebugMode 
      ? const DevelopmentEnvironment() 
      : const ProductionEnvironment(),
  );

  runApp(const MainApp());
}

Here, I decide which environment to use based on kDebugMode, which is a Flutter const that tells me if I'm in debug or release mode.

You can choose your environment however you want, including using Flutter Flavors.

Stream Builders

Stream builders are basically a StreamBuilder with some easy-to-use features.

A real-world example would be changing which home widget is displayed depending on the currently authenticated user. For instance, set the MaterialApp.home property to:

return AuthenticationQuery(
  loginConfiguration: BaseLoginConfiguration(
    header: const AppHeader(),
    privacyPolicyText: "Política de Privacidade",
    privacyPolicyURL: "https://meucronogramacapilar.code.art.br/Privacy.html",
    termsOfUseText: "Termos de Uso",
    termsOfUseURL: "https://meucronogramacapilar.code.art.br/Terms.html",
    signInWithAppleText: "Entrar com Apple",
    signInWithGoogleText: "Entrar com Google",
    footerTextColor: Colors.white,
    progressIndicatorColor: Colors.white,
    backgroundColor: theme.primaryColor,
    onDebug: () => Navigator.of(context).push(
      MaterialPageRoute<void>(builder: (_) => DriftDbViewer(Database.instance)),
    ),
  ),
  builder: (_, principal) => Text(
    principal == null 
      ? "Not authenticated"
      : "${principal!.displayName} authenticated"
  ),
);

This AuthenticationQuery is a widget of the query_stack_firebase_authentication that will provide a LoginPage for your app automatically.

Its source code is:

class AuthenticationQuery extends StatelessWidget {
  const AuthenticationQuery({required this.loginConfiguration, required this.builder, super.key});

  final BaseLoginConfiguration loginConfiguration;
  final Widget Function(BuildContext context, Principal principal) builder;

  @override
  Widget build(BuildContext context) {
    return QueryStreamBuilder<Principal>(
      stream: AuthenticationService.current.currentPrincipalStream,
      emptyBuilder: (_) => BaseLoginPage(loginConfiguration: loginConfiguration),
      waitingBuilder: (_) => WaitingPage(header: loginConfiguration.header),
      errorBuilder: (_, __) => BaseLoginPage(loginConfiguration: loginConfiguration),
      initialData: AuthenticationService.current.currentPrincipalStream.hasValue ? AuthenticationService.current.currentPrincipalStream.value : null,
      onError: _onError,
      dataBuilder: builder,
    );
  }

  void _onError(BuildContext context, Object error) {
    showOkAlertDialog(
      context: context,
      title: "Erro inesperado",
      message: error.toString(),
    );
  }
}

The magic is done by the QueryStreamBuilder<Principal>.

The QueryStreamBuilder will listen to a stream (in this case, the AuthenticationService currentPrincipalStream that will change from null (no user authenticated to an instance of Principal (representing the authenticated user))).

Whenever the stream changes, some builder is called:

  • emptyBuilder will be called whenever the stream content is null or an empty Iterator (empty list, map or set).
  • waitingBuilder will be called whenever the stream is in a waiting state (the first time it initializes)
  • errorBuilder will be called whenever the stream has an error on it
  • dataBuilder will be called when the stream has a valid value.

In this case, empty means no user authenticated (so we will build a BaseLoginPage), data means an authenticated user `so we will build whatever the app wants, passing the currently authenticated user.

In other words: is a StreamBuilder where you don't have to deal with empty values, waiting for states and exceptions by yourself.

Future Builders

Future builders are special FutureBuilders that handle some neat things automatically for you.

For example, let's assume you need to build a different page, depending on the user having some settings configurated or not (in my example, the authenticated user has to configure some company stuff before entering the app, so, I'm using a remote api to check if the user already has some company configurated or if he needs to set up that now).

For that purpose, I can use the QueryFutureBuilder<bool> to ask my server if it has some company setup or not (which I call first access). Or I can wrap this in a specialized widget made for this purpose:

// By using a custom widget instead of a generic `QueryFutureBuilder<T>,`
// I don't need to manually handle query keys and I can have access
// to a typed specialized version of this data using
// `FirstAccessQuery.of(context)`
@immutable
class FirstAccessQuery extends StatelessWidget {
  const FirstAccessQuery({required this.builder, super.key});

  // This builder will be called with the response of my query
  final Widget Function(BuildContext context, bool firstAccessComplete) builder;

  // Each query must have a key, so I can access them later to
  // make it refresh (for instance: after setuping my first company
  // I can trigger a refetch on this widget manually)
  static final String queryKey = "${FirstAccessQuery}";

  // I just wrap a `QueryFutureBuilder<Response>` so I don't
  // repeat myself in the future and keep all things related
  // in a single place
  @override
  Widget build(BuildContext context) {
    return QueryFutureBuilder<bool>(
      queryKey: queryKey,
      // This is the method that will call my local database or
      // remote API and return me a `true` if this user has any
      // company registered or `false` if don't and I need to
      // do this now
      future: () => CompaniesService.current.getFirstAccessIsComplete(),
      dataBuilder: builder,
    );
  }

  // Some generic `maybeOf` and `of` of `InheritedModels` (those are
  // a special case of `InheritedWidget`s)
  static Query<bool>? maybeOf(BuildContext context) {
    return InheritedModel.inheritFrom<Query<bool>>(context, aspect: queryKey);
  }

  static Query<bool> of(BuildContext context) {
    final result = maybeOf(context);

    assert(result != null, "Unable to find an instance of FirstAccessQuery in the widget tree");

    return result!;
  }
}

So, I can now decide what to do based on whatever the user was previously setup:

...
return FirstAccessQuery(
  builder: (context, hasFirstAccess) {
    if(hasFirstAccess) {
      return const HomePage();
    }

    return const SetupCompanyPage();
  },
);
...

On SetupCompanyPage(), I can trigger a refresh by calling:

...
  final query = FirstAccessQuery.of(context);

  await query.refreshFn();
...

This will make the FirstAccessQuery() widget to reexecute the future function, calling my API again.

Also, QueryFutureBuilder<T> can also reexecute its feature when:

  • A navigation pops into the page where the QueryFutureBuilder<T> is in, if it is stale (stale is a duration after the execution of the future where the automatic refetch is not done. If you set this for 5 minutes, then all the automatic refetchs will only hit your API after 5 minutes since the last successful response)
  • You configure an automatic refetch timer using refetchInterval
  • Your app was in the background and now returns to the foreground

Also, QueryFutureBuilder<T> will attempt to reexecute the future n times ( configurated by the maxAttempts property) before yielding an error.

QueryFutureBuilder<T> will only set a waiting state (building a waitingBuilder, so you can show, for example, spinning progress while the data is being fetched) if keepPreviousDate is false, otherwise, nothing will change until the fetch is done when the dataBuilder will be executed with the new data and your screen will refresh.

For simple values (such as the default Flutter counter sample), you can define a mutable service that has some internal _count variable that is handled by functions and those functions triggers a stream:

class CounterService extends BaseService {
  CounterService(this.initialValue);

  static CounterService get current => Environment.get<CounterService>();

  final int? initialValue;

  // Use the package `rxDart` for this `BehaviorSubject`:
  final _streamController = BehaviorSubject<int>(initialValue ?? 0); 

  Stream<int> get counterStream => _streamController.stream;

  void addToCounter() {
    final currentValue = _streamController.value;

    _streamController.add(currentValue + 1);
  }
}

And use the QueryStreamBuilder:

...
return QueryStreamBuilder<int>(
      stream: CounterService.current.counterStream,
      initialData: 1,
      dataBuilder: (context, value) => Text("Counter: ${value}"),
    )
...

This way, your counter value is immutable outside your service (which is the major problem with Provider).