Adaptive Apps in Flutter

1. Introduction

Flutter is Google's UI toolkit for building beautiful, natively compiled applications for mobile, web, and desktop from a single codebase. In this codelab, you'll learn how to build a Flutter app that adapts to the platform that it is running on, be that Android, iOS, the web, Windows, macOS, or Linux.

What you'll learn

  • How to grow a Flutter app designed for mobile to work on all six platforms supported by Flutter.
  • The different Flutter APIs for detecting the platform and when to use each API.
  • Adapting to the restrictions and expectations of running an app on the web.
  • How to use different packages alongside each other to support the full range of Flutter's platforms.

What you'll build

In this codelab, you'll initially build a Flutter app for Android and iOS that explores Flutter's YouTube playlists. You will then adapt this application to work on the three desktop platforms (Windows, macOS, and Linux) by modifying how information is displayed given the size of the application window. Then you will adapt the application for the web by making text displayed in the app selectable, as web users expect. Finally, you will add authentication to the app so you can explore your own Playlists, as opposed to the ones created by the Flutter team, which requires different approaches to authentication for Android, iOS, and the web, versus the three desktop platforms Windows, macOS, and Linux.

Here is a screenshot of the Flutter app on Android and iOS:

Here is a screenshot of the app running on macOS in the wide screen layout:

9252693f839709f4.png

This codelab focuses on transforming a mobile Flutter app into an adaptive app that works across all six Flutter platforms. Non-relevant concepts and code blocks are glossed over, and are provided for you to simply copy and paste.

What would you like to learn from this codelab?

I'm new to the topic, and I want a good overview. I know something about this topic, but I want a refresher. I'm looking for example code to use in my project. I'm looking for an explanation of something specific.

2. Set up your Flutter environment

This codelab covers developing Flutter apps across multiple operating systems. While one can reach four of the six target platforms from a macOS laptop or desktop (Android, iOS, web, and macOS), to reach all six will require multiple systems. Developers on all development platforms should be able to develop for Android, the web, and the desktop operating system you are developing on. This is enough to cover all of the lessons of this codelab.

You must install software to compile for various targets. For example, you need access to Xcode tooling for iOS and macOS development, Android Studio to install Android tooling, Visual Studio for Win32 compilation tooling, and so forth. For details, please refer to Flutter's getting started documentation, then follow that with flutter.dev/desktop.

You also need an editor to edit Dart code. Check out the instructions for configuring Visual Studio Code, Android Studio, and Emacs.

3. Get started

Confirming your development environment

The easiest way to make sure everything is ready for development, please run the following command:

$ flutter doctor

If anything is shown without a checkmark, please run the following to get further details on what is wrong:

$ flutter doctor -v

This confirms that Flutter is appropriately configured for development on both mobile and the web. To enable desktop development, please configure Flutter using the appropriate versions of the following:

$ flutter config --enable-windows-desktop # for the Windows runner
$ flutter config --enable-macos-desktop   # for the macOS runner
$ flutter config --enable-linux-desktop   # for the Linux runner

To confirm that Flutter for desktop is enabled, run the following command.

$ flutter devices
1 connected device:

Windows (desktop) • windows    • windows-x64    • Microsoft Windows [Version 10.0.19041.508]
macOS (desktop)   • macos      • darwin-x64     • macOS 11.2.3 20D91 darwin-x64
Linux (desktop)   • linux      • linux-x64      • Linux

With all of the above complete, you are now ready to develop Flutter apps for many different target platforms.

Creating a Flutter project

An easy way to get started writing Flutter for desktop apps is to use the Flutter command-line tool to create a Flutter project. Alternatively, your IDE may provide a workflow for creating a Flutter project through its UI.

$ flutter create adaptive_app
Creating project adaptive_app
[Eliding listing of created files]
Running "flutter pub get" in adaptive_app...                     2,445ms
Wrote 128 files.

All done!
In order to run your application, type:

  $ cd adaptive_app
  $ flutter run

Your application code is in adaptive_app/lib/main.dart.

To make sure everything is working, run the boilerplate Flutter application as a mobile app as shown below. Alternatively, open this project in your IDE, and use its tooling to run the application. Thanks to the previous step, running as a desktop application should be the only available option.

$ flutter run
Launching lib/main.dart on iPhone 12 in debug mode...
Running Xcode build...
 └─Compiling, linking and signing...                        15.9s
Xcode build done.                                           41.2s
Syncing files to device iPhone 12...                               213ms

Flutter run key commands.
r Hot reload. 🔥🔥🔥
R Hot restart.
h List all available interactive commands.
d Detach (terminate "flutter run" but leave application running).
c Clear the screen
q Quit (terminate the application on the device).

💪 Running with sound null safety 💪

An Observatory debugger and profiler on iPhone 12 is available at: http://127.0.0.1:60071/t9hy0pnIWgE=/
Activating Dart DevTools...                                         3.3s
The Flutter DevTools debugger and profiler on iPhone 12 is available at:
http://127.0.0.1:9101?uri=http://127.0.0.1:60071/t9hy0pnIWgE=/

You should now see the app running. Modify the content in lib/main.dart as follows, and perform a hot reload to update the content. How to perform a hot reload changes depending on whether you are running the app via the command line (type ‘r' into the console window) or via an editor (in which case, saving the file is probably enough to trigger hot reload).

lib/main.dart

import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const ResizeablePage(),
    );
  }
}

