Set up a SPA with dog images using flutter and get it ready for production using firebase hosting
Single page application is a website where all the content is shown on one (long) scrollable page. The page may be static but dynamically updated from the server as you scroll. It has become a very popular design pattern. Examples include Netflix and Trello.
An SPA is ideal for a small business with a limited number of options to offer.
You can read a lot of articles to know more about SPA, for example, Single Page Application: What is it and how exactly does it work? And Advantages and Disadvantages of Building a Single Page App in 2022,
In this tutorial, we’ll build a very simple SPA that displays images of dogs by breed. Above is a sample screenshot. Run the SPA using the following URL: https://dog-spa.web.app
, It has the following features:
- The content is lazily loaded, that is, on start-up only the images for the first few breeds are loaded, and the rest are loaded as soon as you scroll down. This makes the time until the first breed is shown smaller.
- The URL for each breed updates as you scroll, so if you’re most interested in Labradors, you can bookmark the URL when viewing that breed, and later you (and the friends you want) can mail the URL. You can go directly to this section using this URL:
https://dog-spa.web.app/?breed=labrador
- If you click on the Menu button a menu is shown, making it easy to navigate among the different breeds.
You will learn how to make this SPA. The tutorial assumes that you have a basic knowledge of building Flutter apps.
Create a new flutter project using Android Studio or flutter create
command. To change main.dart
with the following code:
import 'package:flutter/material.dart';
import 'dart:ui';
import 'constants.dart';
import 'dogwidget.dart';void main()
runApp(MyApp());
class MyApp extends StatelessWidget
MyApp(Key? key) : super(key: key);
@override
Widget build(BuildContext context)
return MaterialApp(
home: MyHomePage(),
);
class MyHomePage extends StatefulWidget
MyHomePage(Key? key) : super(key: key);
@override
State createState() => _MyHomePageState();
class _MyHomePageState extends State
@override
Widget build(BuildContext context)
print('********** Main build');
return Scaffold(
appBar: AppBar(
title: Center(
child: Text('Dog Breeds Single Page Application',
style: TextStyle(color: Colors.white, fontSize: 40)))),
body: ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(dragDevices:
PointerDeviceKind.touch,
PointerDeviceKind.mouse,
),
child: ListView.builder(
cacheExtent: 0,
itemCount: dogBreeds.length,
itemBuilder: (BuildContext context, int index)
return Container(
color: Colors.primaries[index.remainder(Colors.primaries.length)],
child: DogWidget(dogBreeds[index]));
),
),
);
ListView.builder
Loads a list of widgets. In our case, this is the same widget, DogWidget
, controlled by a parameter. but you can also have a list of widget names constants.dart
and return a different widget for each index.
Without it ScrollConfiguration
Widgets, you can scroll with the scroll wheel on the mouse and by using the scrollbar, but not by dragging the left mouse button down. more info Here,
add lib/constants.dart
With a list of breeds:
const List dogBreeds = [
'airedale',
'beagle',
'dalmatian',
'cockapoo',
'dachshund',
'havanese',
'germanshepherd',
'labradoodle',
'labrador',
'pekinese',
];
add lib/dogwidget.dart
,
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'dart:convert' as convert;
import 'dart:math';
import 'dart:ui';class DogWidget extends StatelessWidget {
final String? breed;
const DogWidget(this.breed, Key? key) : super(key: key);
@override
Widget build(BuildContext context) {
print('DogWidget build for breed $breed');
return Center(
child: FutureBuilder(
future: http.get(Uri.https('dog.ceo', '/api/breed/$breed/images')),
builder: (BuildContext context, AsyncSnapshot snapshot)
if (snapshot.connectionState == ConnectionState.waiting)
return Container(
height: 500,
child: const CupertinoActivityIndicator(
radius: 100,
color: Colors.white,
));
else if (snapshot.connectionState == ConnectionState.done)
if (snapshot.hasError)
return const Text('Error');
else if (snapshot.hasData)
var jsonResponse = convert.jsonDecode(snapshot.data!.body);
List imgList = jsonResponse["message"];
print('$breed number of images $imgList.length');
return Column(children: [
Text('Breed: $breed', style: const TextStyle(color: Colors.white, fontSize: 25)),
if (imgList.length > 0) dogRow(imgList, context, first: 0),
if (imgList.length > 4) dogRow(imgList, context, first: 4),
if (imgList.length > 8) dogRow(imgList, context, first: 8),
]);
else
return const Text('Empty data');
else
return Text('State: $snapshot.connectionState');
),
);
}
Widget dogRow(List imgList, BuildContext context, int first = 0)
return ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(dragDevices:
PointerDeviceKind.touch,
PointerDeviceKind.mouse,
),
child: SingleChildScrollView(
scrollDirection:Axis.horizontal,
physics: BouncingScrollPhysics(),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (int i = first; i < min(first + 4, imgList.length); i++)
Padding(
padding: const EdgeInsets.all(10),
child: ClipRRect(
borderRadius: BorderRadius.circular(15.0),
child: Image.network(
imgList[i].toString(),
height: 200,
),
),
)
],
),
),
);
}
This widget receives up to 12 images for one breed Dog CEO Dog API, This returns them in rows one through three. For example:
If the viewport is not wide enough to show all the dogs in a row, we want the row to be horizontally scrollable (by dragging it), so SingleChildScrollView
And this ScrollConfiguration
Widget.
dogWidget
uses it http package on pub.dev. Updates pubspec.yaml
do one more with it Pub get
, Then open a command line window, go to the project directory and enter this command: flutter run -d chrome
, (Or, if you prefer Microsoft Edge over Google Chrome, flutter run -d edge
.) In the command line window, you will see this:
only the first two breeds are loaded ListView cacheExtent: 0
, With the default cache, four or five breeds will be preloaded.
Also note, in DogWidget
The Container height:500
Around the Cupertino Activity Indicators. Without it, the initial size of the widget will be small – it grows to its full size when images are loaded. and when ListView
Determines how many widgets fit in the viewport; Initial size matters.
Scroll down in the Chrome window and look for the additional DogWidget build line that appears in the command line window.
SPA test
One can write a long tutorial about this topic. Here, I’ll just give some pointers and tips:
- Most testing will be done with
flutter run -d chrome
, You can add print statements to show where you are and the variable values. Output can be viewed in the command line window and in Flutter messages. - Flutter DevTools There is a suite of performance and debugging tools for Dart and Flutter. The Flutter Inspector and Debugger can be accessed via the URL shown in the command line window (see example above). With a debugger, you can set breakpoints, inspect variables, and more. The Inspector is great for debugging the GUI.
- chrome dev tools There is a set of web developer tools built directly into the Google Chrome browser. you reach it by typing
Ctrl-Shift-I
in viewport. The console view will show the print output and flutter messages. The network view will appear as the images load. To test lazy loading, tap Throttling and select Fast 3G. - Having two screens is very useful. You can then display the SPA on one screen and have the command line window, Flutter DevTools, and Chrome DevTools on the other screen. In Chrome DevTools, click the menu icon (three vertical dots) and then Undock in separate window.
- There are several ways to run a SPA:
flutter run -d chrome
flutter run -d web-server --debug
flutter run -d web-server --release
flutter build web
then (project directory)/build/webflutter run
, to run it as an Android app on an attached (or simulated) mobile phone. useif (kIsWeb)
To avoid code in SPA that is not supported on Android (see finalmain.dart
for two examples).- there is also an option called
--web-renderer
where you can selectauto
,html
eithercanvaskit
, Here, we use the default, which isauto
, you can read about Here,
ListView.builder
It has one limitation: using its scroll controller, you can move to an offset but not an index for a specific widget. Which is needed in the menu.
if all widgets ListView
The height is the same, this is not a problem. You can easily calculate the offset. or you can use PageView.builder
Instead ListView.builder
,
But if the altitude changes unexpectedly (as in our case), what can you do? Here are some ideas:
- Assign a GlobalKey to each widget in
ListView
, use againscrollController.position.ensureVisible
,globalKeys[index].currentContext.findRenderObject()
,…) to scroll until the widget with this index is visible in the viewport. it works fine withListView,
but withListView.builder
, it doesn’t always work because the key is null for widgets not in the viewport or cache. then you canscrollcontroller.jumpTo
a short distance in a loop until key is non-zero and then doensureVisible
, It works, but it’s not the best solution. - Use
indexed_list_view
package on pub.dev. It has some limitations. - Use
scrollable_positioned_list
package on pub.dev. This is what we will use. Updatespubspec.yaml
With it and get a pulsating pub.
Now we can create a menu to scroll down to a certain breed.
add lib/navigationmenu.dart
,
import 'package:flutter/material.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'constants.dart';class NavigationMenu extends StatelessWidget
final ItemScrollController itemScrollController;
int? focusedDogIndex;
NavigationMenu(this.itemScrollController, this.focusedDogIndex);
@override
Widget build(BuildContext context)
return Container(
color: Colors.white,
padding: const EdgeInsets.all(3.0),
child: Column(
children: [
for (int i = 0; i < dogBreeds.length; i++) menuRow(i, focusedDogIndex),
],
),
);
Widget menuRow(int index, int? focusedDogIndex)
return GestureDetector(
onTap: ()
itemScrollController.scrollTo(
index: index, duration: Duration(seconds: 1), curve: Curves.easeInOut);
,
child: Container(
width: 150,
height: 50,
color: Colors.primaries[index.remainder(Colors.primaries.length)],
child: Center(
child: Text(
dogBreeds[index],
style: TextStyle(color: index == focusedDogIndex ? Colors.white : Colors.black, fontSize: 15),
)),
),
);
This widget will display a menu that looks like this:
When a row is tapped, the scroll controller scrollTo
The method is invoked with the corresponding index as a parameter. (optional parameter focusedDogIndex
explained in step 3.)
To change main.dart
with this:
import 'package:flutter/material.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'constants.dart';
import 'dogwidget.dart';
import 'navigationmenu.dart';void main()
runApp(MyApp());
class MyApp extends StatelessWidget
MyApp(Key? key) : super(key: key);
@override
Widget build(BuildContext context)
return MaterialApp(
home: MyHomePage(),
);
class MyHomePage extends StatefulWidget
MyHomePage(Key? key) : super(key: key);
@override
State createState() => _MyHomePageState();
class _MyHomePageState extends State
final ItemScrollController itemScrollController = ItemScrollController();
@override
Widget build(BuildContext context)
print('********** Main build');
return Scaffold(
appBar: AppBar(
title: Center(
child: Text('Dog Breeds Single Page Application',
style: TextStyle(color: Colors.white, fontSize: 40)))),
body: Stack(children: [
ScrollablePositionedList.builder(
// cacheExtent: 0,
itemCount: dogBreeds.length,
itemScrollController: itemScrollController,
itemBuilder: (BuildContext context, int index)
return Container(
color: Colors.primaries[index.remainder(Colors.primaries.length)],
child: DogWidget(dogBreeds[index]));
),
NavigationMenu(itemScrollController)
]),
);
The scroll controller is declared and then used in the builder and menu. The widget list is wrapped with a Stack
Widgets, and menus have been added.
The command window now shows this:
Many breeds are now pre-loaded in the beginning, not just two. this is because scrollable_positioned_list
does not support cacheExtent
parameter.
To make the site more user friendly, I added a menu button to it appBar
To turn on and off the menu. and wrap the menu with a Draggable
Widget. main.dart
Now it looks like this:
import 'package:flutter/material.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'constants.dart';
import 'dogwidget.dart';
import 'navigationmenu.dart';void main()
runApp(MyApp());
class MyApp extends StatelessWidget
MyApp(Key? key) : super(key: key);
@override
Widget build(BuildContext context)
return MaterialApp(
debugShowCheckedModeBanner: false,
home: MyHomePage(),
);
class MyHomePage extends StatefulWidget
MyHomePage(Key? key) : super(key: key);
@override
State createState() => _MyHomePageState();
class _MyHomePageState extends State
final ItemScrollController itemScrollController = ItemScrollController();
bool menuVisible = false;
Offset menuPosition = Offset(20, 20);
@override
Widget build(BuildContext context)
print('********** Main build');
return Scaffold(
appBar: AppBar(
title: Center(
child: Text('Dog Breeds Single Page Application',
style: TextStyle(color: Colors.white, fontSize: 40))),
actions: [
IconButton(
icon: Icon(Icons.menu),
onPressed: ()
menuVisible = !menuVisible;
setState(() );
,
)
],
),
body: Stack(children: [
ScrollablePositionedList.builder(
// cacheExtent: 0,
itemCount: dogBreeds.length,
itemScrollController: itemScrollController,
itemBuilder: (BuildContext context, int index)
return Container(
color: Colors.primaries[index.remainder(Colors.primaries.length)],
child: DogWidget(dogBreeds[index]));
),
menuVisible
? Positioned(
left: menuPosition.dx,
top: menuPosition.dy,
child: Draggable(
feedback: NavigationMenu(itemScrollController),
childWhenDragging: Opacity(opacity: .3, child: NavigationMenu(itemScrollController)),
onDragEnd: (details)
menuPosition = details.offset;
setState(() );
,
child: NavigationMenu(
itemScrollController,
),
),
)
: Container()
]),
);
Now, the menu can be toggled on and off and dragged to a new position if you want. debugShowCheckedModeBanner: false
line removes the debug banner, which would otherwise hide the menu button.
Do a hot restart and test the new version.
If the SPA has a lot of widgets, one might be able to bookmark a URL that leads directly to the desired widget and doesn’t require scrolling. Of course, this may not be very important with a menu, but it’s nice and easy to fix.
We will support URL like hostname/?breed=labrador
, The URL can be updated with one line of code:
html.window.history.pushState(null, '', '?breed=$dogBreeds[focusedDogIndex]');
The problem is to know how far the user has scrolled in the SPA. itemPositionsListener
specialty in scrollable_positioned_list
takes care of him. ScrollablePositionedList.builder
Sends a stream of posts to anyone who wants to listen. the listener will get a set of ItemPosition
at regular intervals during scrolling. there will be a ItemPosition
For each widget that is fully or partially visible in the viewport and includes the following:
- The index of the widget (for example,
2
for a dalmatian) itemLeadingEdge
= Distance from the leading edge of the viewport to the leading edge of the widget proportional to the main axis length of the viewport. It can be negative if the item is partially visible. For example,0.5
This means that the top of the widget is halfway down the viewport.itemTrailingEdge
= The distance from the leading edge to the trailing edge of the widget proportional to the length of the viewport’s main axis. It may be more than one if the item is partially visible.
To get this stream, wrap the menu with a ValueListenableBuilder
Widget:
final ItemScrollController itemScrollController = ItemScrollController();
final ItemPositionsListener itemPositionsListener = ItemPositionsListener.create();
:
body: Stack(children: [
ScrollablePositionedList.builder(
// cacheExtent: 0,
itemCount: dogBreeds.length,
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
itemBuilder: (BuildContext context, int index)
return Container(
color: Colors.primaries[index.remainder(Colors.primaries.length)],
child: DogWidget(dogBreeds[index]));
),
ValueListenableBuilder>(
valueListenable: itemPositionsListener.itemPositions,
builder: (context, positions, child)
if (positions.isEmpty)
return Container();
int focusedDogIndex = findFocusedDog(positions);
html.window.history.pushState(null, '', '?breed=$dogBreeds[focusedDogIndex]');
return menuVisible
? Positioned(
left: menuPosition.dx,
top: menuPosition.dy,
child: Draggable(
feedback: NavigationMenu(itemScrollController),
childWhenDragging: Opacity(opacity: .3, child: NavigationMenu(itemScrollController)),
onDragEnd: (details)
menuPosition = details.offset;
setState(() );
,
child: NavigationMenu(itemScrollController, focusedDogIndex: focusedDogIndex),
),
)
: Container();
)
]),
findFocusedDog
The purpose of the function is to detect which breed the user is most focused on when there are multiple breeds in the viewport. For example, in these cases,
function should return 7 = labradoodle
for the left image, and 8 = labrador
for the right.
The returned value is used to do the following:
- To update URL —
html.window.history.pushState(null, ‘’, ‘?breed=$dogBreeds[focusedDogIndex]
- In
NavigationMenu
Call, which will highlight the corresponding row in the menu.
add lib/findfocuseddog.dart
with the following code:
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';int findFocusedDog(Iterable positions)
ItemPosition topPos = positions.where((ItemPosition position) => position.itemTrailingEdge > 0).reduce(
(ItemPosition min, ItemPosition position) =>
position.itemTrailingEdge < min.itemTrailingEdge ? position : min);
ItemPosition botPos = positions.where((ItemPosition position) => position.itemLeadingEdge < 1).reduce(
(ItemPosition max, ItemPosition position) =>
position.itemLeadingEdge > max.itemLeadingEdge ? position : max);
if (topPos.itemLeadingEdge == 0)
// If a widget is aligned with the top of the viewport, use it
return topPos.index;
else if (positions.length == 1)
// If a widget covers the whole viewport, use it
return topPos.index;
else if (positions.length == 2)
// If there are two widgets in the viewport,
// select the one which uses most of the viewport
if (topPos.itemTrailingEdge < 1 - botPos.itemLeadingEdge)
return topPos.index + 1;
else
return topPos.index;
else if (positions.length == 3)
// If there are three widgets in the viewport, select the one in the middle
Iterable otherPositions = positions.where((ItemPosition position) => position != topPos && position != botPos);
return otherPositions.first.index;
else
return topPos.index;
ItemPositions
can occur in any order in an iterable — the positions.first
Position doesn’t necessarily represent the topmost widget. This complicates the code a bit. (I suspect there may be an easier solution. Reply if you have ideas.)
The URL dynamically updates as you scroll. If you visit a URL with ?breed=xyz , you want the SPA to go straight to the query breed. To accomplish this, add breed as a parameter MyHomePage
,
class MyApp extends StatelessWidget
MyApp(Key? key) : super(key: key);
@override
Widget build(BuildContext context)
String queryBreed;
String query = Uri.base.query;
if (query.length > 6)
queryBreed = query.substring(6);
if (dogBreeds.indexOf(queryBreed) == -1) queryBreed = dogBreeds[0];
else
queryBreed = dogBreeds[0];
print('building for breed $queryBreed');
return MaterialApp(
debugShowCheckedModeBanner: false,
home: MyHomePage(queryBreed: queryBreed),
);
class MyHomePage extends StatefulWidget
String queryBreed;
MyHomePage(Key? key, required this.queryBreed) : super(key: key);
@override
State createState() => _MyHomePageState();
then add the following initState
Override:
@override
void initState()
SchedulerBinding.instance.addPostFrameCallback((_)
itemScrollController.jumpTo(index: dogBreeds.indexOf(widget.queryBreed));
);
super.initState();
addPostFrameCallback
This ensures that the jump is postponed until after the first build is complete.
full final main.dart
looks like this:
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'dart:html' as html if (kIsWeb) "";
import 'dart:ui';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'constants.dart';
import 'dogwidget.dart';
import 'navigationmenu.dart';
import 'findfocuseddog.dart';void main()
runApp(MyApp());
class MyApp extends StatelessWidget
MyApp(Key? key) : super(key: key);
@override
Widget build(BuildContext context)
String queryBreed;
String query = Uri.base.query;
if (query.length > 6)
queryBreed = query.substring(6);
if (dogBreeds.indexOf(queryBreed) == -1) queryBreed = dogBreeds[0];
else
queryBreed = dogBreeds[0];
print('building for breed $queryBreed');
return MaterialApp(
debugShowCheckedModeBanner: false,
home: MyHomePage(queryBreed: queryBreed),
);
class MyHomePage extends StatefulWidget
String queryBreed;
MyHomePage(Key? key, required this.queryBreed) : super(key: key);
@override
State createState() => _MyHomePageState();
class _MyHomePageState extends State {
final ItemScrollController itemScrollController = ItemScrollController();
final ItemPositionsListener itemPositionsListener = ItemPositionsListener.create();
bool menuVisible = false;
Offset menuPosition = Offset(20, 20);
@override
void initState()
SchedulerBinding.instance.addPostFrameCallback((_)
itemScrollController.jumpTo(index: dogBreeds.indexOf(widget.queryBreed));
);
super.initState();
@override
Widget build(BuildContext context)
print('********** Main build');
return Scaffold(
appBar: AppBar(
title: Center(
child: Text('Dog Breeds Single Page Application',
style: TextStyle(color: Colors.white, fontSize: 40))),
actions: [
IconButton(
icon: Icon(Icons.menu),
onPressed: ()
menuVisible = !menuVisible;
setState(() );
,
)
],
),
body: Stack(children: [
ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(dragDevices:
PointerDeviceKind.touch,
PointerDeviceKind.mouse,
),
child: ScrollablePositionedList.builder(
// cacheExtent: 0,
itemCount: dogBreeds.length,
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
itemBuilder: (BuildContext context, int index)
return Container(
color: Colors.primaries[index.remainder(Colors.primaries.length)],
child: DogWidget(dogBreeds[index]));
),
),
ValueListenableBuilder>(
valueListenable: itemPositionsListener.itemPositions,
builder: (context, positions, child)
if (positions.isEmpty)
return Container();
int focusedDogIndex = findFocusedDog(positions);
if (kIsWeb) html.window.history.pushState(null, '', '?breed=$dogBreeds[focusedDogIndex]');
return menuVisible
? Positioned(
left: menuPosition.dx,
top: menuPosition.dy,
child: Draggable(
feedback: NavigationMenu(itemScrollController),
childWhenDragging: Opacity(opacity: .3, child: NavigationMenu(itemScrollController)),
onDragEnd: (details)
menuPosition = details.offset;
setState(() );
,
child: NavigationMenu(itemScrollController, focusedDogIndex: focusedDogIndex),
),
)
: Container();
)
]),
);
}
favicon, A favicon is a small 16×16 pixel icon shown in tabs and bookmarks in Chrome. I used paint to make this:
To change web\favicon.png
with your favicon.
manifest and chrome icon, update file web\manifest.json
, change fields name
, short_name
And description
, Also, change web\icons\icon-192.png
with its own icon. I used the same image as the splash image, see below, and resized it to 192×192. Name and icon to be used if you choose to install the SPA as a Chrome app by clicking on the leftmost of the three icons in the Chrome address bar:
splash screen, This is shown when starting the SPA, waiting for flutter to load, and displaying the first app screen. It looks like this (but with blue background):
store image and gif \web\icons
, add the following two lines to it web\index.html
Right after the body:


