Incidental new state management

I was tinkering some way to build an offline-first app that would do a local-first database, syncing with a remote Supabase (show local db, update UI, fetch remote, if available, if remote is new, update local db then update UI).

So, I thought: using Stream<State> would be nice, especially when State is sealed. I could use Drift and Supabase stream capabilities to wire up all reactivity with little effort.

So, I did up writing this single class that a) solve all reactivity, b) allow to mutate state within its own scope and c) it would be testable, if I delegate all implementations to some dependency inversion fx (such as get_it).

Any opinions about this?

import 'dart:developer';

import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';

abstract base class BaseStreamer<T> extends StatelessWidget {
  const BaseStreamer({super.key});

  Stream<T> stream(BuildContext context);

  @protected
  void initState(BuildContext context) {}

  @protected
  void postInitState(BuildContext context) {}

  @protected
  void didChangeDependencies(BuildContext context) {}

  @protected
  void dispose(BuildContext context) {}

  @protected
  Widget buildWaiter(BuildContext context) {
    return const SizedBox.shrink();
  }

  @protected
  Widget buildError(
    BuildContext context,
    Object error,
    StackTrace stackTrace,
  ) {
    return ErrorWidget(error);
  }

  @protected
  Widget buildState(BuildContext context, T state);

  @protected
  @override
  @nonVirtual
  Widget build(BuildContext context) {
    return _BaseStreamer<T>(this);
  }
}

final class _BaseStreamer<T> extends StatefulWidget {
  const _BaseStreamer(this.streamer, {super.key});

  final BaseStreamer<T> streamer;

  @override
  State<_BaseStreamer<T>> createState() => _BaseStreamerState<T>();
}

final class _BaseStreamerState<T> extends State<_BaseStreamer<T>> {
  @override
  void initState() {
    log("Initializing", name: widget.streamer.runtimeType.toString());
    super.initState();
    widget.streamer.initState(context);

    SchedulerBinding.instance.addPostFrameCallback(
      (delta) {
        log(
          "Post initializing (delta: ${delta.inMilliseconds})",
          name: widget.streamer.runtimeType.toString(),
        );

        widget.streamer.postInitState(context);
      },
    );
  }

  @override
  void didChangeDependencies() {
    log("Dependencies changed", name: widget.streamer.runtimeType.toString());
    super.didChangeDependencies();
    widget.streamer.didChangeDependencies(context);
  }

  @override
  void dispose() {
    log("Disposing", name: widget.streamer.runtimeType.toString());
    super.dispose();
    widget.streamer.dispose(context);
  }

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<T>(
      stream: widget.streamer.stream(context),
      builder: (context, snapshot) {
        if (snapshot.hasError) {
          log(
            "Error",
            error: snapshot.error,
            stackTrace: snapshot.stackTrace,
            name: widget.streamer.runtimeType.toString(),
          );

          widget.streamer.buildError(
            context,
            snapshot.error!,
            snapshot.stackTrace!,
          );
        }

        if (snapshot.connectionState == ConnectionState.waiting) {
          log("Waiting", name: widget.streamer.runtimeType.toString());
          return widget.streamer.buildWaiter(context);
        }

        final state = snapshot.data as T;

        log("State ${state}", name: widget.streamer.runtimeType.toString());

        return widget.streamer.buildState(context, state);
      },
    );
  }
}

My main app (that checks if the user is authenticated or not and load a theme setting if it is authenticated) is starting to be like this:

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

import 'package:flutter_native_splash/flutter_native_splash.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:material_color_utilities/material_color_utilities.dart';

import '../../shared/presentation/theme_region.dart';
import '../../shared/streams/base_streamer.dart';
import '../entities/app_state.dart';
import '../entities/theme_settings.dart';

final class AppStream extends BaseStreamer<AppState> {
  const AppStream({super.key});

  @override
  void postInitState(BuildContext context) {
    FlutterNativeSplash.remove();
  }

  @override
  Stream<AppState> stream(BuildContext context) async* {
    // Here I can initialize supabase, the local database, etc., and check if the 
    // user is authenticated and then listen to the local database user settings
    // table to issue some new ThemeSettings when that changes

    yield const UnauthenticatedAppState(
      themeSettings: ThemeSettings(
        seedColor: Colors.pink,
        themeMode: ThemeMode.system,
        usePastelColors: true,
        fontFamily: "Lexend",
      ),
    );
  }

  static ThemeData _buildTheme(
    Brightness brightness,
    ThemeSettings themeSettings,
  ) {
   // Build the theme (removed for brevity)
  }

  @override
  Widget buildState(BuildContext context, AppState state) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: _buildTheme(Brightness.light, state.themeSettings),
      darkTheme: _buildTheme(Brightness.dark, state.themeSettings),
      themeMode: state.themeSettings.themeMode,
      builder: (context, child) => ThemeRegion(child: child!),
      home: Scaffold(
        body: Center(
          child: FilledButton(
            onPressed: () {},
            child: Text(
              // Here I can do a nice `switch` with exhaustive checking on my sealed state
              state.toJson(),
            ),
          ),
        ),
      ),
    );
  }
}

1 Like

Why not just use watchStream from watch_it?

Because reactivity is only part of the game. That class will contain methods as well, making it a Controller.

Stream for the sake of stream we’re already have StreamBuilder.

I wouldn’t include that handling in there TBH. If you enjoy rolling your own state management, sure :slight_smile:

Too late. Already did. And I’m loving it =)

1 Like