class ResizeablePage extends StatelessWidget {
  const ResizeablePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final mediaQuery = MediaQuery.of(context);
    final themePlatform = Theme.of(context).platform;

    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'Window properties',
              style: Theme.of(context).textTheme.headline5,
            ),
            const SizedBox(height: 8),
            SizedBox(
              width: 350,
              child: Table(
                textBaseline: TextBaseline.alphabetic,
                children: <TableRow>[
                  _fillTableRow(
                    context: context,
                    property: 'Window Size',
                    value: '${mediaQuery.size.width.toStringAsFixed(1)} x '
                        '${mediaQuery.size.height.toStringAsFixed(1)}',
                  ),
                  _fillTableRow(
                    context: context,
                    property: 'Device Pixel Ratio',
                    value: mediaQuery.devicePixelRatio.toStringAsFixed(2),
                  ),
                  _fillTableRow(
                    context: context,
                    property: 'Platform.isXXX',
                    value: platformDescription(),
                  ),
                  _fillTableRow(
                    context: context,
                    property: 'Theme.of(ctx).platform',
                    value: themePlatform.toString(),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }

  TableRow _fillTableRow(
      {required BuildContext context,
      required String property,
      required String value}) {
    return TableRow(
      children: [
        TableCell(
          verticalAlignment: TableCellVerticalAlignment.baseline,
          child: Row(
            children: [
              Text(
                property,
                style: Theme.of(context).textTheme.bodyText1,
              ),
              const SizedBox(width: 8, height: 24),
            ],
          ),
        ),
        TableCell(
          verticalAlignment: TableCellVerticalAlignment.baseline,
          child: Row(
            children: [
              Text(
                value,
              ),
            ],
          ),
        ),
      ],
    );
  }

  String platformDescription() {
    if (kIsWeb) {
      return 'Web';
    } else if (Platform.isAndroid) {
      return 'Android';
    } else if (Platform.isIOS) {
      return 'iOS';
    } else if (Platform.isWindows) {
      return 'Windows';
    } else if (Platform.isMacOS) {
      return 'macOS';
    } else if (Platform.isLinux) {
      return 'Linux';
    } else if (Platform.isFuchsia) {
      return 'Fuchsia';
    } else {
      return 'Unknown';
    }
  }
}

The above app is designed to give you a feeling for how different platforms can be detected and adapted to. Here is the app running natively on Android and iOS:

And here is the same code running natively on macOS and inside of Chrome, again running on macOS.

The important point to note here is that, at first glance, Flutter is doing what it can to adapt the content to the display it is running on. The laptop on which these screenshots were taken has a high resolution Mac display, which is why both the macOS and web versions of the app are rendered at Device Pixel Ratio of 2. Meanwhile, on the iPhone 12, you see a ratio of 3, and 2.63 on the Pixel 2. In all cases the displayed text is roughly similar, making our job as developers a lot easier.

The second point to note is that the two options for checking out which platform the code is running on results in different values. The first option inspects the Platform object imported from dart:io, while the second option (available only inside the Widget's build method), retrieves the Theme object from the BuildContext argument.

The reason that these two methods return different results is that their intent is different. The Platform object imported from dart:io is meant to be used for making decisions that are independent of rendering choices. A prime example of this is deciding which plugins to use, which may or may not have matching native implementations for a specific physical platform.

Extracting the Theme from the BuildContext is intended for implementation decisions that are Theme centric. A prime example of this is deciding whether to use the Material slider, or the Cupertino slider, as discussed in Slider.adaptive.

In the next section you'll build a basic YouTube playlist explorer app that is optimized purely for Android and iOS. In the following sections you will add various adaptations to make the app work better on desktop and the web.

4. Build a mobile app

Add packages

In this app you will use a variety of Flutter packages to gain access to the YouTube Data API, state management, and a touch of theming.

$ flutter pub add googleapis
Resolving dependencies...
+ _discoveryapis_commons 1.0.1
  async 2.8.1 (2.8.2 available)
+ googleapis 4.0.0
+ http 0.13.3
+ http_parser 4.0.0
  matcher 0.12.10 (0.12.11 available)
+ pedantic 1.11.1
  test_api 0.4.2 (0.4.3 available)
Changed 5 dependencies!

The first package, googleapis is a generated Dart library for accessing Google APIs.

$ flutter pub add http
Resolving dependencies...
  async 2.8.1 (2.8.2 available)
  matcher 0.12.10 (0.12.11 available)
  test_api 0.4.2 (0.4.3 available)
Got dependencies!

The http package will be instrumental for building out the ability to access the YouTube Data API using API keys.

$ flutter pub add provider
Resolving dependencies...
  async 2.8.1 (2.8.2 available)
  matcher 0.12.10 (0.12.11 available)
+ nested 1.0.0
+ provider 6.0.0
  test_api 0.4.2 (0.4.3 available)
Changed 2 dependencies!

For state management, use provider.

$ flutter pub add url_launcher
Resolving dependencies...
  async 2.8.1 (2.8.2 available)
+ flutter_web_plugins 0.0.0 from sdk flutter
+ js 0.6.3
  matcher 0.12.10 (0.12.11 available)
+ plugin_platform_interface 2.0.1
  test_api 0.4.2 (0.4.3 available)
+ url_launcher 6.0.9
+ url_launcher_linux 2.0.1
+ url_launcher_macos 2.0.1
+ url_launcher_platform_interface 2.0.4
+ url_launcher_web 2.0.4
+ url_launcher_windows 2.0.2
Changed 9 dependencies!

Use url_launcher as a way of jumping into a video from a playlist. As you can see from the resolved dependencies, url_launcher has implementations for Windows, macOS, Linux, and the web, in addition to the default Android and iOS. This is one capability you won't need to create platform specific code for.

$ flutter pub add flex_color_scheme
Resolving dependencies...
  async 2.8.1 (2.8.2 available)
+ flex_color_scheme 3.0.1
  matcher 0.12.10 (0.12.11 available)
  test_api 0.4.2 (0.4.3 available)
Changed 1 dependency!

This last package is purely about giving the app a nice default color scheme. See the flex_color_scheme documentation to get an understanding of the full range of capabilities.

Configuring the mobile apps for url_launcher

The url_launcher plugin requires configuration of the Android and iOS runner applications. In the iOS Flutter runner, add the following lines to the plist dictionary.

ios/Runner/Info.plist

        <key>LSApplicationQueriesSchemes</key>
        <array>
                <string>https</string>
                <string>http</string>
                <string>tel</string>
                <string>mailto</string>
        </array>

In the Android Flutter runner, add the following lines to the Manifest.xml. Add this queries node as a direct child of the manifest node and a peer of the application node.

android/app/src/main/AndroidManifest.xml

  <queries>
    <intent>
      <action android:name="android.intent.action.VIEW" />
      <data android:scheme="https" />
    </intent>
    <intent>
      <action android:name="android.intent.action.DIAL" />
      <data android:scheme="tel" />
    </intent>
    <intent>
      <action android:name="android.intent.action.SEND" />
      <data android:mimeType="*/*" />
    </intent>
  </queries>

For more details about these required configuration changes, please see the url_launcher documentation.

Accessing the YouTube Data API

To access the YouTube Data API to list playlists, you need to create an API project to generate the required API Keys. These steps assume you already have a Google Account, so create one if you haven't got one handy already.

Navigate to the Developer Console to create an API project:

15503a98e8205380.png

Then navigate to the Credentials page, and create an API Key.

d08356137d2d03ca.png

After a couple of seconds, you should see a dialog with your shiny new API Key. You will be using this key shortly.

894da1b1739d3d85.png

Add code

For the rest of this step you will cut'n'paste a lot of code to build a mobile app, without any commentary on the code. The intent of this codelab is to take the mobile app and adapt it to both desktop and the web. For a more detailed introduction to building Flutter apps for mobile, please see Write Your First Flutter App, part 1, part 2, and Building beautiful UIs with Flutter.

Add the following files, firstly the state object for the app.

lib/src/app_state.dart

import 'dart:collection';

import 'package:flutter/foundation.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:http/http.dart' as http;

class FlutterDevPlaylists extends ChangeNotifier {
  FlutterDevPlaylists({
    required String flutterDevAccountId,
    required String youTubeApiKey,
  }) : _flutterDevAccountId = flutterDevAccountId {
    _api = YouTubeApi(
      _ApiKeyClient(
        client: http.Client(),
        key: youTubeApiKey,
      ),
    );
    _loadPlaylists();
  }

  Future<void> _loadPlaylists() async {
    String? nextPageToken;
    _playlists.clear();

    do {
      final response = await _api.playlists.list(
        ['snippet', 'contentDetails', 'id'],
        channelId: _flutterDevAccountId,
        maxResults: 50,
        pageToken: nextPageToken,
      );
      _playlists.addAll(response.items!);
      _playlists.sort((a, b) => a.snippet!.title!
          .toLowerCase()
          .compareTo(b.snippet!.title!.toLowerCase()));
      notifyListeners();
    } while (nextPageToken != null);
  }

  final String _flutterDevAccountId;
  late final YouTubeApi _api;

  final List<Playlist> _playlists = [];
  List<Playlist> get playlists => UnmodifiableListView(_playlists);

  final Map<String, List<PlaylistItem>> _playlistItems = {};
  List<PlaylistItem> playlistItems({required String playlistId}) {
    if (!_playlistItems.containsKey(playlistId)) {
      _playlistItems[playlistId] = [];
      _retrievePlaylist(playlistId);
    }
    return UnmodifiableListView(_playlistItems[playlistId]!);
  }

  Future<void> _retrievePlaylist(String playlistId) async {
    String? nextPageToken;
    do {
      var response = await _api.playlistItems.list(
        ['snippet', 'contentDetails'],
        playlistId: playlistId,
        maxResults: 25,
        pageToken: nextPageToken,
      );
      var items = response.items;
      if (items != null) {
        _playlistItems[playlistId]!.addAll(items);
      }
      notifyListeners();
      nextPageToken = response.nextPageToken;
    } while (nextPageToken != null);
  }
}

class _ApiKeyClient extends http.BaseClient {
  _ApiKeyClient({required this.key, required this.client});

  final String key;
  final http.Client client;

  @override
  Future<http.StreamedResponse> send(http.BaseRequest request) {
    final url = request.url.replace(queryParameters: {
      ...request.url.queryParametersAll,
      'key': [key]
    });

    return client.send(http.Request(request.method, url));
  }
}

Next, add the individual playlist detail page.

lib/src/playlist_details.dart

import 'package:flutter/material.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/link.dart';

import 'app_state.dart';

class PlaylistDetails extends StatelessWidget {
  const PlaylistDetails(
      {required this.playlistId, required this.playlistName, Key? key})
      : super(key: key);
  final String playlistId;
  final String playlistName;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(playlistName),
      ),
      body: Consumer<FlutterDevPlaylists>(
        builder: (context, flutterDev, _) {
          final playlistItems =
              flutterDev.playlistItems(playlistId: playlistId);
          if (playlistItems.isEmpty) {
            return const Center(child: CircularProgressIndicator());
          }

          return _PlaylistDetailsListView(playlistItems: playlistItems);
        },
      ),
    );
  }
}