then add the following file as web\styles.css
,
html,
body
background-color: #368ed6
.center
margin: 0;
position: absolute;
top: 30%;
left: 50%;
margin-right: -50%;
transform: translate(-50%, -50%)
.below
margin: 0;
position: absolute;
top: 70%;
left: 50%;
margin-right: -50%;
transform: translate(-50%, -50%)
And finally, add this line to it web\index.html
Right after the link to the manifest:
Build and host a SPA, Now you are ready to do the final build. Enter this command: flutter build web
, a build
The directory will be created on a server with all the files required for the deployment. icon and other files web
will be copied to the directory build\web
,
For hosting, I recommend Firebase Hosting. It is very easy to use. follow directions Here, When creating a Firebase project, make sure you change the default Project ID to something more user-friendly as this will be part of the URL for the SPA: (project_id).web.app
, during firebase init hosting
,
- enter build/web as your public directory
- Answer Yes to Configure as a single-page app?
- File build/web/index.html answers No. Already exists. overwrite?
Now you can deploy your SPA to the server: firebase deploy.
add firebase analytics, This is optional, but recommended if you want to gain insight into how and where your SPA is used. And it is not difficult to implement it. follow directions Here,
to find the code to be added index.html
Go to the Firebase console, select your project, click the Project Overview Settings cogwheel, scroll down to Your Apps, select CDN Configuration, and copy the code:
Add this code to web/index.html just after the img statement for the splash screen. Then do a new firebase build and firebase deploy.
(Since we are using Firebase Hosting, we can improve performance a bit by loading from Firebase SDK reserved url, I haven’t tried it.)
Run the SPA on multiple devices to test that analytics works. Then go to the Firebase console and select Analytics – Realtime. In Users in the last 30 minutes, you should see the number of devices you’ve used for testing.
To prevent abuse of the APIK, which anyone can demonstrate using the Chrome Inspector, you should limit its use to your SPA. it is done Here,
Thanks for reading this tutorial. If you’ve been following along, you’ve built a single page application website using Flutter Web with lazy loading, URL updates, and a menu, and deployed it with Firebase hosting and analytics.