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(),
),
),
),
),
);
}
}