class _PlaylistDetailsListView extends StatelessWidget {
  const _PlaylistDetailsListView({Key? key, required this.playlistItems})
      : super(key: key);
  final List<PlaylistItem> playlistItems;

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: playlistItems.length,
      itemBuilder: (context, index) {
        final playlistItem = playlistItems[index];
        return Padding(
          padding: const EdgeInsets.all(8.0),
          child: ClipRRect(
            borderRadius: BorderRadius.circular(4),
            child: Stack(
              alignment: Alignment.center,
              children: [
                Image.network(playlistItem.snippet!.thumbnails!.high!.url!),
                _buildGradient(context),
                _buildTitleAndSubtitle(context, playlistItem),
                _buildPlayButton(context, playlistItem),
              ],
            ),
          ),
        );
      },
    );
  }

  Widget _buildGradient(BuildContext context) {
    return Positioned.fill(
      child: DecoratedBox(
        decoration: BoxDecoration(
          gradient: LinearGradient(
            colors: [Colors.transparent, Theme.of(context).backgroundColor],
            begin: Alignment.topCenter,
            end: Alignment.bottomCenter,
            stops: const [0.5, 0.95],
          ),
        ),
      ),
    );
  }

  Widget _buildTitleAndSubtitle(
      BuildContext context, PlaylistItem playlistItem) {
    return Positioned(
      left: 20,
      right: 0,
      bottom: 20,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            playlistItem.snippet!.title!,
            style: Theme.of(context).textTheme.bodyText1!.copyWith(
                  fontSize: 18,
                  // fontWeight: FontWeight.bold,
                ),
          ),
          Text(
            playlistItem.snippet!.videoOwnerChannelTitle!,
            style: Theme.of(context).textTheme.bodyText2!.copyWith(
                  fontSize: 12,
                ),
          ),
        ],
      ),
    );
  }

  Widget _buildPlayButton(BuildContext context, PlaylistItem playlistItem) {
    return Container(
      width: 42,
      height: 42,
      decoration: const BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.all(
          Radius.circular(21),
        ),
      ),
      child: Center(
        child: Transform.scale(
          scale: 2,
          child: Link(
            uri: Uri.parse(
                'https://www.youtube.com/watch?v=${playlistItem.snippet!.resourceId!.videoId}'),
            builder: (context, followLink) => IconButton(
              onPressed: followLink,
              color: Colors.red,
              icon: const Icon(Icons.play_circle_fill),
            ),
          ),
        ),
      ),
    );
  }
}

Next, add the list of playlists.

lib/src/playlists.dart

import 'package:flutter/material.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:provider/provider.dart';

import 'app_state.dart';
import 'playlist_details.dart';

class Playlists extends StatelessWidget {
  const Playlists({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('FlutterDev Playlists'),
      ),
      body: Consumer<FlutterDevPlaylists>(
        builder: (context, flutterDev, child) {
          final playlists = flutterDev.playlists;
          if (playlists.isEmpty) {
            return const Center(
              child: CircularProgressIndicator(),
            );
          }

          return _PlaylistsListView(items: playlists);
        },
      ),
    );
  }
}

class _PlaylistsListView extends StatelessWidget {
  const _PlaylistsListView({
    Key? key,
    required this.items,
  }) : super(key: key);

  final List<Playlist> items;

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: items.length,
      itemBuilder: (context, index) {
        var playlist = items[index];
        return Padding(
          padding: const EdgeInsets.all(8.0),
          child: ListTile(
            leading: Image.network(
              playlist.snippet!.thumbnails!.default_!.url!,
            ),
            title: Text(playlist.snippet!.title!),
            subtitle: Text(
              playlist.snippet!.description!,
            ),
            onTap: () {
              Navigator.push(
                context,
                MaterialPageRoute(
                  builder: (context) {
                    return PlaylistDetails(
                      playlistId: playlist.id!,
                      playlistName: playlist.snippet!.title!,
                    );
                  },
                ),
              );
            },
          ),
        );
      },
    );
  }
}

And replace content of main.dart file as follows:

lib/main.dart

import 'dart:io';

import 'package:flex_color_scheme/flex_color_scheme.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

import 'src/app_state.dart';
import 'src/playlists.dart';

// From https://www.youtube.com/channel/UCwXdFgeE9KYzlDdR7TG9cMw
const flutterDevAccountId = 'UCwXdFgeE9KYzlDdR7TG9cMw';

// TODO: Replace with your YouTube API Key
const youTubeApiKey = 'AIzaNotAnApiKey';

void main() {
  if (youTubeApiKey == 'AIzaNotAnApiKey') {
    print('youTubeApiKey has not been configured.');
    exit(1);
  }

  runApp(ChangeNotifierProvider<FlutterDevPlaylists>(
    create: (BuildContext context) => FlutterDevPlaylists(
      flutterDevAccountId: flutterDevAccountId,
      youTubeApiKey: youTubeApiKey,
    ),
    child: const PlaylistsApp(),
  ));
}

class PlaylistsApp extends StatelessWidget {
  const PlaylistsApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'FlutterDev Playlists',
      theme: FlexColorScheme.light(scheme: FlexScheme.red).toTheme,
      darkTheme: FlexColorScheme.dark(scheme: FlexScheme.red).toTheme,
      themeMode: ThemeMode.dark, // Or ThemeMode.System if you'd prefer
      debugShowCheckedModeBanner: false,
      home: const Playlists(),
    );
  }
}

You are almost ready to run this code on Android and iOS. Just one more thing to change, modify the youTubeApiKey constant on line 14 with the YouTube API Key generated in the previous step.

lib/main.dart

// TODO: Replace with your YouTube API Key
const youTubeApiKey = 'AIzaNotAnApiKey';

Run the app

Now that you have a complete application, you should be able to run it successfully on an Android emulator or an iPhone simulator. You will see a list of Flutter's playlists, when you select a playlist you will see the videos on that playlist, and finally if you click the Play button, you will be launched into the YouTube experience to watch the video.

If, however, you attempt to run this app on desktop, you will see the layout feels wrong when expanded into a normal desktop-sized window. You will look into ways to adapt to this in the next step.

5. Adapting to the desktop

The desktop problem

If you run the app on one of the native desktop platforms, Windows, macOS, or Linux, you will notice an interesting problem. It works, but it looks ... odd.

A fix for this is to add a split view, listing the playlists on the left, and the videos on the right. However, you only want this layout to kick in when the code isn't running on Android or iOS, and the window is wide enough. The following instructions show how to implement this capability.

First, add in the split_view package to aid in constructing the layout.

$ flutter pub add split_view
Resolving dependencies...
  async 2.8.1 (2.8.2 available)
  matcher 0.12.10 (0.12.11 available)
+ split_view 3.1.0
  test_api 0.4.2 (0.4.3 available)
Changed 1 dependency!

Introducing adaptive widgets

The pattern you are going to use in this codelab is to introduce Adaptive widgets that make implementation choices based on attributes like screen width, platform theme, and the like. In this case, you are going to introduce an AdaptivePlaylists widget that re-works how Playlists and PlaylistDetails interact. Edit the lib/main.dart file as follows:

lib/main.dart

import 'dart:io';

import 'package:flex_color_scheme/flex_color_scheme.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

import 'src/adaptive_playlists.dart'; // Add this import
import 'src/app_state.dart';
// Delete the src/playlists.dart import

// From https://www.youtube.com/channel/UCwXdFgeE9KYzlDdR7TG9cMw
const flutterDevAccountId = 'UCwXdFgeE9KYzlDdR7TG9cMw';

