Skip to content

Instantly share code, notes, and snippets.

@sma
Created February 22, 2024 10:25
Show Gist options
  • Save sma/d2263a1af471d4e07e07989c79ae5f75 to your computer and use it in GitHub Desktop.
Save sma/d2263a1af471d4e07e07989c79ae5f75 to your computer and use it in GitHub Desktop.
a demo application using Firebase & signals
import 'dart:async';
import 'dart:collection';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'firebase_options.dart';
// let ------------------------------------------------------------------------
extension Let<T> on T {
R let<R>(R Function(T it) f) => f(this);
}
// equatable ------------------------------------------------------------------
mixin Equatable {
@override
bool operator ==(Object other) {
// a hack because I didn't want to write a proper equals method
return identical(this, other) || other is Equatable && toString() == other.toString();
}
@override
int get hashCode => toString().hashCode;
}
// data model -----------------------------------------------------------------
@immutable
abstract class Model with Equatable {
const Model(this.id);
final String id;
}
class Game extends Model {
const Game(super.id, this.name);
final String name;
@override
String toString() => 'Game($id, $name)';
}
class Sheet extends Model {
const Sheet(super.id, this.name, this.stats);
final String name;
final Map<String, dynamic> stats;
@override
String toString() {
// so that my hacky equals method works
return 'Sheet($id, $name, ${(stats.entries.toList()..sort((a, b) => a.key.compareTo(b.key))).map((e) => '${e.key}:${e.value}').join(',')})';
}
int? intStat(String name) {
final value = stats[name];
if (value is int) return value;
if (value is String) return int.tryParse(value);
return null;
}
}
@immutable
class ModelList<E> with IterableMixin<E> {
const ModelList([this.elements = const []]);
ModelList.of(Iterable<E> i) : elements = i.toList();
final List<E> elements;
@override
String toString() => elements.toString();
@override
bool operator ==(Object other) {
return identical(this, other) || other is ModelList && listEquals(elements, other.elements);
}
@override
int get hashCode => Object.hashAll(elements);
E operator [](int index) => elements[index];
@override
Iterator<E> get iterator => elements.iterator;
}
extension ModelListExt<E extends Model> on ModelList<E> {
ModelList<String> get ids => ModelList.of(elements.map((e) => e.id));
E? byId(String id) {
for (final element in elements) {
if (element.id == id) {
return element;
}
}
return null;
}
}
// firestore ------------------------------------------------------------------
final firestore = FirebaseFirestore.instance;
Game gameFromSnapshot(DocumentSnapshot ds) {
return Game(ds.id, ds['name'] as String);
}
Game? optionalGameFromSnapshot(DocumentSnapshot? ds) {
return ds == null || !ds.exists ? null : gameFromSnapshot(ds);
}
ModelList<Game> gamesFromSnapshot(QuerySnapshot qs) {
return ModelList.of(qs.docs.map(gameFromSnapshot));
}
Sheet sheetFromSnapshot(DocumentSnapshot ds) {
return Sheet(ds.id, ds['name'] as String, ds['stats'] as Map<String, dynamic>);
}
Sheet? optionalSheetFromSnapshot(DocumentSnapshot? ds) {
return ds == null || !ds.exists ? null : sheetFromSnapshot(ds);
}
ModelList<Sheet> sheetsFromSnapshot(QuerySnapshot qs) {
return ModelList.of(qs.docs.map(sheetFromSnapshot));
}
Stream<ModelList<Game>> games() {
return firestore //
.collection('games')
.orderBy('name')
.snapshots()
.map(gamesFromSnapshot);
}
Stream<Game?> gameById(String gameId) {
return firestore //
.collection('games')
.doc(gameId)
.snapshots()
.map(optionalGameFromSnapshot);
}
Future<String> addGame(String name) async {
return (await firestore.collection('games').add({'name': name})).id;
}
Future<void> deleteGame(String id) async {
await firestore.collection('games').doc(id).delete();
}
Stream<ModelList<Sheet>> sheetsForGame(String gameId) {
return firestore //
.collection('games')
.doc(gameId)
.collection('characters')
.orderBy('name')
.snapshots()
.map(sheetsFromSnapshot);
}
Stream<Sheet?> sheetForGameById(String gameId, String sheetId) {
return firestore //
.collection('games')
.doc(gameId)
.collection('characters')
.doc(sheetId)
.snapshots()
.map(optionalSheetFromSnapshot);
}
Future<String> addSheetForGame(String gameId, String name) async {
final ref = await firestore //
.collection('games')
.doc(gameId)
.collection('characters')
.add({
'name': name,
'stats': <String, dynamic>{},
});
return ref.id;
}
Future<void> deleteSheetForGame(String gameId, String sheetId) async {
await firestore //
.collection('games')
.doc(gameId)
.collection('characters')
.doc(sheetId)
.delete();
}
Future<void> updateStatForGameAndSheet(String gameId, String sheetId, String name, Object? value) async {
print('updating stat: $name = $value ($gameId, $sheetId)');
return firestore //
.collection('games')
.doc(gameId)
.collection('characters')
.doc(sheetId)
.update({
FieldPath(['stats', name]): value
});
}
// signals --------------------------------------------------------------------
extension<T> on ReadonlySignal<T> {
/// Returns a new signal that maps the receiver's value using [fn].
ReadonlySignal<U> map<U>(U Function(T value) fn) => select((sgnl) => fn(sgnl()));
}
extension<T> on AsyncSignal<T> {
/// Returns a new signal that emits the receiver's value, ignoring loading
/// and error states for simplicity (they will throw errors).
ReadonlySignal<T> get sync => map((state) => state.requireValue);
}
/// Creates a signal that emits a [ModelList] events from a Firestore stream
/// returned by the reactive [callback], ignoring loading and error states.
ReadonlySignal<ModelList<T>> modelListSignal<T extends Model>(Stream<ModelList<T>> Function() callback) {
return streamSignal<ModelList<T>>(callback, initialValue: const ModelList()).sync;
}
/// The current list of games.
final games$ = modelListSignal(games);
/// The identifier of the currently selected game, default to `null`.
final currentGameId$ = signal<String?>(null);
/// The currently selected game, or `null` if no game is selected.
final currentGame$ = computed(() => currentGameId$()?.let(games$().byId));
/// The list of sheets for the currently selected game,
/// or an empty list of no game was selected.
final sheets$ = modelListSignal<Sheet>(() {
if (currentGameId$() case final gameId?) {
return sheetsForGame(gameId);
}
return const Stream.empty();
});
/// The identifier of the currently selected sheet, default to `null`.
final currentSheetId$ = signal<String?>(null);
/// The currently selected sheet, or `null` if no sheet is selected or if
/// no game is selected or if the selected sheet does not exist anymore.
final currentSheet$ = streamSignal<Sheet?>(() {
print('recomputing currentSheet');
final gameId = currentGameId$();
final sheetId = currentSheetId$();
if (gameId != null && sheetId != null) return sheetForGameById(gameId, sheetId);
return const Stream.empty();
}).sync;
// flutter --------------------------------------------------------------------
Future<void> main() async {
runApp(const Initialize(child: MyApp()));
}
class Initialize extends StatelessWidget {
const Initialize({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return FutureBuilder(
// ignore: discarded_futures
future: Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform),
builder: (context, snapshot) {
if (snapshot.hasData) return child;
if (snapshot.hasError) return ErrorWidget(snapshot.error!);
return const Center(child: CircularProgressIndicator());
},
);
}
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
listTileTheme: const ListTileThemeData(
selectedColor: Colors.white,
selectedTileColor: Colors.pink,
),
),
debugShowCheckedModeBanner: false,
home: const GamesPage(),
);
}
}
class GamesPage extends StatelessWidget {
const GamesPage({super.key});
@override
Widget build(BuildContext context) {
print('rebuilding GamesPage');
return const Scaffold(
body: Row(
children: [
Expanded(child: GamesList()),
Expanded(child: SheetsList()),
Expanded(child: SheetView()),
],
),
);
}
}
class GamesList extends StatelessWidget {
const GamesList({super.key});
@override
Widget build(BuildContext context) {
print('rebuilding GamesList');
return Column(
children: [
Expanded(
child: ListView(
children: [
...games$.watch(context).map((game) {
return ListTile(
onTap: () {
currentGameId$.value = game.id;
},
selected: currentGameId$.watch(context) == game.id,
title: Text(game.name),
subtitle: Text(game.id),
);
}),
],
),
),
const GamesListButtons(),
],
);
}
}
class GamesListButtons extends StatelessWidget {
const GamesListButtons({super.key});
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(color: Theme.of(context).colorScheme.primaryContainer),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Row(
children: [
StatIconButton(
onPressed: () async => _add(context),
icon: const Icon(Icons.add),
),
Gap.hxxs,
StatIconButton(
onPressed: currentGame$.watch(context) != null ? () async => _delete(context) : null,
icon: const Icon(Icons.remove),
),
],
),
),
);
}
Future<void> _add(BuildContext context) async {
final name = await showDialog<String>(
context: context,
builder: (_) => const InputDialog(title: Text('Add game')),
);
if (name != null && name.trim().isNotEmpty) {
await addGame(name.trim());
}
}
Future<void> _delete(BuildContext context) async {
final gameId = currentGameId$();
if (gameId != null) {
await deleteGame(gameId);
}
}
}
class SheetsList extends StatelessWidget {
const SheetsList({super.key});
@override
Widget build(BuildContext context) {
final ids = computed(() => sheets$().ids).watch(context);
print('rebuilding SheetsList: $ids');
return Column(
children: [
Expanded(
child: ListView(
children: [
...ids.map((id) {
return ListTile(
onTap: () => currentSheetId$.value = id,
selected: currentSheetId$.watch(context) == id,
title: Watch((context) {
return Text(computed(() => sheets$().byId(id)?.name ?? '...')());
}),
subtitle: Text(id),
);
}),
],
),
),
const SheetsListButtons(),
],
);
}
}
class SheetsListButtons extends StatelessWidget {
const SheetsListButtons({super.key});
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(color: Theme.of(context).colorScheme.primaryContainer),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Row(
children: [
StatIconButton(
onPressed: () async => _add(context),
icon: const Icon(Icons.add),
),
Gap.hxxs,
StatIconButton(
onPressed: currentSheet$.watch(context) != null ? () async => _delete(context) : null,
icon: const Icon(Icons.remove),
),
],
),
),
);
}
Future<void> _add(BuildContext context) async {
final name = await showDialog<String>(
context: context,
builder: (_) => const InputDialog(title: Text('Add sheet')),
);
if (name != null && name.trim().isNotEmpty) {
final gameId = currentGameId$();
if (gameId != null) {
await addSheetForGame(gameId, name.trim());
}
}
}
Future<void> _delete(BuildContext context) async {
final gameId = currentGameId$();
final sheetId = currentSheetId$();
if (gameId != null && sheetId != null) {
await deleteSheetForGame(gameId, sheetId);
}
}
}
class InputDialog extends StatefulWidget {
const InputDialog({super.key, required this.title});
final Widget title;
@override
State<InputDialog> createState() => _InputDialogState();
}
class _InputDialogState extends State<InputDialog> {
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Builder(builder: (context) {
return AlertDialog(
title: widget.title,
content: TextField(
autofocus: true,
controller: _controller,
decoration: const InputDecoration(
border: OutlineInputBorder(),
),
onSubmitted: (value) {
Navigator.pop(context, value);
},
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, _controller.text),
child: const Text('OK'),
),
],
);
});
}
}
class SheetView extends StatelessWidget {
const SheetView({super.key});
@override
Widget build(BuildContext context) {
print('rebuilding SheetView');
if (currentSheet$.map((sheet) => sheet?.id).watch(context) == null) {
return const Align(
alignment: AlignmentDirectional.topStart,
child: Padding(
padding: EdgeInsets.all(16),
child: Text('No sheet selected'),
),
);
}
return ListView(
padding: const EdgeInsets.all(16),
children: [
Watch((context) {
final name = currentSheet$.map((sheet) => sheet?.name)();
return Text(name ?? '…', style: Theme.of(context).textTheme.bodyLarge);
}),
const StatField(name: 'Physique'),
const StatField(name: 'Precision'),
const StatField(name: 'Logic'),
const StatField(name: 'Empathy'),
const Divider(),
const StatField(name: 'Agility'),
const StatField(name: 'Close Combat'),
const StatField(name: 'Force'),
const StatField(name: 'Medicine'),
const StatField(name: 'Ranged Combat'),
const StatField(name: 'Stealth'),
const StatField(name: 'Investigation'),
const StatField(name: 'Learning'),
const StatField(name: 'Vigilance'),
const StatField(name: 'Inspiration'),
const StatField(name: 'Manipulation'),
const StatField(name: 'Observation'),
],
);
}
}
class StatField extends StatelessWidget {
const StatField({super.key, required this.name, String? label}) : label = label ?? name;
final String name;
final String label;
@override
Widget build(BuildContext context) {
final value = computed(() => currentSheet$()?.intStat(name) ?? 0).watch(context);
print('rebuilding StatField: $name = $value');
return SizedBox(
height: 32,
child: Row(
children: [
Text('$name:'),
const Spacer(),
Gap.hm,
Text('$value'),
Gap.hs,
StatIconButton(
onPressed: () async => setStat(name, value + 1),
icon: const Icon(Icons.add),
),
Gap.hxxs,
StatIconButton(
onPressed: () async => setStat(name, value - 1),
icon: const Icon(Icons.remove),
),
],
),
);
}
static Future<void> setStat(String name, Object? value) async {
final gameId = currentGameId$();
final sheetId = currentSheetId$();
if (gameId != null && sheetId != null) {
await updateStatForGameAndSheet(gameId, sheetId, name, '$value');
}
}
}
class StatIconButton extends StatelessWidget {
const StatIconButton({super.key, required this.onPressed, required this.icon});
final VoidCallback? onPressed;
final Widget icon;
@override
Widget build(BuildContext context) {
return IconButton.filled(
style: IconButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
fixedSize: const Size(20, 20),
iconSize: 12,
),
onPressed: onPressed,
icon: icon,
);
}
}
class Gap {
const Gap._();
static const hxxs = SizedBox(width: 2);
static const hxs = SizedBox(width: 4);
static const hs = SizedBox(width: 8);
static const hm = SizedBox(width: 16);
static const hl = SizedBox(width: 24);
static const hxl = SizedBox(width: 32);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment