Created
February 22, 2024 10:25
-
-
Save sma/d2263a1af471d4e07e07989c79ae5f75 to your computer and use it in GitHub Desktop.
a demo application using Firebase & signals
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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