// TODO: Replace with your YouTube API Key
const youTubeApiKey = 'AIzaNotAnApiKey';

void main() {
  if (youTubeApiKey == 'AIzaNotAnApiKey') {
    print('youTubeApiKey has not been configured.');
    exit(1);
  }

  runApp(ChangeNotifierProvider<FlutterDevPlaylists>(
    create: (BuildContext context) => FlutterDevPlaylists(
      flutterDevAccountId: flutterDevAccountId,
      youTubeApiKey: youTubeApiKey,
    ),
    child: const PlaylistsApp(),
  ));
}

class PlaylistsApp extends StatelessWidget {
  const PlaylistsApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'FlutterDev Playlists',
      theme: FlexColorScheme.light(scheme: FlexScheme.red).toTheme,
      darkTheme: FlexColorScheme.dark(scheme: FlexScheme.red).toTheme,
      themeMode: ThemeMode.dark, // Or ThemeMode.System if you'd prefer
      debugShowCheckedModeBanner: false,
      home: const AdaptivePlaylists(), // Edit this line
    );
  }
}

Next, create the file for the AdaptivePlaylist widget:

lib/src/adaptive_playlists.dart

import 'package:flutter/material.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:split_view/split_view.dart';

import 'playlist_details.dart';
import 'playlists.dart';

class AdaptivePlaylists extends StatelessWidget {
  const AdaptivePlaylists({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final screenWidth = MediaQuery.of(context).size.width;
    final targetPlatform = Theme.of(context).platform;

    if (targetPlatform == TargetPlatform.android ||
        targetPlatform == TargetPlatform.iOS ||
        screenWidth <= 600) {
      return const NarrowDisplayPlaylists();
    } else {
      return const WideDisplayPlaylists();
    }
  }
}

class NarrowDisplayPlaylists extends StatelessWidget {
  const NarrowDisplayPlaylists({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('FlutterDev Playlists')),
      body: Playlists(
        playlistSelected: (Playlist playlist) {
          Navigator.push(
            context,
            MaterialPageRoute(
              builder: (context) {
                return Scaffold(
                  appBar: AppBar(title: Text(playlist.snippet!.title!)),
                  body: PlaylistDetails(
                    playlistId: playlist.id!,
                    playlistName: playlist.snippet!.title!,
                  ),
                );
              },
            ),
          );
        },
      ),
    );
  }
}

class WideDisplayPlaylists extends StatefulWidget {
  const WideDisplayPlaylists({
    Key? key,
  }) : super(key: key);

  @override
  State<WideDisplayPlaylists> createState() => _WideDisplayPlaylistsState();
}

class _WideDisplayPlaylistsState extends State<WideDisplayPlaylists> {
  Playlist? selectedPlaylist;
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: selectedPlaylist == null
            ? const Text('FlutterDev Playlists')
            : Text('FlutterDev Playlist: ${selectedPlaylist!.snippet!.title!}'),
      ),
      body: SplitView(
        viewMode: SplitViewMode.Horizontal,
        children: [
          Playlists(playlistSelected: (playlist) {
            setState(() {
              selectedPlaylist = playlist;
            });
          }),
          if (selectedPlaylist != null)
            PlaylistDetails(
                playlistId: selectedPlaylist!.id!,
                playlistName: selectedPlaylist!.snippet!.title!)
          else
            const Center(
              child: Text('Select a playlist'),
            ),
        ],
      ),
    );
  }
}

This file is interesting for a couple of different reasons. First, it's using both the width of the window (using MediaQuery.of(context).size.width), and you are inspecting the theme (using Theme.of(context).platform) to decide whether to display a wide layout with the SplitView widget, or a narrow display without it.

The second point of note is that it's dealing with the previously hard coded handling of pushing a route. This is done by surfacing a callback argument in the Playlists widget that notifies the surrounding code that the user has selected a playlist, and needs to do whatever is required to display that playlist. Also note that the Scaffold has been factored out of the Playlists and PlaylistDetails widgets now that these widgets aren't top level.

Next, edit the src/lib/playlists.dart file as follows:

lib/src/playlists.dart

import 'package:flutter/material.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:provider/provider.dart';

import 'app_state.dart';

class Playlists extends StatelessWidget {
  const Playlists({required this.playlistSelected, Key? key}) : super(key: key);

  final PlaylistsListSelected playlistSelected;

  @override
  Widget build(BuildContext context) {
    return Consumer<FlutterDevPlaylists>(
      builder: (context, flutterDev, child) {
        final playlists = flutterDev.playlists;
        if (playlists.isEmpty) {
          return const Center(
            child: CircularProgressIndicator(),
          );
        }

        return _PlaylistsListView(
          items: playlists,
          playlistSelected: playlistSelected,
        );
      },
    );
  }
}

typedef PlaylistsListSelected = void Function(Playlist playlist);

class _PlaylistsListView extends StatefulWidget {
  const _PlaylistsListView({
    Key? key,
    required this.items,
    required this.playlistSelected,
  }) : super(key: key);

  final List<Playlist> items;
  final PlaylistsListSelected playlistSelected;

  @override
  State<_PlaylistsListView> createState() => _PlaylistsListViewState();
}

class _PlaylistsListViewState extends State<_PlaylistsListView> {
  late ScrollController _scrollController;

  @override
  void initState() {
    super.initState();
    _scrollController = ScrollController();
  }

  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      controller: _scrollController,
      itemCount: widget.items.length,
      itemBuilder: (context, index) {
        var playlist = widget.items[index];
        return Padding(
          padding: const EdgeInsets.all(8.0),
          child: ListTile(
            leading: Image.network(
              playlist.snippet!.thumbnails!.default_!.url!,
            ),
            title: Text(playlist.snippet!.title!),
            subtitle: Text(
              playlist.snippet!.description!,
            ),
            onTap: () {
              widget.playlistSelected(playlist);
            },
          ),
        );
      },
    );
  }
}

There is a lot of change in this file. Apart from the aforementioned introduction of a playlistSelected callback, and the elimination of the Scaffold widget, the _PlaylistsListView widget is converted from stateless to stateful. This change is required due to the introduction of an owned ScrollController that has to be constructed and destroyed.

The introduction of a ScrollController is interesting because it is required due to the fact that on a wide layout you have two ListView widgets side by side. On a mobile phone it is traditional to have a single ListView, and thus there can be a single long-lived ScrollController that all ListViews attach to, and detach from, during their individual life cycles. Desktop is different, in a world where multiple ListViews side by side make sense.

And finally, edit the lib/src/playlist_details.dart file as follows:

lib/src/playlist_details.dart

import 'package:flutter/material.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/link.dart';

import 'app_state.dart';

class PlaylistDetails extends StatelessWidget {
  const PlaylistDetails(
      {required this.playlistId, required this.playlistName, Key? key})
      : super(key: key);
  final String playlistId;
  final String playlistName;

  @override
  Widget build(BuildContext context) {
    return Consumer<FlutterDevPlaylists>(
      builder: (context, flutterDev, _) {
        final playlistItems = flutterDev.playlistItems(playlistId: playlistId);
        if (playlistItems.isEmpty) {
          return const Center(child: CircularProgressIndicator());
        }

        return _PlaylistDetailsListView(playlistItems: playlistItems);
      },
    );
  }
}

class _PlaylistDetailsListView extends StatefulWidget {
  const _PlaylistDetailsListView({Key? key, required this.playlistItems})
      : super(key: key);
  final List<PlaylistItem> playlistItems;

  @override
  State<_PlaylistDetailsListView> createState() =>
      _PlaylistDetailsListViewState();
}

class _PlaylistDetailsListViewState extends State<_PlaylistDetailsListView> {
  late ScrollController _scrollController;

