Build a Single Page Application With Flutter Web | by Bo Hellgren | Nov, 2022

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:

  1. 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.
  2. 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
  3. 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 DogWidgetThe 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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 webthen (project directory)/build/web
  • flutter run, to run it as an Android app on an attached (or simulated) mobile phone. use if (kIsWeb) To avoid code in SPA that is not supported on Android (see final main.dart for two examples).
  • there is also an option called --web-renderer where you can select auto, htmleither canvaskit, Here, we use the default, which is auto, 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:

  1. Assign a GlobalKey to each widget in ListView, use again scrollController.position.ensureVisible ,globalKeys[index].currentContext.findRenderObject(),…) to scroll until the widget with this index is visible in the viewport. it works fine with ListView, but with ListView.builder, it doesn’t always work because the key is null for widgets not in the viewport or cache. then you can scrollcontroller.jumpTo a short distance in a loop until key is non-zero and then do ensureVisible, It works, but it’s not the best solution.
  2. Use indexed_list_view package on pub.dev. It has some limitations.
  3. Use scrollable_positioned_list package on pub.dev. This is what we will use. Updates pubspec.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_nameAnd 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.htmlRight after the body:


wait1 Build a Single Page Application With Flutter Web | by Bo Hellgren | Nov, 2022

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.htmlRight 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.htmlGo 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.

Leave a Reply