Merge branch 'main' into feat/multiball-asset

pull/235/head
Rui Miguel Alonso 3 years ago committed by GitHub
commit beebc59e32
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -0,0 +1,77 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:pinball/l10n/l10n.dart';
import 'package:pinball/theme/theme.dart';
import 'package:pinball_ui/pinball_ui.dart';
/// {@template footer}
/// Footer widget with links to the main tech stack.
/// {@endtemplate}
class Footer extends StatelessWidget {
/// {@macro footer}
const Footer({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(50, 0, 50, 32),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: const [
_MadeWithFlutterAndFirebase(),
_GoogleIO(),
],
),
);
}
}
class _GoogleIO extends StatelessWidget {
const _GoogleIO({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final theme = Theme.of(context);
return Text(
l10n.footerGoogleIOText,
style: theme.textTheme.bodyText1!.copyWith(color: AppColors.white),
);
}
}
class _MadeWithFlutterAndFirebase extends StatelessWidget {
const _MadeWithFlutterAndFirebase({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final theme = Theme.of(context);
return RichText(
textAlign: TextAlign.center,
text: TextSpan(
text: l10n.footerMadeWithText,
style: theme.textTheme.bodyText1!.copyWith(color: AppColors.white),
children: <TextSpan>[
TextSpan(
text: l10n.footerFlutterLinkText,
recognizer: TapGestureRecognizer()
..onTap = () => openLink('https://flutter.dev'),
style: const TextStyle(
decoration: TextDecoration.underline,
),
),
const TextSpan(text: ' & '),
TextSpan(
text: l10n.footerFirebaseLinkText,
recognizer: TapGestureRecognizer()
..onTap = () => openLink('https://firebase.google.com'),
style: const TextStyle(
decoration: TextDecoration.underline,
),
),
],
),
);
}
}

@ -23,7 +23,7 @@ class AndroidAcres extends Blueprint {
children: [ children: [
ScoringBehavior(points: 20000), ScoringBehavior(points: 20000),
], ],
)..initialPosition = Vector2(-32.6, -9.2), )..initialPosition = Vector2(-32.8, -9.2),
AndroidBumper.cow( AndroidBumper.cow(
children: [ children: [
ScoringBehavior(points: 20), ScoringBehavior(points: 20),

@ -13,6 +13,7 @@ extension PinballGameAssetsX on PinballGame {
const dinoTheme = DinoTheme(); const dinoTheme = DinoTheme();
return [ return [
images.load(components.Assets.images.boardBackground.keyName),
images.load(components.Assets.images.ball.ball.keyName), images.load(components.Assets.images.ball.ball.keyName),
images.load(components.Assets.images.ball.flameEffect.keyName), images.load(components.Assets.images.ball.flameEffect.keyName),
images.load(components.Assets.images.signpost.inactive.keyName), images.load(components.Assets.images.signpost.inactive.keyName),

@ -9,11 +9,10 @@ import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:pinball/game/game.dart'; import 'package:pinball/game/game.dart';
import 'package:pinball/gen/assets.gen.dart';
import 'package:pinball_audio/pinball_audio.dart'; import 'package:pinball_audio/pinball_audio.dart';
import 'package:pinball_components/pinball_components.dart' hide Assets; import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart'; import 'package:pinball_flame/pinball_flame.dart';
import 'package:pinball_theme/pinball_theme.dart' hide Assets; import 'package:pinball_theme/pinball_theme.dart';
class PinballGame extends Forge2DGame class PinballGame extends Forge2DGame
with with
@ -46,6 +45,7 @@ class PinballGame extends Forge2DGame
unawaited(add(gameFlowController = GameFlowController(this))); unawaited(add(gameFlowController = GameFlowController(this)));
unawaited(add(CameraController(this))); unawaited(add(CameraController(this)));
unawaited(add(Backboard.waiting(position: Vector2(0, -88)))); unawaited(add(Backboard.waiting(position: Vector2(0, -88))));
await add(BoardBackgroundSpriteComponent());
await add(Drain()); await add(Drain());
await add(BottomGroup()); await add(BottomGroup());
unawaited(addFromBlueprint(Boundaries())); unawaited(addFromBlueprint(Boundaries()));
@ -186,26 +186,25 @@ class DebugPinballGame extends PinballGame with FPSCounter {
@override @override
Future<void> onLoad() async { Future<void> onLoad() async {
await super.onLoad(); await super.onLoad();
await _loadBackground();
await add(_DebugInformation()); await add(_DebugInformation());
} }
// TODO(alestiago): Move to PinballGame once we have the real background // TODO(allisonryan0002): Remove after google letters have been correctly
// component. // placed.
Future<void> _loadBackground() async { // Future<void> _loadBackground() async {
final sprite = await loadSprite( // final sprite = await loadSprite(
Assets.images.components.background.path, // Assets.images.components.background.path,
); // );
final spriteComponent = SpriteComponent( // final spriteComponent = SpriteComponent(
sprite: sprite, // sprite: sprite,
size: Vector2(120, 160), // size: Vector2(120, 160),
anchor: Anchor.center, // anchor: Anchor.center,
) // )
..position = Vector2(0, -7.8) // ..position = Vector2(0, -7.8)
..priority = RenderPriority.background; // ..priority = RenderPriority.boardBackground;
await add(spriteComponent); // await add(spriteComponent);
} // }
@override @override
void onTapUp(TapUpInfo info) { void onTapUp(TapUpInfo info) {

@ -107,5 +107,21 @@
"rounds": "Ball Ct:", "rounds": "Ball Ct:",
"@rounds": { "@rounds": {
"description": "Text displayed on the scoreboard widget to indicate rounds left" "description": "Text displayed on the scoreboard widget to indicate rounds left"
},
"footerMadeWithText": "Made with ",
"@footerMadeWithText": {
"description": "Text shown on the footer which mentions technologies used to build the app."
},
"footerFlutterLinkText": "Flutter",
"@footerFlutterLinkText": {
"description": "Text on the link shown on the footer which navigates to the Flutter page"
},
"footerFirebaseLinkText": "Firebase",
"@footerFirebaseLinkText": {
"description": "Text on the link shown on the footer which navigates to the Firebase page"
},
"footerGoogleIOText": "Google I/O",
"@footerGoogleIOText": {
"description": "Text shown on the footer which mentions Google I/O"
} }
} }

@ -1,15 +0,0 @@
{
"@@locale": "es",
"play": "Jugar",
"@play": {
"description": "Text displayed on the landing page play button"
},
"start": "Comienzo",
"@start": {
"description": "Text displayed on the character selection page start button"
},
"characterSelectionTitle": "¡Elige a tu personaje!",
"@characterSelectionTitle": {
"description": "Title text displayed on the character selection page"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

@ -14,6 +14,10 @@ class $AssetsImagesGen {
$AssetsImagesBackboardGen get backboard => const $AssetsImagesBackboardGen(); $AssetsImagesBackboardGen get backboard => const $AssetsImagesBackboardGen();
$AssetsImagesBallGen get ball => const $AssetsImagesBallGen(); $AssetsImagesBallGen get ball => const $AssetsImagesBallGen();
$AssetsImagesBaseboardGen get baseboard => const $AssetsImagesBaseboardGen(); $AssetsImagesBaseboardGen get baseboard => const $AssetsImagesBaseboardGen();
/// File path: assets/images/board-background.png
AssetGenImage get boardBackground =>
const AssetGenImage('assets/images/board-background.png');
$AssetsImagesBoundaryGen get boundary => const $AssetsImagesBoundaryGen(); $AssetsImagesBoundaryGen get boundary => const $AssetsImagesBoundaryGen();
$AssetsImagesDashGen get dash => const $AssetsImagesDashGen(); $AssetsImagesDashGen get dash => const $AssetsImagesDashGen();
$AssetsImagesDinoGen get dino => const $AssetsImagesDinoGen(); $AssetsImagesDinoGen get dino => const $AssetsImagesDinoGen();

@ -0,0 +1,26 @@
// ignore_for_file: public_member_api_docs
import 'package:flame/components.dart';
import 'package:pinball_components/pinball_components.dart';
class BoardBackgroundSpriteComponent extends SpriteComponent with HasGameRef {
BoardBackgroundSpriteComponent()
: super(
anchor: Anchor.center,
priority: RenderPriority.boardBackground,
position: Vector2(0, -1),
);
@override
Future<void> onLoad() async {
await super.onLoad();
final sprite = Sprite(
gameRef.images.fromCache(
Assets.images.boardBackground.keyName,
),
);
this.sprite = sprite;
size = sprite.originalSize / 10;
}
}

@ -3,6 +3,7 @@ export 'android_spaceship.dart';
export 'backboard/backboard.dart'; export 'backboard/backboard.dart';
export 'ball.dart'; export 'ball.dart';
export 'baseboard.dart'; export 'baseboard.dart';
export 'board_background_sprite_component.dart';
export 'board_dimensions.dart'; export 'board_dimensions.dart';
export 'board_side.dart'; export 'board_side.dart';
export 'boundaries.dart'; export 'boundaries.dart';

@ -33,13 +33,13 @@ abstract class RenderPriority {
// TODO(allisonryan0002): fix this magic priority. Could bump all priorities // TODO(allisonryan0002): fix this magic priority. Could bump all priorities
// so there are no negatives. // so there are no negatives.
static const int background = 3 * _below + _base; static const int boardBackground = 3 * _below + _base;
// Boundaries // Boundaries
static const int bottomBoundary = _above + dinoBottomWall; static const int bottomBoundary = _above + dinoBottomWall;
static const int outerBoundary = _above + background; static const int outerBoundary = _above + boardBackground;
static const int outerBottomBoundary = _above + rocket; static const int outerBottomBoundary = _above + rocket;

@ -45,6 +45,7 @@ flutter:
- asset: fonts/PixeloidMono-1G8ae.ttf - asset: fonts/PixeloidMono-1G8ae.ttf
assets: assets:
- assets/images/
- assets/images/ball/ - assets/images/ball/
- assets/images/baseboard/ - assets/images/baseboard/
- assets/images/boundary/ - assets/images/boundary/

@ -0,0 +1,48 @@
// ignore_for_file: cascade_invocations
import 'package:flame/components.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pinball_components/pinball_components.dart';
import '../../helpers/helpers.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final assets = [
Assets.images.boardBackground.keyName,
];
final flameTester = FlameTester(() => TestGame(assets));
group('BoardBackgroundSpriteComponent', () {
flameTester.test(
'loads correctly',
(game) async {
final boardBackground = BoardBackgroundSpriteComponent();
await game.ensureAdd(boardBackground);
expect(game.contains(boardBackground), isTrue);
},
);
flameTester.testGameWidget(
'renders correctly',
setUp: (game, tester) async {
await game.images.loadAll(assets);
final boardBackground = BoardBackgroundSpriteComponent();
await game.ensureAdd(boardBackground);
await tester.pump();
game.camera
..followVector2(Vector2.zero())
..zoom = 3.7;
},
verify: (game, tester) async {
await expectLater(
find.byGame<TestGame>(),
matchesGoldenFile('golden/board-background.png'),
);
},
);
});
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

@ -1,3 +1,7 @@
library pinball_ui; library pinball_ui;
export 'package:url_launcher/url_launcher.dart';
export 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart';
export 'src/dialog/dialog.dart'; export 'src/dialog/dialog.dart';
export 'src/external_links/external_links.dart';

@ -0,0 +1,12 @@
import 'package:flutter/foundation.dart';
import 'package:url_launcher/url_launcher.dart';
/// Opens the given [url] in a new tab of the host browser
Future<void> openLink(String url, {VoidCallback? onError}) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri);
} else if (onError != null) {
onError();
}
}

@ -9,17 +9,18 @@ environment:
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
url_launcher: ^6.1.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
mocktail: ^0.3.0
test: ^1.19.2 test: ^1.19.2
very_good_analysis: ^2.4.0 very_good_analysis: ^2.4.0
flutter: flutter:
uses-material-design: true uses-material-design: true
generate: true generate: true
assets: assets:
- assets/images/dialog/ - assets/images/dialog/

@ -0,0 +1,81 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:pinball_ui/pinball_ui.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
class MockUrlLauncher extends Mock
with MockPlatformInterfaceMixin
implements UrlLauncherPlatform {}
void main() {
late UrlLauncherPlatform urlLauncher;
setUp(() {
urlLauncher = MockUrlLauncher();
UrlLauncherPlatform.instance = urlLauncher;
});
group('openLink', () {
test('launches the link', () async {
when(
() => urlLauncher.canLaunch(any()),
).thenAnswer(
(_) async => true,
);
when(
() => urlLauncher.launch(
any(),
useSafariVC: any(named: 'useSafariVC'),
useWebView: any(named: 'useWebView'),
enableJavaScript: any(named: 'enableJavaScript'),
enableDomStorage: any(named: 'enableDomStorage'),
universalLinksOnly: any(named: 'universalLinksOnly'),
headers: any(named: 'headers'),
),
).thenAnswer(
(_) async => true,
);
await openLink('uri');
verify(
() => urlLauncher.launch(
any(),
useSafariVC: any(named: 'useSafariVC'),
useWebView: any(named: 'useWebView'),
enableJavaScript: any(named: 'enableJavaScript'),
enableDomStorage: any(named: 'enableDomStorage'),
universalLinksOnly: any(named: 'universalLinksOnly'),
headers: any(named: 'headers'),
),
);
});
test('executes the onError callback when it cannot launch', () async {
var wasCalled = false;
when(
() => urlLauncher.canLaunch(any()),
).thenAnswer(
(_) async => false,
);
when(
() => urlLauncher.launch(
any(),
useSafariVC: any(named: 'useSafariVC'),
useWebView: any(named: 'useWebView'),
enableJavaScript: any(named: 'enableJavaScript'),
enableDomStorage: any(named: 'enableDomStorage'),
universalLinksOnly: any(named: 'universalLinksOnly'),
headers: any(named: 'headers'),
),
).thenAnswer(
(_) async => true,
);
await openLink(
'url',
onError: () {
wasCalled = true;
},
);
await expectLater(wasCalled, isTrue);
});
});
}

@ -700,6 +700,62 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.3.0" version: "1.3.0"
url_launcher:
dependency: transitive
description:
name: url_launcher
url: "https://pub.dartlang.org"
source: hosted
version: "6.1.0"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
url: "https://pub.dartlang.org"
source: hosted
version: "6.0.16"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
url: "https://pub.dartlang.org"
source: hosted
version: "6.0.15"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.5"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.9"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0"
uuid: uuid:
dependency: transitive dependency: transitive
description: description:
@ -772,4 +828,4 @@ packages:
version: "3.1.0" version: "3.1.0"
sdks: sdks:
dart: ">=2.16.0 <3.0.0" dart: ">=2.16.0 <3.0.0"
flutter: ">=2.8.0" flutter: ">=2.10.0"

@ -0,0 +1,100 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:pinball/footer/footer.dart';
import 'package:pinball_ui/pinball_ui.dart';
import '../helpers/helpers.dart';
void main() {
group('Footer', () {
late UrlLauncherPlatform urlLauncher;
setUp(() async {
urlLauncher = MockUrlLauncher();
UrlLauncherPlatform.instance = urlLauncher;
});
testWidgets('renders "Made with..." and "Google I/O"', (tester) async {
await tester.pumpApp(const Footer());
expect(find.text('Google I/O'), findsOneWidget);
expect(
find.byWidgetPredicate(
(widget) =>
widget is RichText &&
widget.text.toPlainText() == 'Made with Flutter & Firebase',
),
findsOneWidget,
);
});
testWidgets(
'tapping on "Flutter" opens the flutter website',
(tester) async {
when(() => urlLauncher.canLaunch(any())).thenAnswer((_) async => true);
when(
() => urlLauncher.launch(
any(),
useSafariVC: any(named: 'useSafariVC'),
useWebView: any(named: 'useWebView'),
enableJavaScript: any(named: 'enableJavaScript'),
enableDomStorage: any(named: 'enableDomStorage'),
universalLinksOnly: any(named: 'universalLinksOnly'),
headers: any(named: 'headers'),
),
).thenAnswer((_) async => true);
await tester.pumpApp(const Footer());
final flutterTextFinder = find.byWidgetPredicate(
(widget) => widget is RichText && tapTextSpan(widget, 'Flutter'),
);
await tester.tap(flutterTextFinder);
await tester.pumpAndSettle();
verify(
() => urlLauncher.launch(
'https://flutter.dev',
useSafariVC: any(named: 'useSafariVC'),
useWebView: any(named: 'useWebView'),
enableJavaScript: any(named: 'enableJavaScript'),
enableDomStorage: any(named: 'enableDomStorage'),
universalLinksOnly: any(named: 'universalLinksOnly'),
headers: any(named: 'headers'),
),
);
},
);
testWidgets(
'tapping on "Firebase" opens the firebase website',
(tester) async {
when(() => urlLauncher.canLaunch(any())).thenAnswer((_) async => true);
when(
() => urlLauncher.launch(
any(),
useSafariVC: any(named: 'useSafariVC'),
useWebView: any(named: 'useWebView'),
enableJavaScript: any(named: 'enableJavaScript'),
enableDomStorage: any(named: 'enableDomStorage'),
universalLinksOnly: any(named: 'universalLinksOnly'),
headers: any(named: 'headers'),
),
).thenAnswer((_) async => true);
await tester.pumpApp(const Footer());
final firebaseTextFinder = find.byWidgetPredicate(
(widget) => widget is RichText && tapTextSpan(widget, 'Firebase'),
);
await tester.tap(firebaseTextFinder);
await tester.pumpAndSettle();
verify(
() => urlLauncher.launch(
'https://firebase.google.com',
useSafariVC: any(named: 'useSafariVC'),
useWebView: any(named: 'useWebView'),
enableJavaScript: any(named: 'enableJavaScript'),
enableDomStorage: any(named: 'enableDomStorage'),
universalLinksOnly: any(named: 'universalLinksOnly'),
headers: any(named: 'headers'),
),
);
},
);
});
}

@ -24,6 +24,7 @@ void main() {
Assets.images.backboard.backboardScores.keyName, Assets.images.backboard.backboardScores.keyName,
Assets.images.backboard.backboardGameOver.keyName, Assets.images.backboard.backboardGameOver.keyName,
Assets.images.backboard.display.keyName, Assets.images.backboard.display.keyName,
Assets.images.boardBackground.keyName,
Assets.images.ball.ball.keyName, Assets.images.ball.ball.keyName,
Assets.images.ball.flameEffect.keyName, Assets.images.ball.flameEffect.keyName,
Assets.images.baseboard.left.keyName, Assets.images.baseboard.left.keyName,

@ -12,3 +12,4 @@ export 'mocks.dart';
export 'navigator.dart'; export 'navigator.dart';
export 'pump_app.dart'; export 'pump_app.dart';
export 'test_games.dart'; export 'test_games.dart';
export 'text_span.dart';

@ -11,6 +11,8 @@ import 'package:pinball/select_character/select_character.dart';
import 'package:pinball/start_game/start_game.dart'; import 'package:pinball/start_game/start_game.dart';
import 'package:pinball_audio/pinball_audio.dart'; import 'package:pinball_audio/pinball_audio.dart';
import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_ui/pinball_ui.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
class MockPinballGame extends Mock implements PinballGame {} class MockPinballGame extends Mock implements PinballGame {}
@ -92,3 +94,7 @@ class MockSparkyBumper extends Mock implements SparkyBumper {}
class MockMultiballCubit extends Mock implements MultiballCubit {} class MockMultiballCubit extends Mock implements MultiballCubit {}
class MockMultiplierCubit extends Mock implements MultiplierCubit {} class MockMultiplierCubit extends Mock implements MultiplierCubit {}
class MockUrlLauncher extends Mock
with MockPlatformInterfaceMixin
implements UrlLauncherPlatform {}

@ -0,0 +1,17 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/widgets.dart';
bool tapTextSpan(RichText richText, String text) {
final isTapped = !richText.text.visitChildren(
(visitor) => _findTextAndTap(visitor, text),
);
return isTapped;
}
bool _findTextAndTap(InlineSpan visitor, String text) {
if (visitor is TextSpan && visitor.text == text) {
(visitor.recognizer as TapGestureRecognizer?)?.onTap?.call();
return false;
}
return true;
}

@ -56,12 +56,20 @@
<title>I/O Pinball Machine - Flutter</title> <title>I/O Pinball Machine - Flutter</title>
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-67589403-1"></script>
<script> <script>
window.dataLayer = window.dataLayer || []; (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
function gtag(){dataLayer.push(arguments);} new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
gtag('js', new Date()); j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
gtag('config', 'UA-67589403-1'); 'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-ND4LWWZ');
</script>
<script>
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','//www.google-analytics.com/analytics.js','ga');
ga('create', 'UA-67589403-1', 'auto');
ga('send', 'pageview');
</script> </script>
<script src="/__/firebase/8.9.1/firebase-app.js"></script> <script src="/__/firebase/8.9.1/firebase-app.js"></script>
<script src="/__/firebase/8.9.1/firebase-firestore.js"></script> <script src="/__/firebase/8.9.1/firebase-firestore.js"></script>

Loading…
Cancel
Save