  @override
  void initState() {
    super.initState();
    _scrollController = ScrollController();
  }

  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      controller: _scrollController,
      itemCount: widget.playlistItems.length,
      itemBuilder: (context, index) {
        final playlistItem = widget.playlistItems[index];
        return Padding(
          padding: const EdgeInsets.all(8.0),
          child: ClipRRect(
            borderRadius: BorderRadius.circular(4),
            child: Stack(
              alignment: Alignment.center,
              children: [
                Image.network(playlistItem.snippet!.thumbnails!.high!.url!),
                _buildGradient(context),
                _buildTitleAndSubtitle(context, playlistItem),
                _buildPlayButton(context, playlistItem),
              ],
            ),
          ),
        );
      },
    );
  }

  Widget _buildGradient(BuildContext context) {
    return Positioned.fill(
      child: DecoratedBox(
        decoration: BoxDecoration(
          gradient: LinearGradient(
            colors: [Colors.transparent, Theme.of(context).backgroundColor],
            begin: Alignment.topCenter,
            end: Alignment.bottomCenter,
            stops: const [0.5, 0.95],
          ),
        ),
      ),
    );
  }

  Widget _buildTitleAndSubtitle(
      BuildContext context, PlaylistItem playlistItem) {
    return Positioned(
      left: 20,
      right: 0,
      bottom: 20,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            playlistItem.snippet!.title!,
            style: Theme.of(context).textTheme.bodyText1!.copyWith(
                  fontSize: 18,
                  // fontWeight: FontWeight.bold,
                ),
          ),
          Text(
            playlistItem.snippet!.videoOwnerChannelTitle!,
            style: Theme.of(context).textTheme.bodyText2!.copyWith(
                  fontSize: 12,
                ),
          ),
        ],
      ),
    );
  }

  Widget _buildPlayButton(BuildContext context, PlaylistItem playlistItem) {
    return Container(
      width: 42,
      height: 42,
      decoration: const BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.all(
          Radius.circular(21),
        ),
      ),
      child: Center(
        child: Transform.scale(
          scale: 2,
          child: Link(
            uri: Uri.parse(
                'https://www.youtube.com/watch?v=${playlistItem.snippet!.resourceId!.videoId}'),
            builder: (context, followLink) => IconButton(
              onPressed: followLink,
              color: Colors.red,
              icon: const Icon(Icons.play_circle_fill),
            ),
          ),
        ),
      ),
    );
  }
}

Akin to the Playlists widget above, this file also has changes for the elimination of the Scaffold widget, and the introduction of an owned ScrollController.

Run the app again!

Running the app on your choice of desktop, be it Windows, macOS, or Linux. It should now work as you'd expect.

a62707b349375734.png

6. Adapting to the web

What's up with those images, eh?

If you attempt to run this app as is on the web, even with the layout changes from the previous step, you will see that there is some remaining work to adapt to the web browser environment:

40f4d5a6d1e75de4.png

If you take a peek in the debug console, you will see a gentle hint as to what you must do next.

════════ Exception caught by image resource service ════════════════════════════
Failed to load network image.
Image URL: https://i.ytimg.com/vi/QIW35-vcA2o/default.jpg
Trying to load an image from another domain? Find answers at:
https://flutter.dev/docs/development/platform-integration/web-images
═════════════════════════════════════════════════════════════════════════

Creating a CORS Proxy

One way to deal with the image rendering issues is to introduce a proxy web service to add in the required Cross Origin Resource Sharing headers. Bring up a terminal and create a Dart web server as follows:

$ dart create --template server-shelf yt_cors_proxy
Creating yt_cors_proxy using template server-shelf...

  .gitignore
  analysis_options.yaml
  CHANGELOG.md
  pubspec.yaml
  README.md
  Dockerfile
  .dockerignore
  test/server_test.dart
  bin/server.dart

Running pub get...                     3.9s
  Resolving dependencies...
  Changed 53 dependencies!

Created project yt_cors_proxy in yt_cors_proxy! In order to get started, run the following commands:

  cd yt_cors_proxy
  dart run bin/server.dart

Change directory into the yt_cors_proxy server, and add a couple of required dependencies:

$ cd yt_cors_proxy
$ dart pub add shelf_cors_headers
Resolving dependencies... 
+ shelf_cors_headers 0.1.2
Changed 1 dependency!
$ dart pub add http
"http" was found in dev_dependencies. Removing "http" and adding it to dependencies instead.
Resolving dependencies... 
Got dependencies!

There are some current dependencies that are no longer required. Trim these as follows:

$ dart pub remove args
Resolving dependencies... 
These packages are no longer being depended on:
- args 2.2.0
Changed 1 dependency!
$ dart pub remove shelf_router
Resolving dependencies... 
These packages are no longer being depended on:
- http_methods 1.1.0
- shelf_router 1.1.1
Changed 2 dependencies!

Next, modify the contents of the server.dart file to match the following:

yt_cors_proxy/bin/server.dart

import 'dart:async';
import 'dart:io';

import 'package:http/http.dart' as http;
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart';
import 'package:shelf_cors_headers/shelf_cors_headers.dart';

Future<Response> _requestHandler(Request req) async {
  final target = req.url.replace(scheme: 'https', host: 'i.ytimg.com');
  final response = await http.get(target);
  return Response.ok(response.bodyBytes, headers: response.headers);
}

void main(List<String> args) async {
  // Use any available host or container IP (usually `0.0.0.0`).
  final ip = InternetAddress.anyIPv4;

  // Configure a pipeline that adds CORS headers and proxies requests.
  final handler = Pipeline()
      .addMiddleware(logRequests())
      .addMiddleware(corsHeaders(headers: {ACCESS_CONTROL_ALLOW_ORIGIN: '*'}))
      .addHandler(_requestHandler);

  // For running in containers, respect the PORT environment variable.
  final port = int.parse(Platform.environment['PORT'] ?? '8080');
  final server = await serve(handler, ip, port);
  print('Server listening on port ${server.port}');
}

You can run this server as follows:

$ dart run bin/server.dart 
Server listening on port 8080

Alternatively, you can build it as a Docker image, and run the resulting Docker image as follows:

$ docker build . -t yt-cors-proxy      
[+] Building 2.7s (14/14) FINISHED
$ docker run -p 8080:8080 yt-cors-proxy 
Server listening on port 8080

Next, modify the Flutter code to take advantage of this CORS proxy, but only when running inside a web browser.

A pair of adaptable widgets

The first of the pair of widgets is how your app will use the CORS proxy.

lib/src/adaptive_image.dart

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';

class AdaptiveImage extends StatelessWidget {
  AdaptiveImage.network(String url, {Key? key}) : super(key: key) {
    if (kIsWeb) {
      _url = Uri.parse(url)
          .replace(host: 'localhost', port: 8080, scheme: 'http')
          .toString();
    } else {
      _url = url;
    }
  }

  late final String _url;

  @override
  Widget build(BuildContext context) {
    return Image.network(_url);
  }
}

The interesting thing to note is that you are using kIsWeb due to the fact that this isn't a difference due to theming, but instead is a difference due to the runtime platform. The other adaptable widget deals with the fact that web browser users expect text to be selectable:

lib/src/adaptive_text.dart

import 'package:flutter/material.dart';

class AdaptiveText extends StatelessWidget {
  const AdaptiveText(this.data, {Key? key, this.style}) : super(key: key);
  final String data;
  final TextStyle? style;

  @override
  Widget build(BuildContext context) {
    switch (Theme.of(context).platform) {
      case TargetPlatform.android:
      case TargetPlatform.iOS:
        return Text(data, style: style);
      default:
        return SelectableText(data, style: style);
    }
  }
}

Now, spread these adaptations throughout the codebase:

lib/src/playlist_details.dart

import 'package:flutter/material.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/link.dart';

import 'adaptive_image.dart';   // Add this line,
import 'adaptive_text.dart';    // and this line
import 'app_state.dart';

class PlaylistDetails extends StatelessWidget {
  const PlaylistDetails(
      {required this.playlistId, required this.playlistName, Key? key})
      : super(key: key);
  final String playlistId;
  final String playlistName;

  @override
  Widget build(BuildContext context) {
    return Consumer<FlutterDevPlaylists>(
      builder: (context, flutterDev, _) {
        final playlistItems = flutterDev.playlistItems(playlistId: playlistId);
        if (playlistItems.isEmpty) {
          return const Center(child: CircularProgressIndicator());
        }

        return _PlaylistDetailsListView(playlistItems: playlistItems);
      },
    );
  }
}

class _PlaylistDetailsListView extends StatefulWidget {
  const _PlaylistDetailsListView({Key? key, required this.playlistItems})
      : super(key: key);
  final List<PlaylistItem> playlistItems;

  @override
  State<_PlaylistDetailsListView> createState() =>
      _PlaylistDetailsListViewState();
}

class _PlaylistDetailsListViewState extends State<_PlaylistDetailsListView> {
  late ScrollController _scrollController;

  @override
  void initState() {
    super.initState();
    _scrollController = ScrollController();
  }

  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      controller: _scrollController,
      itemCount: widget.playlistItems.length,
      itemBuilder: (context, index) {
        final playlistItem = widget.playlistItems[index];
        return Padding(
          padding: const EdgeInsets.all(8.0),
          child: ClipRRect(
            borderRadius: BorderRadius.circular(4),
            child: Stack(
              alignment: Alignment.center,
              children: [
                AdaptiveImage.network(    // Modify this line
                    playlistItem.snippet!.thumbnails!.high!.url!),
                _buildGradient(context),
                _buildTitleAndSubtitle(context, playlistItem),
                _buildPlayButton(context, playlistItem),
              ],
            ),
          ),
        );
      },
    );
  }

  Widget _buildGradient(BuildContext context) {
    return Positioned.fill(
      child: DecoratedBox(
        decoration: BoxDecoration(
          gradient: LinearGradient(
            colors: [Colors.transparent, Theme.of(context).backgroundColor],
            begin: Alignment.topCenter,
            end: Alignment.bottomCenter,
            stops: const [0.5, 0.95],
          ),
        ),
      ),
    );
  }

  Widget _buildTitleAndSubtitle(
      BuildContext context, PlaylistItem playlistItem) {
    return Positioned(
      left: 20,
      right: 0,
      bottom: 20,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          AdaptiveText(                   // This line
            playlistItem.snippet!.title!,
            style: Theme.of(context).textTheme.bodyText1!.copyWith(
                  fontSize: 18,
                  // fontWeight: FontWeight.bold,
                ),
          ),
          AdaptiveText(                   // And, this line
            playlistItem.snippet!.videoOwnerChannelTitle!,
            style: Theme.of(context).textTheme.bodyText2!.copyWith(
                  fontSize: 12,
                ),
          ),
        ],
      ),
    );
  }

  Widget _buildPlayButton(BuildContext context, PlaylistItem playlistItem) {
    return Container(
      width: 42,
      height: 42,
      decoration: const BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.all(
          Radius.circular(21),
        ),
      ),
      child: Center(
        child: Transform.scale(
          scale: 2,
          child: Link(
            uri: Uri.parse(
                'https://www.youtube.com/watch?v=${playlistItem.snippet!.resourceId!.videoId}'),
            builder: (context, followLink) => IconButton(
              onPressed: followLink,
              color: Colors.red,
              icon: const Icon(Icons.play_circle_fill),
            ),
          ),
        ),
      ),
    );
  }
}

In the above code you adapted both the Image.network and the Text widgets. Next, adapt the Playlists widget.

lib/src/playlists.dart

import 'package:flutter/material.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:provider/provider.dart';

import 'adaptive_image.dart'; // Add this line
import 'app_state.dart';

class Playlists extends StatelessWidget {
  const Playlists({required this.playlistSelected, Key? key}) : super(key: key);

  final PlaylistsListSelected playlistSelected;

  @override
  Widget build(BuildContext context) {
    return Consumer<FlutterDevPlaylists>(
      builder: (context, flutterDev, child) {
        final playlists = flutterDev.playlists;
        if (playlists.isEmpty) {
          return const Center(
            child: CircularProgressIndicator(),
          );
        }

        return _PlaylistsListView(
          items: playlists,
          playlistSelected: playlistSelected,
        );
      },
    );
  }
}

typedef PlaylistsListSelected = void Function(Playlist playlist);

class _PlaylistsListView extends StatefulWidget {
  const _PlaylistsListView({
    Key? key,
    required this.items,
    required this.playlistSelected,
  }) : super(key: key);

  final List<Playlist> items;
  final PlaylistsListSelected playlistSelected;

  @override
  State<_PlaylistsListView> createState() => _PlaylistsListViewState();
}

class _PlaylistsListViewState extends State<_PlaylistsListView> {
  late ScrollController _scrollController;

  @override
  void initState() {
    super.initState();
    _scrollController = ScrollController();
  }

  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      controller: _scrollController,
      itemCount: widget.items.length,
      itemBuilder: (context, index) {
        var playlist = widget.items[index];
        return Padding(
          padding: const EdgeInsets.all(8.0),
          child: ListTile(
            leading: AdaptiveImage.network(  // Change this one.
              playlist.snippet!.thumbnails!.default_!.url!,
            ),
            title: Text(playlist.snippet!.title!),
            subtitle: Text(
              playlist.snippet!.description!,
            ),
            onTap: () {
              widget.playlistSelected(playlist);
            },
          ),
        );
      },
    );
  }
}

This time you only adapted the Image.network widget, but left the two Text widgets as they were. This was intentional because, if you adapt the Text widgets, the ListTile's onTap functionality is blocked when the user taps on the text.

Run the app on the web, properly

With the CORS proxy running, you should be able to run the web version of the app and have it look something like the following:

5663aaa4d91ad4c5.png

7. Adaptive Authentication

In this step you are going to extend the app by giving it the ability to authenticate the user, and then show that user's playlists. You are going to have to use multiple plugins to cover the different platforms the app can run on, because handling OAuth is done very differently between Android, iOS, the web, Windows, macOS, and Linux.

Adding plugins to enable Google authentication

You are going to install three packages to handle Google authentication.

$ flutter pub add googleapis_auth
Resolving dependencies...
  async 2.8.1 (2.8.2 available)
+ crypto 3.0.1
+ googleapis_auth 1.1.0
  matcher 0.12.10 (0.12.11 available)
  test_api 0.4.2 (0.4.3 available)
Changed 2 dependencies!
$ flutter pub add google_sign_in
Resolving dependencies...
  async 2.8.1 (2.8.2 available)
+ google_sign_in 5.0.7
+ google_sign_in_platform_interface 2.0.1
+ google_sign_in_web 0.10.0+2
  matcher 0.12.10 (0.12.11 available)
+ quiver 3.0.1
  test_api 0.4.2 (0.4.3 available)
Changed 4 dependencies!
$ flutter pub add extension_google_sign_in_as_googleapis_auth
Resolving dependencies...
  async 2.8.1 (2.8.2 available)
+ extension_google_sign_in_as_googleapis_auth 2.0.2
  matcher 0.12.10 (0.12.11 available)
  test_api 0.4.2 (0.4.3 available)
Changed 1 dependency!

Use the googleapis_auth plugin to authenticate on Windows, macOS, and Linux using the user's web browser. On Android, iOS, and the web, use google_sign_in, with extension_google_sign_in_as_googleapis_auth acting as an interop shim between the two packages.

Update the code

Start the update by creating a new reusable abstraction, the AdaptiveLogin widget. This widget is designed for you to re-use, and as such requires some configuration:

lib/src/adaptive_login.dart

import 'dart:io' show Platform;

import 'package:extension_google_sign_in_as_googleapis_auth/extension_google_sign_in_as_googleapis_auth.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:googleapis_auth/auth_io.dart';
import 'package:http/http.dart' as http;
import 'package:url_launcher/link.dart';

typedef AdaptiveLoginBuilder = Widget Function(
  BuildContext context,
  http.Client authClient,
);

typedef _AdaptiveLoginButtonWidget = Widget Function({
  required VoidCallback? onPressed,
});

class AdaptiveLogin extends StatelessWidget {
  const AdaptiveLogin(
      {required this.builder,
      required this.clientId,
      required this.scopes,
      required this.loginButtonChild,
      Key? key})
      : super(key: key);
  final AdaptiveLoginBuilder builder;
  final ClientId clientId;
  final List<String> scopes;
  final Widget loginButtonChild;

  @override
  Widget build(BuildContext context) {
    if (kIsWeb || Platform.isAndroid || Platform.isIOS) {
      return _GoogleSignInLogin(
        builder: builder,
        button: _loginButton,
        scopes: scopes,
      );
    } else {
      return _GoogleApisAuthLogin(
        builder: builder,
        button: _loginButton,
        scopes: scopes,
        clientId: clientId,
      );
    }
  }

  Widget _loginButton({required VoidCallback? onPressed}) => ElevatedButton(
        onPressed: onPressed,
        child: loginButtonChild,
      );
}

class _GoogleSignInLogin extends StatefulWidget {
  const _GoogleSignInLogin({
    required this.builder,
    required this.button,
    required this.scopes,
  });
  final AdaptiveLoginBuilder builder;
  final _AdaptiveLoginButtonWidget button;
  final List<String> scopes;

  @override
  State<_GoogleSignInLogin> createState() => _GoogleSignInLoginState();
}

class _GoogleSignInLoginState extends State<_GoogleSignInLogin> {
  @override
  initState() {
    super.initState();
    _googleSignIn = GoogleSignIn(
      scopes: widget.scopes,
    );
    _googleSignIn.onCurrentUserChanged.listen((account) {
      if (account != null) {
        _googleSignIn.authenticatedClient().then((authClient) {
          setState(() {
            _authClient = authClient;
          });
        });
      }
    });
  }

  late final GoogleSignIn _googleSignIn;
  http.Client? _authClient;

  @override
  Widget build(BuildContext context) {
    final authClient = _authClient;
    if (authClient != null) {
      return widget.builder(context, authClient);
    }

    return Scaffold(
      body: Center(
        child: widget.button(onPressed: () {
          _googleSignIn.signIn();
        }),
      ),
    );
  }
}

class _GoogleApisAuthLogin extends StatefulWidget {
  const _GoogleApisAuthLogin({
    required this.builder,
    required this.button,
    required this.scopes,
    required this.clientId,
  });
  final AdaptiveLoginBuilder builder;
  final _AdaptiveLoginButtonWidget button;
  final List<String> scopes;
  final ClientId clientId;

  @override
  State<_GoogleApisAuthLogin> createState() => _GoogleApisAuthLoginState();
}

class _GoogleApisAuthLoginState extends State<_GoogleApisAuthLogin> {
  @override
  initState() {
    super.initState();
    clientViaUserConsent(widget.clientId, widget.scopes, (url) {
      setState(() {
        _authUrl = Uri.parse(url);
      });
    }).then((authClient) {
      setState(() {
        _authClient = authClient;
      });
    });
  }

  Uri? _authUrl;
  http.Client? _authClient;

  @override
  Widget build(BuildContext context) {
    final authClient = _authClient;
    if (authClient != null) {
      return widget.builder(context, authClient);
    }

    final authUrl = _authUrl;
    if (authUrl != null) {
      return Scaffold(
        body: Center(
          child: Link(
            uri: authUrl,
            builder: (context, followLink) =>
                widget.button(onPressed: followLink),
          ),
        ),
      );
    }

    return const Scaffold(
      body: Center(
        child: CircularProgressIndicator(),
      ),
    );
  }
}

There is a lot going on in this file. The important part is that in AdaptiveLogin's build method, the runtime platform is checked using a combination of kIsWeb, and dart:io's Platform.isXXX calls, instantiating the _GoogleSignInLogin stateful widget for Android, iOS, and the web; while for Windows, macOS, and Linux, a _GoogleApisAuthLogin stateful widget is constructed.

Additional configuration is required to use these classes, which comes later, after updating the rest of the code base to use this new widget. Start with renaming the FlutterDevPlaylists to AuthedUserPlaylists to better reflect its new purpose in life, and updating the code to reflect that the http.Client is now passed after construction. Finally, the _ApiKeyClient class is no longer required:

lib/src/app_state.dart

import 'dart:collection';

import 'package:flutter/foundation.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:http/http.dart' as http;

class AuthedUserPlaylists extends ChangeNotifier {  // Rename class
  set authClient(http.Client client) { // Drop constructor, add setter
    _api = YouTubeApi(client);
    _loadPlaylists();
  }

  Future<void> _loadPlaylists() async {
    String? nextPageToken;
    _playlists.clear();

    do {
      final response = await _api!.playlists.list(  // Add ! to _api
        ['snippet', 'contentDetails', 'id'],
        mine: true,   // convert from channelId: to mine:
        maxResults: 50,
        pageToken: nextPageToken,
      );
      _playlists.addAll(response.items!);
      _playlists.sort((a, b) => a.snippet!.title!
          .toLowerCase()
          .compareTo(b.snippet!.title!.toLowerCase()));
      notifyListeners();
    } while (nextPageToken != null);
  }

  YouTubeApi? _api;    // Convert from late final to optional

  final List<Playlist> _playlists = [];
  List<Playlist> get playlists => UnmodifiableListView(_playlists);

  final Map<String, List<PlaylistItem>> _playlistItems = {};
  List<PlaylistItem> playlistItems({required String playlistId}) {
    if (!_playlistItems.containsKey(playlistId)) {
      _playlistItems[playlistId] = [];
      _retrievePlaylist(playlistId);
    }
    return UnmodifiableListView(_playlistItems[playlistId]!);
  }

  Future<void> _retrievePlaylist(String playlistId) async {
    String? nextPageToken;
    do {
      var response = await _api!.playlistItems.list(  // Add ! to _api
        ['snippet', 'contentDetails'],
        playlistId: playlistId,
        maxResults: 25,
        pageToken: nextPageToken,
      );
      var items = response.items;
      if (items != null) {
        _playlistItems[playlistId]!.addAll(items);
      }
      notifyListeners();
      nextPageToken = response.nextPageToken;
    } while (nextPageToken != null);
  }
}

// Delete the now unused _ApiKeyClient class

Next, update the PlaylistDetails widget with the new name for the provided application state object:

lib/src/playlist_details.dart

class PlaylistDetails extends StatelessWidget {
  const PlaylistDetails(
      {required this.playlistId, required this.playlistName, Key? key})
      : super(key: key);
  final String playlistId;
  final String playlistName;

  @override
  Widget build(BuildContext context) {
    return Consumer<AuthedUserPlaylists>(  // Update this line
      builder: (context, flutterDev, _) {
        final playlistItems = flutterDev.playlistItems(playlistId: playlistId);
        if (playlistItems.isEmpty) {
          return const Center(child: CircularProgressIndicator());
        }

        return _PlaylistDetailsListView(playlistItems: playlistItems);
      },
    );
  }
}

Similarly, update the Playlists widget:

lib/src/playlists.dart

class Playlists extends StatelessWidget {
  const Playlists({required this.playlistSelected, Key? key}) : super(key: key);

  final PlaylistsListSelected playlistSelected;

  @override
  Widget build(BuildContext context) {
    return Consumer<AuthedUserPlaylists>(  // Update this line
      builder: (context, flutterDev, child) {
        final playlists = flutterDev.playlists;
        if (playlists.isEmpty) {
          return const Center(
            child: CircularProgressIndicator(),
          );
        }

        return _PlaylistsListView(
          items: playlists,
          playlistSelected: playlistSelected,
        );
      },
    );
  }
}

Finally, update the main.dart file to correctly use the new AdaptiveLogin widget:

lib/main.dart

// Drop dart:io import

import 'package:flex_color_scheme/flex_color_scheme.dart';
import 'package:flutter/material.dart';
import 'package:googleapis_auth/googleapis_auth.dart'; // Add this line
import 'package:provider/provider.dart';

import 'src/adaptive_login.dart';                     // Add this line
import 'src/adaptive_playlists.dart';
import 'src/app_state.dart';

// Drop flutterDevAccountId and youTubeApiKey

// Add from this line
// From https://developers.google.com/youtube/v3/guides/auth/installed-apps#identify-access-scopes
final scopes = [
  'https://www.googleapis.com/auth/youtube.readonly',
];

// TODO: Replace with your Client ID and Client Secret for Desktop configuration
final clientId = ClientId(
  'TODO-Client-ID.apps.googleusercontent.com',
  'TODO-Client-secret',
);
// To this line 

void main() {
  runApp(ChangeNotifierProvider<AuthedUserPlaylists>(        // Modify this line
    create: (BuildContext context) => AuthedUserPlaylists(), // Modify this line
    child: const PlaylistsApp(),
  ));
}

class PlaylistsApp extends StatelessWidget {
  const PlaylistsApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Your Playlists',            // Change FlutterDev to Your
      theme: FlexColorScheme.light(scheme: FlexScheme.red).toTheme,
      darkTheme: FlexColorScheme.dark(scheme: FlexScheme.red).toTheme,
      themeMode: ThemeMode.dark, // Or ThemeMode.System if you'd prefer
      debugShowCheckedModeBanner: false,
      // Modify from here
      home: AdaptiveLogin(
        builder: (context, authClient) {
          context.read<AuthedUserPlaylists>().authClient = authClient;
          return const AdaptivePlaylists();
        },
        clientId: clientId,
        scopes: scopes,
        loginButtonChild: const Text('Login to YouTube'),
      ),
      // to here.
    );
  }
}

The changes in this file reflect the change from just displaying Flutter's YouTube playlists to displaying the authenticated user's playlists. While the code is now complete, there are still a series of modifications required to this file, and the files under the respective Runner apps, to properly configure the google_sign_in and googleapis_auth packages for authentication.

Configuring googleapis_auth

The first step to configuring authentication is to eliminate the API Key you previously configured and used. Navigate to your API project's credentials page, and delete the API key:

276e2bacf644ddcb.png

This generates a pop-up that you acknowledge by hitting the Delete button:

42790bc5a9e33733.png

Then, create an OAuth client ID:

a9388c563c3af5fa.png

For Application type, select Desktop app.

14ac01c7e986f105.png

Accept the name, and click Create.

edc720d0fd6e90f3.png

This creates the Client ID and Client Secret that you must add to lib/main.dart to configure the googleapis_auth flow. An important implementation detail is that the googleapis_auth flow uses a temporary web server running on localhost to capture the generated OAuth token, which on macOS requires modifications to the entitlement files:

macos/Runner/DebugProfile.entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
        <dict>
                <key>com.apple.security.app-sandbox</key>
                <true/>
                <key>com.apple.security.cs.allow-jit</key>
                <true/>
                <key>com.apple.security.network.server</key>
                <true/>
                <!-- add the following two lines -->
                <key>com.apple.security.network.client</key>
                <true/>
        </dict>
</plist>

macos/Runner/Release.entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
        <dict>
                <key>com.apple.security.app-sandbox</key>
                <true/>
                <!-- add the following two lines -->
                <key>com.apple.security.network.client</key>
                <true/>
        </dict>
</plist>

You should now be able to run your app on Windows, macOS, or Linux (if the app was compiled on those targets).

9d70de0309005529.png

Configuring google_sign_in for Android

Head back to your API project's credentials page, and create another OAuth client ID, except this time select Android:

49c965ef6681aedc.png

For the rest of the form, fill in the Package name with the package declared in android/app/src/main/AndroidManifest.xml. If you have followed the directions to the letter, it should be com.example.adaptive_app. Extract the SHA-1 certificate fingerprint using the instructions from the Google Cloud Platform Console help page:

f5b66a6709fee529.png

This is enough to get the app working on Android. Depending on the choice of Google APIs you use, you may need to add the generated JSON file to your application bundle.

374673f67eb01869.png

Configuring google_sign_in for iOS

Head back to your API project's credentials page, and create another OAuth client ID, except this time select iOS:

. d7faeeaab4c1f158.png

For the rest of the form, fill in the Bundle ID by opening ios/Runner.xcworkspace in Xcode. Navigate to the Project Navigator, select the Runner in the navigator, then select the General tab, and copy the Bundle Identifier. If you have followed this codelab step by step, it should be com.example.adaptiveApp.

f6f31b1cd1817585.png

Ignore the App Store ID and the Team ID for now, as they aren't required for local development:

b716f94c08767a8.png

Download the generated .plist file, it's name is based on your generated client ID. Rename the downloaded file to GoogleService-Info.plist, and then drag it into your running Xcode editor, alongside the Info.plist file under Runner/Runner in the left hand navigator. For the options dialog in Xcode, select Copy items if needed, Create folder references, and Add to the Runner target.

8fdaa5b16a7848d8.png

Exit out of Xcode then, in your IDE of choice, add the following to your Info.plist:

ios/Runner/Info.plist

<key>CFBundleURLTypes</key>
<array>
        <dict>
                <key>CFBundleTypeRole</key>
                <string>Editor</string>
                <key>CFBundleURLSchemes</key>
                <array>
                        <!-- TODO Replace this value: -->
                        <!-- Copied from GoogleService-Info.plist key REVERSED_CLIENT_ID -->
                        <string>com.googleusercontent.apps.TODO-REPLACE-ME</string>
                </array>
        </dict>
</array>

You need to edit the value to match the entry in your generated GoogleService-Info.plist file. You also must set the minimum iOS version to 9. Edit the ios/Podfile as follows:

ios/Podfile

# iOS 9 for google_sign_in 
platform :ios, '9.0'      # Uncomment this line

# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'

project 'Runner', {
  'Debug' => :debug,
  'Profile' => :release,
  'Release' => :release,
}

def flutter_root
  generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
  unless File.exist?(generated_xcode_build_settings_path)
    raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
  end

  File.foreach(generated_xcode_build_settings_path) do |line|
    matches = line.match(/FLUTTER_ROOT\=(.*)/)
    return matches[1].strip if matches
  end
  raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end

require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)

flutter_ios_podfile_setup

target 'Runner' do
  use_frameworks!
  use_modular_headers!

  flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
end

post_install do |installer|
  installer.pods_project.targets.each do |target|
    flutter_additional_ios_build_settings(target)
  end
end

Run your app, and after logging in, you should see your playlists.

2288b1be35428203.png

Configuring google_sign_in for the web

Head back to your API project's credentials page, and create another OAuth client ID, except this time select Web application:

90d4e2545dc440ef.png

For the rest of the form, fill in the Authorized JavaScript origins as follows:

68e393aaeafc20f8.png

This generates a Client ID. Add the following meta tag to web/index.html, updated to include the generated Client ID:

web/index.html

<meta name="google-signin-client_id" content="YOUR_GOOGLE_SIGN_IN_OAUTH_CLIENT_ID.apps.googleusercontent.com">

Running this sample requires a bit of hand holding. You need to run the CORS proxy you created in the prior step, and you need to run the Flutter web app on the port specified in the Web application OAuth client ID form using the following instructions.

In one terminal, run the CORS Proxy server as follows:

$ dart run bin/server.dart
Server listening on port 8080

In another terminal, run the Flutter app as follows:

$ flutter run -d chrome --web-hostname localhost --web-port 8090
Launching lib/main.dart on Chrome in debug mode...
Waiting for connection from debug service on Chrome...             20.4s
This app is linked to the debug service: ws://127.0.0.1:52430/Nb3Q7puZqvI=/ws
Debug service listening on ws://127.0.0.1:52430/Nb3Q7puZqvI=/ws

💪 Running with sound null safety 💪

🔥  To hot restart changes while running, press "r" or "R".
For a more detailed help message, press "h". To quit, press "q".

After logging in once more, you should see your playlists:

b4bd465126a5f6a0.png

8. Next steps

Congratulations!

You've completed the codelab and built an adaptive Flutter app that runs on all six platforms that Flutter supports. You adapted the code to handle differences in how screens are layed out, how text is interacted with, how images are loaded, and how authentication works.

There are a lot more things that you can adapt in your applications. To learn additional ways to adapt your code to different environments where it will run, see Building adaptive apps.