Apps adaptáveis no Flutter

1. Introdução

O Flutter é um kit de ferramentas de IU do Google para criar apps incríveis e nativos para dispositivos móveis, Web e computadores com uma única base de código. Neste codelab, você vai aprender a criar um app do Flutter que se adapta à plataforma em que está sendo executado, seja ela Android, iOS, Web, Windows, macOS ou Linux.

O que você vai aprender

  • Como desenvolver um app do Flutter voltado para dispositivos móveis nas seis plataformas com suporte.
  • As diferentes APIs do Flutter para detectar a plataforma e quando usar cada uma delas.
  • Como se adaptar às restrições e expectativas ao executar um app na Web.
  • Como usar diferentes pacotes lado a lado para oferecer suporte toda a gama de plataformas do Flutter.

O que você vai criar

Neste codelab, você vai criar inicialmente um app do Flutter para Android e iOS que explora as playlists do YouTube no Flutter. Depois, você vai adaptar esse aplicativo para funcionar nas três plataformas de computador (Windows, macOS e Linux), modificando como as informações são mostradas de acordo com o tamanho da janela do aplicativo. Em seguida, vai adaptar o aplicativo para a Web, permitindo que o texto mostrado no app possa ser selecionado, como esperado pelos usuários. Finalmente, você vai adicionar autenticação ao app para poder conferir suas próprias playlists, em vez daquelas criadas pela equipe do Flutter, o que exige abordagens diferentes de autenticação para Android, iOS e Web em comparação com as três plataformas de computador, Windows, macOS e Linux.

Esta é uma captura de tela do app do Flutter em Android e iOS:

Esta é uma captura de tela do app em execução no macOS com layout de widescreen:

b424266e6fd4b3c3.png

O foco deste codelab é transformar o app do Flutter para dispositivos móveis em um app adaptável que funcione em todas as seis plataformas do Flutter. Conceitos não relevantes e blocos de código são citados rapidamente e fornecidos para que você simplesmente os copie e cole.

O que você quer aprender com este codelab?

Ainda não conheço bem o assunto e quero ter uma boa visão geral. Conheço um pouco sobre esse assunto, mas quero me atualizar. Estou procurando exemplos de código para usar no meu projeto. Estou procurando uma explicação de algo específico.

2. Configurar o ambiente de desenvolvimento do Flutter

Você precisa de dois softwares para concluir este laboratório: o SDK do Flutter e um editor (links em inglês).

É possível executar o codelab usando qualquer um destes dispositivos:

  • Um dispositivo físico Android ou iOS conectado ao computador e configurado para o modo de desenvolvedor.
  • O simulador do iOS, que exige a instalação de ferramentas do Xcode.
  • O Android Emulator, que requer configuração no Android Studio.
  • Um navegador (o Chrome é necessário para depuração).
  • Como um aplicativo para computador Windows, Linux ou macOS. Você precisa desenvolver na plataforma em que planeja implantar. Portanto, se quiser desenvolver um app para um computador Windows, você terá que desenvolver no Windows para acessar a cadeia de build adequada. Há requisitos específicos de cada sistema operacional que são abordados em detalhes em docs.flutter.dev/desktop.

3. Para começar

Como confirmar seu ambiente de desenvolvimento

A maneira mais fácil de garantir que tudo esteja pronto para desenvolvimento é executar o comando a seguir:

$ flutter doctor

Se algo aparecer sem uma marca de seleção, execute o seguinte para saber em detalhes o que há de errado:

$ flutter doctor -v

Pode ser necessário instalar ferramentas de desenvolvedor para dispositivos móveis ou computador. Para mais detalhes sobre a configuração das suas ferramentas dependendo do sistema operacional do host, consulte a documentação de instalação do Flutter.

Como criar um projeto do Flutter

Uma maneira fácil de começar a criar apps para computador no Flutter é usar a ferramenta de linha de comando do Flutter para criar um projeto. Como alternativa, o ambiente de desenvolvimento integrado pode fornecer um fluxo de trabalho para criar um projeto do Flutter pela própria IU.

$ 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.

Para garantir que tudo esteja funcionando, execute o aplicativo boilerplate do Flutter como um app para dispositivos móveis, conforme mostrado abaixo. Como alternativa, abra esse projeto no seu ambiente de desenvolvimento integrado e use as ferramentas dele para executar o aplicativo. Graças à etapa anterior, a execução como um aplicativo para computador é a única opção disponível.

$ 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=/

Agora, você vai conferir o app em execução. Modifique o conteúdo em lib/main.dart, como mostrado a seguir, e execute uma recarga automática para atualizar o conteúdo. A maneira como a recarga automática é executada muda dependendo de você estar executando o app pela linha de comando (digitando "r" na janela do console) ou por um editor (nesse caso, salvar o arquivo é provavelmente o suficiente para acionar a recarga automática).

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({super.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({super.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: Padding(
            padding: const EdgeInsets.all(8.0),
            child: Text(property),
          ),
        ),
        TableCell(
          verticalAlignment: TableCellVerticalAlignment.baseline,
          child: Padding(
            padding: const EdgeInsets.all(8.0),
            child: 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';
    }
  }
}

O app acima é criado para que você entenda como é possível detectar e fazer adaptações em diferentes plataformas. Este é o app executado de forma nativa no Android e no iOS:

E este é o mesmo código executado nativamente no macOS e no Chrome, novamente executado no macOS.

O ponto importante a ser observado aqui é que, à primeira vista, o Flutter está fazendo o possível para adaptar o conteúdo à tela em que está sendo executado. O laptop em que foram feitas as capturas tinha uma tela Mac de alta resolução. É por isso que as versões do app para macOS e Web são renderizadas na proporção de pixels do dispositivo de 2. Já no iPhone 12, a proporção é de 3 e, no Pixel 2, de 2.63. Em todos os casos, o texto mostrado é praticamente o mesmo, facilitando muito o trabalho dos desenvolvedores.

O segundo ponto a notar é que as duas opções para verificar em que plataforma o código está sendo executado resultam em diferentes valores. A primeira opção inspeciona o objeto Platform importado de dart:io, enquanto a segunda (disponível apenas no método build do widget) recupera o objeto Theme do argumento BuildContext.

Esses dois métodos retornam resultados diferentes porque a intent de cada um é diferente. O objeto Platform importado de dart:io é usado para tomar decisões independentes das opções de renderização. Um excelente exemplo disso é decidir quais plug-ins usar, quais podem ou não ter implementações nativas correspondentes para uma plataforma física específica.

A extração do Theme do BuildContext é destinada às decisões de implementação que são centradas no tema. Um excelente exemplo disso é decidir usar o controle deslizante do Material Design ou do Cupertino, conforme discutido em Slider.adaptive.

Na próxima seção, você vai criar um app básico para o explorador de playlists do YouTube, otimizado exclusivamente para Android e iOS. Nas seções seguintes, você vai adicionar várias adaptações para que o app funcione melhor no computador e na Web.

4. Criar um app para dispositivos móveis

Adicionar pacotes

Neste app, você vai usar vários pacotes do Flutter para ter acesso à API YouTube Data, gerenciamento de estado e um toque de aplicação de temas.

$ flutter pub add googleapis
Resolving dependencies...
+ _discoveryapis_commons 1.0.3
  async 2.8.2 (2.9.0 available)
  characters 1.2.0 (1.2.1 available)
  clock 1.1.0 (1.1.1 available)
  fake_async 1.3.0 (1.3.1 available)
+ googleapis 9.1.0
+ http 0.13.4
+ http_parser 4.0.1
  matcher 0.12.11 (0.12.12 available)
  material_color_utilities 0.1.4 (0.1.5 available)
  meta 1.7.0 (1.8.0 available)
  path 1.8.1 (1.8.2 available)
  source_span 1.8.2 (1.9.0 available)
  string_scanner 1.1.0 (1.1.1 available)
  term_glyph 1.2.0 (1.2.1 available)
  test_api 0.4.9 (0.4.12 available)
+ typed_data 1.3.1
Changed 5 dependencies!

O primeiro pacote, googleapis, é uma biblioteca Dart gerada para acesso às APIs do Google.

$ flutter pub add http
Resolving dependencies...
  async 2.8.2 (2.9.0 available)
  characters 1.2.0 (1.2.1 available)
  clock 1.1.0 (1.1.1 available)
  fake_async 1.3.0 (1.3.1 available)
  matcher 0.12.11 (0.12.12 available)
  material_color_utilities 0.1.4 (0.1.5 available)
  meta 1.7.0 (1.8.0 available)
  path 1.8.1 (1.8.2 available)
  source_span 1.8.2 (1.9.0 available)
  string_scanner 1.1.0 (1.1.1 available)
  term_glyph 1.2.0 (1.2.1 available)
  test_api 0.4.9 (0.4.12 available)
Got dependencies!

O pacote http será fundamental para criar a capacidade de acessar a API YouTube Data usando chaves de API.

$ flutter pub add provider
Resolving dependencies...
  async 2.8.2 (2.9.0 available)
  characters 1.2.0 (1.2.1 available)
  clock 1.1.0 (1.1.1 available)
  fake_async 1.3.0 (1.3.1 available)
  matcher 0.12.11 (0.12.12 available)
  material_color_utilities 0.1.4 (0.1.5 available)
  meta 1.7.0 (1.8.0 available)
+ nested 1.0.0
  path 1.8.1 (1.8.2 available)
+ provider 6.0.3
  source_span 1.8.2 (1.9.0 available)
  string_scanner 1.1.0 (1.1.1 available)
  term_glyph 1.2.0 (1.2.1 available)
  test_api 0.4.9 (0.4.12 available)
Changed 2 dependencies!

Para gerenciamento de estado, use provider.

$ flutter pub add url_launcher
Resolving dependencies...
  async 2.8.2 (2.9.0 available)
  characters 1.2.0 (1.2.1 available)
  clock 1.1.0 (1.1.1 available)
  fake_async 1.3.0 (1.3.1 available)
+ flutter_web_plugins 0.0.0 from sdk flutter
+ js 0.6.4
  matcher 0.12.11 (0.12.12 available)
  material_color_utilities 0.1.4 (0.1.5 available)
  meta 1.7.0 (1.8.0 available)
  path 1.8.1 (1.8.2 available)
+ plugin_platform_interface 2.1.2
  source_span 1.8.2 (1.9.0 available)
  string_scanner 1.1.0 (1.1.1 available)
  term_glyph 1.2.0 (1.2.1 available)
  test_api 0.4.9 (0.4.12 available)
+ url_launcher 6.1.4
+ url_launcher_android 6.0.17
+ url_launcher_ios 6.0.17
+ url_launcher_linux 3.0.1
+ url_launcher_macos 3.0.1
+ url_launcher_platform_interface 2.1.0
+ url_launcher_web 2.0.12
+ url_launcher_windows 3.0.1
Changed 11 dependencies!

Use o url_launcher como uma maneira de abrir diretamente um vídeo de uma playlist. Como é possível observar a partir das dependências resolvidas, o url_launcher tem implementações para Windows, macOS, Linux e Web, além do Android e iOS padrão. Para esse recurso você não vai precisar criar um código específico de plataforma.

$ flutter pub add flex_color_scheme
Resolving dependencies...
  async 2.8.2 (2.9.0 available)
  characters 1.2.0 (1.2.1 available)
  clock 1.1.0 (1.1.1 available)
  fake_async 1.3.0 (1.3.1 available)
+ flex_color_scheme 5.1.0
  matcher 0.12.11 (0.12.12 available)
  material_color_utilities 0.1.4 (0.1.5 available)
  meta 1.7.0 (1.8.0 available)
  path 1.8.1 (1.8.2 available)
  source_span 1.8.2 (1.9.0 available)
  string_scanner 1.1.0 (1.1.1 available)
  term_glyph 1.2.0 (1.2.1 available)
  test_api 0.4.9 (0.4.12 available)
Changed 1 dependency!m

Esse pacote se destina apenas a dar ao app um bom esquema de cores padrão. Consulte a documentação de flex_color_scheme para entender a gama completa de recursos.

$ flutter pub add go_router
Resolving dependencies...
  async 2.9.0 (2.10.0 available)
  boolean_selector 2.1.0 (2.1.1 available)
  collection 1.16.0 (1.17.0 available)
+ flutter_web_plugins 0.0.0 from sdk flutter
+ go_router 5.2.0
+ js 0.6.4 (0.6.5 available)
+ logging 1.1.0
  matcher 0.12.12 (0.12.13 available)
  material_color_utilities 0.1.5 (0.2.0 available)
  source_span 1.9.0 (1.9.1 available)
  stack_trace 1.10.0 (1.11.0 available)
  stream_channel 2.1.0 (2.1.1 available)
  string_scanner 1.1.1 (1.2.0 available)
  test_api 0.4.12 (0.4.16 available)
  vector_math 2.1.2 (2.1.4 available)
Changed 4 dependencies!

Para implementar navegação entre as diferentes telas, adicione go_router ao projeto.

Esse pacote oferece uma API conveniente, baseada em URL para navegação usando o roteador do Flutter.

Como configurar os apps para dispositivos móveis para url_launcher

O plug-in url_launcher requer configuração dos aplicativos para execução no Android e no iOS. No runner do Flutter no iOS, adicione as seguintes linhas ao dicionário plist.

ios/Runner/Info.plist

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

No runner do Flutter no Android, adicione as seguintes linhas ao Manifest.xml. Adicione este nó queries como um filho direto do nó manifest e semelhante ao nó application.

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>

Para mais detalhes sobre essas mudanças obrigatórias na configuração, consulte a documentação do url_launcher.

Como acessar a API YouTube Data

Para acessar a API YouTube Data para listar playlists, você precisa criar um projeto de API para gerar as chaves de API necessárias. Para essas etapas, supomos que você já tenha uma Conta do Google. Se ainda não tiver, crie uma agora.

Acesse o Console para desenvolvedores para criar um projeto de API:

7fe39926b91104c3.png

Depois de criar um projeto, acesse a página da biblioteca da API. Na caixa de pesquisa, digite "youtube" e selecione a opção "youtube data api v3".

26ac7d6164430ece.png

Na página de detalhes da API YouTube Data v3, ative a API.

5a877ea82b83ae42.png

Depois da ativação, acesse a página de credenciais e crie uma chave de API.

a75ba6e17bef352.png

Depois de alguns segundos, vai aparecer uma caixa de diálogo com sua nova chave de API. Você vai usar essa chave em breve.

d808e4a25d448ecc.png

Adicionar código

Para o resto desta etapa, você vai cortar e colar muito código para criar um app para dispositivos móveis, sem nenhum comentário sobre o código. A finalidade deste codelab é adaptar o app para computador e para a Web. Para uma introdução mais detalhada sobre a criação de apps do Flutter para dispositivos móveis, consulte Criar seu primeiro app do Flutter, parte 1, parte 2 e Como criar interfaces incríveis com o Flutter.

Adicione os seguintes arquivos, primeiro o objeto de estado para o 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: <String, List<String>>{
      ...request.url.queryParametersAll,
      'key': [key]
    });

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

Em seguida, adicione a página de detalhes da playlist específica.

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, super.key});
  final String playlistId;
  final String playlistName;

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

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

class _PlaylistDetailsListView extends StatelessWidget {
  const _PlaylistDetailsListView({required this.playlistItems});
  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: [
                if (playlistItem.snippet!.thumbnails!.high != null)
                  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,
                ),
          ),
          if (playlistItem.snippet!.videoOwnerChannelTitle != null)
            Text(
              playlistItem.snippet!.videoOwnerChannelTitle!,
              style: Theme.of(context).textTheme.bodyText2!.copyWith(
                    fontSize: 12,
                  ),
            ),
        ],
      ),
    );
  }

  Widget _buildPlayButton(BuildContext context, PlaylistItem playlistItem) {
    return Stack(
      alignment: AlignmentDirectional.center,
      children: [
        Container(
          width: 42,
          height: 42,
          decoration: const BoxDecoration(
            color: Colors.white,
            borderRadius: BorderRadius.all(
              Radius.circular(21),
            ),
          ),
        ),
        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),
            iconSize: 45,
          ),
        ),
      ],
    );
  }
}

Depois, adicione a lista de playlists.

lib/src/playlists.dart

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

import 'app_state.dart';

class Playlists extends StatelessWidget {
  const Playlists({super.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({required this.items});

  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: () {
              context.go(
                Uri(
                  path: '/playlist/${playlist.id}',
                  queryParameters: <String, String>{
                    'title': playlist.snippet!.title!
                  },
                ).toString(),
              );
            },
          ),
        );
      },
    );
  }
}

E substitua o conteúdo do arquivo main.dart desta forma:

lib/main.dart

import 'dart:io';

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

import 'src/app_state.dart';
import 'src/playlist_details.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';

final _router = GoRouter(
  routes: <RouteBase>[
    GoRoute(
      path: '/',
      builder: (context, state) {
        return const Playlists();
      },
      routes: <RouteBase>[
        GoRoute(
          path: 'playlist/:id',
          builder: (context, state) {
            final title = state.queryParams['title']!;
            final id = state.params['id']!;
            return PlaylistDetails(
              playlistId: id,
              playlistName: title,
            );
          },
        ),
      ],
    ),
  ],
);

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

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

class PlaylistsApp extends StatelessWidget {
  const PlaylistsApp({super.key});

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

Está quase tudo pronto para você executar este código no Android e iOS. Falta apenas uma mudança: modifique a constante youTubeApiKey na linha 14 com a chave da API do YouTube gerada na etapa anterior.

lib/main.dart

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

Para executar este app no macOS, é necessário ativar o app para fazer solicitações HTTP, como mostrado a seguir. Edite os arquivos DebugProfile.entitlements e Release.entitilements desta maneira:

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>

Executar o app

Agora que você tem um aplicativo completo, é possível executá-lo em um Android Emulator ou um simulador do iPhone. Uma lista de playlists do Flutter será exibida. Ao selecionar uma delas, os vídeos dela serão mostrados e, finalmente, se você clicar no botão "Play", poderá assistir o vídeo no YouTube.

Porém, se você tentar executar esse app no computador, vai perceber que há algo errado com o layout maximizado para o tamanho normal de janela de computador. Você vai buscar maneiras de adaptar esse layout na próxima etapa.

5. Como adaptar para o computador

O problema do computador

Se você executar o app em uma das plataformas nativas do computador, Windows, macOS ou Linux, vai notar um problema interessante. Ele funciona, mas fica estranho.

Para corrigir, é preciso adicionar uma tela dividida, listando as playlists à esquerda e os vídeos à direita No entanto, você só quer que esse tipo de layout apareça quando o código não estiver sendo executado no Android ou iOS e quando a janela tiver a largura suficiente. As instruções a seguir mostram como implementar essa capacidade.

Primeiro, adicione o pacote split_view para ajudar na construção do layout.

$ flutter pub add split_view
Resolving dependencies...
+ split_view 3.1.0
  test_api 0.4.3 (0.4.8 available)
Changed 1 dependency!

Como introduzir widgets adaptáveis

O padrão que você vai usar neste codelab é introduzir widgets adaptáveis que fazem escolhas de implementação com base em atributos como largura de tela, tema da plataforma e similares. Nesse caso, você vai introduzir um widget AdaptivePlaylists que retrabalha a forma como Playlists e PlaylistDetails interagem. Edite o arquivo lib/main.dart da seguinte forma:

lib/main.dart

import 'dart:io';

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

import 'src/adaptive_playlists.dart';
import 'src/app_state.dart';
import 'src/playlist_details.dart';

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

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

final _router = GoRouter(
  routes: <RouteBase>[
    GoRoute(
      path: '/',
      builder: (context, state) {
        return const AdaptivePlaylists();
      },
      routes: <RouteBase>[
        GoRoute(
          path: 'playlist/:id',
          builder: (context, state) {
            final title = state.queryParams['title']!;
            final id = state.params['id']!;
            return Scaffold(
              appBar: AppBar(title: Text(title)),
              body: PlaylistDetails(
                playlistId: id,
                playlistName: title,
              ),
            );
          },
        ),
      ],
    ),
  ],
);

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

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

class PlaylistsApp extends StatelessWidget {
  const PlaylistsApp({super.key});

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

A seguir, crie o arquivo do widget AdaptivePlaylist:

lib/src/adaptive_playlists.dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.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({super.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({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('FlutterDev Playlists')),
      body: Playlists(
        playlistSelected: (playlist) {
          context.go(
            Uri(
              path: '/playlist/${playlist.id}',
              queryParameters: <String, String>{
                'title': playlist.snippet!.title!
              },
            ).toString(),
          );
        },
      ),
    );
  }
}

class WideDisplayPlaylists extends StatefulWidget {
  const WideDisplayPlaylists({super.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'),
            ),
        ],
      ),
    );
  }
}

Este arquivo é interessante por algumas razões. Primeiro, ele usa a largura da janela (usando MediaQuery.of(context).size.width), e você está inspecionando o tema (usando Theme.of(context).platform) para decidir se é necessário mostrar um layout amplo com o widget SplitView ou um display estreito sem ele.

O segundo ponto é que se trata do processamento da navegação codificado anteriormente. Isso é feito por meio de um argumento de callback no widget Playlists. Ele avisa o código que o usuário selecionou uma playlist e precisa fazer o que for necessário para exibi-la. Observe também que o Scaffold foi considerado fora dos widgets Playlists e PlaylistDetails, agora que esses widgets não são de nível superior.

A seguir, edite o arquivo src/lib/playlists.dart da seguinte forma:

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({super.key, required this.playlistSelected});

  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({
    required this.items,
    required this.playlistSelected,
  });

  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);
            },
          ),
        );
      },
    );
  }
}

Há muitas mudanças nesse arquivo. Além da introdução de um callback playlistSelected e da eliminação do widget Scaffold, o widget _PlaylistsListView é convertido de "sem estado" para "com estado". Essa mudança é necessária devido à introdução de um ScrollController próprio que precisa ser criado e destruído.

A introdução de um ScrollController é interessante porque é necessária. Isso porque em um layout amplo há dois widgets ListView lado a lado. Em um smartphone é tradicional ter uma única ListView. Assim, pode haver um único ScrollController de vida longa em que todas as ListViews são anexadas e removidas durante os ciclos de vida individuais delas. O computador é diferente, porque várias ListViews lado a lado fazem sentido.

Finalmente, edite o arquivo lib/src/playlist_details.dart da seguinte maneira:

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, super.key});
  final String playlistId;
  final String playlistName;

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

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

class _PlaylistDetailsListView extends StatefulWidget {
  const _PlaylistDetailsListView({required this.playlistItems});
  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: [
                if (playlistItem.snippet!.thumbnails!.high != null)
                  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,
                ),
          ),
          if (playlistItem.snippet!.videoOwnerChannelTitle != null)
            Text(
              playlistItem.snippet!.videoOwnerChannelTitle!,
              style: Theme.of(context).textTheme.bodyText2!.copyWith(
                    fontSize: 12,
                  ),
            ),
        ],
      ),
    );
  }

  Widget _buildPlayButton(BuildContext context, PlaylistItem playlistItem) {
    return Stack(
      alignment: AlignmentDirectional.center,
      children: [
        Container(
          width: 42,
          height: 42,
          decoration: const BoxDecoration(
            color: Colors.white,
            borderRadius: BorderRadius.all(
              Radius.circular(21),
            ),
          ),
        ),
        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),
            iconSize: 45,
          ),
        ),
      ],
    );
  }
}

Assim como o widget Playlists acima, este arquivo também tem mudanças para a eliminação do widget Scaffold e a introdução de um ScrollController próprio.

Execute o app de novo.

Execute o app da sua escolha no computador, seja ele Windows, macOS ou Linux. Agora ele vai funcionar como esperado.

c356b0976c708cdb.png

6. Como adaptar para a Web

O que está acontecendo com essas imagens?

Se você tentar executar este app como está na Web, mesmo com as mudanças de layout feitas na etapa anterior, vai perceber que ainda é necessário algum trabalho para adaptar o app ao ambiente do navegador da Web:

2413ac49488025b4.png

Se você observar o console de depuração, vai perceber uma dica sobre o que precisa ser feito a seguir.

════════ 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
═════════════════════════════════════════════════════════════════════════

Como criar um proxy do CORS

Uma maneira de lidar com os problemas de renderização de imagens é introduzir um serviço de proxy da Web para adicionar os cabeçalhos do Compartilhamento de recursos entre origens. Crie um terminal e um servidor da Web Dart da seguinte maneira:

$ 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

Mude o diretório para o servidor yt_cors_proxy e adicione algumas das dependências necessárias:

$ 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!

Algumas das dependências atuais não são mais necessárias. Remova-as desta forma:

$ 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!

A seguir, modifique o conteúdo do arquivo server.dart para corresponder ao seguinte:

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, we 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}');
}

É possível executar este servidor assim:

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

Como alternativa, você pode criá-lo como uma imagem Docker e executar a imagem resultante da seguinte maneira:

$ 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

A seguir, modifique o código do Flutter para aproveitar o proxy do CORS, mas só executando dentro de um navegador da Web.

Um par de widgets adaptáveis

O primeiro par de widgets é como seu app vai usar o proxy do CORS.

lib/src/adaptive_image.dart

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

class AdaptiveImage extends StatelessWidget {
  AdaptiveImage.network(String url, {super.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);
  }
}

O interessante é que você está usando kIsWeb porque essa não é uma diferença devido ao tema, mas sim à plataforma de execução. O outro widget adaptável lida com o fato de que os usuários de navegadores da Web esperam que o texto seja selecionável:

lib/src/adaptive_text.dart

import 'package:flutter/material.dart';

class AdaptiveText extends StatelessWidget {
  const AdaptiveText(this.data, {super.key, this.style});
  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);
    }
  }
}

Agora, propague essas adaptações por toda a base de código:

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, super.key});
  final String playlistId;
  final String playlistName;

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

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

class _PlaylistDetailsListView extends StatefulWidget {
  const _PlaylistDetailsListView({required this.playlistItems});
  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: [
                if (playlistItem.snippet!.thumbnails!.high != null)
                  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,
                ),
          ),
          if (playlistItem.snippet!.videoOwnerChannelTitle != null)
          AdaptiveText(    // And, this line
            playlistItem.snippet!.videoOwnerChannelTitle!,
            style: Theme.of(context).textTheme.bodyText2!.copyWith(
                  fontSize: 12,
                ),
          ),
        ],
      ),
    );
  }

  Widget _buildPlayButton(BuildContext context, PlaylistItem playlistItem) {
    return Stack(
      alignment: AlignmentDirectional.center,
      children: [
        Container(
          width: 42,
          height: 42,
          decoration: const BoxDecoration(
            color: Colors.white,
            borderRadius: BorderRadius.all(
              Radius.circular(21),
            ),
          ),
        ),
        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),
            iconSize: 45,
          ),
        ),
      ],
    );
  }
}

No código acima, você adaptou os widgets Image.network e Text. Em seguida, adapte o widget Playlists.

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({super.key, required this.playlistSelected});

  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({
    required this.items,
    required this.playlistSelected,
  });

  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);
            },
          ),
        );
      },
    );
  }
}

Desta vez, você só adaptou o widget Image.network, mas deixou os dois widgets Text como estavam. Isso foi intencional porque, se você adaptar os widgets de texto, a funcionalidade onTap de ListTile é bloqueada quando o usuário toca no texto.

Executar o app na Web de maneira adequada

Com o proxy do CORS em execução, você poderá executar a versão para Web do app e ela vai ficar parecida com o seguinte:

1e4f272524ebedb0.png

7. Autenticação adaptável

Nesta etapa, você vai estender o app, dando a ele a capacidade de autenticar o usuário e, em seguida, mostrar as playlists dele. Será necessário usar vários plug-ins para abranger as diferentes plataformas em que o app será executado. Isso porque o processamento do OAuth ocorre de maneira diferente em Android, iOS, Web, Windows, macOS e Linux.

Como adicionar plug-ins para ativar a autenticação do Google

Você vai instalar três pacotes para processar a autenticação do Google.

$ flutter pub add googleapis_auth
Resolving dependencies...
+ crypto 3.0.1
+ googleapis_auth 1.3.0
  test_api 0.4.3 (0.4.8 available)
Changed 2 dependencies!
$ flutter pub add google_sign_in
Resolving dependencies...
+ google_sign_in 5.2.1
+ google_sign_in_platform_interface 2.1.0
+ google_sign_in_web 0.10.0+3
+ quiver 3.0.1+1
  test_api 0.4.3 (0.4.8 available)
Changed 4 dependencies!
$ flutter pub add extension_google_sign_in_as_googleapis_auth
Resolving dependencies...
+ extension_google_sign_in_as_googleapis_auth 2.0.4
  test_api 0.4.3 (0.4.8 available)
Changed 1 dependency!

Use o plug-in googleapis_auth para autenticar no Windows, macOS e Linux usando o navegador da Web do usuário. No Android, no iOS e na Web, use google_sign_in, com extension_google_sign_in_as_googleapis_auth atuando como um paliativo de interoperabilidade entre os dois pacotes.

Atualizar o código

Comece a atualização criando uma nova abstração reutilizável, o widget AdaptiveLogin. Esse widget foi projetado para ser reutilizado e, assim, é necessária alguma configuração.

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:go_router/go_router.dart';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:googleapis_auth/auth_io.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/link.dart';

import 'app_state.dart';

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

class AdaptiveLogin extends StatelessWidget {
  const AdaptiveLogin({
    super.key,
    required this.clientId,
    required this.scopes,
    required this.loginButtonChild,
  });

  final ClientId clientId;
  final List<String> scopes;
  final Widget loginButtonChild;

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

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

class _GoogleSignInLogin extends StatefulWidget {
  const _GoogleSignInLogin({
    required this.button,
    required this.scopes,
  });

  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) {
          if (authClient != null) {
            context.read<AuthedUserPlaylists>().authClient = authClient;
            context.go('/');
          }
        });
      }
    });
  }

  late final GoogleSignIn _googleSignIn;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: widget.button(onPressed: () {
          _googleSignIn.signIn();
        }),
      ),
    );
  }
}

class _GoogleApisAuthLogin extends StatefulWidget {
  const _GoogleApisAuthLogin({
    required this.button,
    required this.scopes,
    required this.clientId,
  });

  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) {
      context.read<AuthedUserPlaylists>().authClient = authClient;
      context.go('/');
    });
  }

  Uri? _authUrl;

  @override
  Widget build(BuildContext context) {
    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(),
      ),
    );
  }
}

Muita coisa acontece neste arquivo. A parte importante é que, no método build de AdaptiveLogin, a plataforma de execução é verificada usando uma combinação de kIsWeb e de chamadas Platform.isXXX de dart:io, instanciando o widget _GoogleSignInLogin com estado para Android, iOS e Web. Já para Windows, macOS e Linux, um widget _GoogleApisAuthLogin com estado é construído.

É necessária uma configuração adicional para usar essas classes, o que será feito posteriormente, depois de atualizar o restante da base de código para usar esse novo widget. Comece por renomear as FlutterDevPlaylists como AuthedUserPlaylists para refletir melhor a nova finalidade delas e atualizar o código para refletir que o http.Client agora foi transmitido depois da construção. Finalmente, a classe _ApiKeyClient não é mais necessária:

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();
  }

  bool get isLoggedIn => _api != null; // Add property

  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

Em seguida, atualize o widget PlaylistDetails com o novo nome do objeto de estado do aplicativo fornecido:

lib/src/playlist_details.dart

class PlaylistDetails extends StatelessWidget {
  const PlaylistDetails(
      {required this.playlistId, required this.playlistName, super.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);
      },
    );
  }
}

Da mesma forma, atualize o widget Playlists:

lib/src/playlists.dart

class Playlists extends StatelessWidget {
  const Playlists({required this.playlistSelected, super.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,
        );
      },
    );
  }
}

Finalmente, atualize o arquivo main.dart para usar corretamente o novo widget AdaptiveLogin:

lib/main.dart

// Drop dart:io import

import 'package:flex_color_scheme/flex_color_scheme.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.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';
import 'src/playlist_details.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

final _router = GoRouter(
  routes: <RouteBase>[
    GoRoute(
      path: '/',
      builder: (context, state) {
        return const AdaptivePlaylists();
      },
      // Add redirect configuration
      redirect: (context, state) {
        if (!context.read<AuthedUserPlaylists>().isLoggedIn) {
          return '/login';
        } else {
          return null;
        }
      },
      // To this line
      routes: <RouteBase>[
        // Add new login Route
        GoRoute(
          path: 'login',
          builder: (context, state) {
            return AdaptiveLogin(
              clientId: clientId,
              scopes: scopes,
              loginButtonChild: const Text('Login to YouTube'),
            );
          },
        ),
        // To this line
        GoRoute(
          path: 'playlist/:id',
          builder: (context, state) {
            final title = state.queryParams['title']!;
            final id = state.params['id']!;
            return Scaffold(
              appBar: AppBar(title: Text(title)),
              body: PlaylistDetails(
                playlistId: id,
                playlistName: title,
              ),
            );
          },
        ),
      ],
    ),
  ],
);

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

class PlaylistsApp extends StatelessWidget {
  const PlaylistsApp({super.key});

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

Depois das mudanças, esse arquivo que apenas mostrava as playlists do YouTube no Flutter agora mostra as playlists do usuário autenticado. Embora o código esteja completo agora, ainda há várias modificações necessárias nesse arquivo e nos arquivos dos respectivos apps Runner para configurar corretamente os pacotes google_sign_in e googleapis_auth para autenticação.

Como configurar googleapis_auth

A primeira etapa para configurar a autenticação é eliminar a chave de API que você configurou e usou anteriormente. Acesse a página de credenciais do seu projeto de API e exclua a chave de API:

e7bf4977a5dcf985.png

Isso gera um pop-up que você confirma pressionando o botão Delete:

eb8b6787c2f2c951.png

Depois, crie um ID de cliente OAuth:

af07105da9fc35d2.png

Na opção "Application type", selecione "Desktop app".

1958672268c3283e.png

Aceite o nome e clique em Create.

85c36e94f304f71f.png

Isso cria o Client ID e a chave secreta do cliente que você precisa adicionar ao lib/main.dart para configurar o fluxo de googleapis_auth. Um detalhe de implementação importante é que o fluxo de googleapis_auth usa um servidor da Web temporário em execução em um localhost para capturar o token OAuth gerado, que no macOS precisa de uma modificação no arquivo macos/Runner/Release.entitlements:

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.server</key>
                <true/>
                <key>com.apple.security.network.client</key>
                <true/>
        </dict>
</plist>

Você não precisa fazer nenhuma edição no arquivo macos/Runner/DebugProfile.entitlements, já que ele já tem um direito de com.apple.security.network.server para ativar recarga automática e a ferramenta de depuração do Dart VM.

Você poderá executar seu app no Windows, macOS ou Linux, se o app tiver sido compilado nesses destinos.

4f323b032dd9a419.png

Como configurar google_sign_in para Android

Volte para a página de credenciais da API do seu projeto e crie outro ID de cliente OAuth, mas, desta vez, selecione Android:

17687358b5a61a5b.png

No restante do formulário, preencha o nome do pacote com aquele declarado em android/app/src/main/AndroidManifest.xml. Se você seguiu as instruções corretamente, ele será com.example.adaptive_app. Extraia a impressão digital do certificado SHA-1 usando as instruções da página de ajuda do Google Cloud Platform Console:

45b22059ae417ce2.png

Isso é suficiente para que o app funcione no Android. Dependendo da escolha das APIs do Google que você usa, talvez seja necessário adicionar o arquivo JSON gerado no seu pacote do aplicativo.

4b4c03d9655b02c.png

Como configurar google_sign_in para iOS

Volte para a página de credenciais da API do seu projeto e crie outro ID de cliente OAuth, mas, desta vez, selecione iOS:

. 86a84eb772759f1f.png

No restante do formulário, preencha o ID do pacote abrindo ios/Runner.xcworkspace no Xcode. Acesse o navegador do projeto, selecione o Runner no navegador, depois a guia General e copie o identificador do pacote. Se você seguiu cada etapa deste codelab, ele será com.example.adaptiveApp.

7cda08d3408046a1.png

Ignore o ID da App Store e o ID da equipe por enquanto, já que eles não são necessários para desenvolvimento local:

577c52bce54ad7c6.png

Faça o download do arquivo .plist gerado, com o nome baseado no ID do cliente gerado. Renomeie o arquivo como GoogleService-Info.plist e arraste-o para o editor Xcode em execução, junto com o arquivo Info.plist em Runner/Runner no navegador à esquerda. Na caixa de diálogo do Xcode, selecione Copy items, se necessário, Create folder references e Add to the Runner segmentado.

5e6ad6dbf468585f.png

Saia do Xcode e, no ambiente de desenvolvimento integrado de sua escolha, adicione o seguinte a 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>

É necessário editar o valor para corresponder à entrada no arquivo GoogleService-Info.plist gerado. Você também precisa definir a versão mínima do iOS para 9. Edite o ios/Podfile da seguinte maneira:

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

Execute o app e, depois de fazer login, verifique suas playlists.

fe0d11203497c860.png

Como configurar google_sign_in para a Web

Volte para a página de credenciais da API do seu projeto e crie outro ID de cliente OAuth, mas, desta vez, selecione aplicação da Web:

7f745c53956c1572.png

No restante do formulário, preencha as origens autorizadas JavaScript da seguinte maneira:

d45fb0e23e874e34.png

Isso gera um Client-ID. Adicione a seguinte tag meta a web/index.html, atualizada para incluir o Client-ID gerado:

web/index.html

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

A execução deste exemplo requer um pouco de controle. Você precisa executar o proxy do CORS que criou na etapa anterior e executar o app da Web do Flutter na porta especificada no formulário de ID do cliente OAuth do aplicativo Web usando as instruções a seguir.

Em um terminal, execute o servidor proxy do CORS da seguinte maneira:

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

Em outro, execute o app do Flutter desta forma:

$ 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".

Depois de fazer login mais uma vez, as playlists vão aparecer:

c9c43252341fa197.png

8. Próximas etapas

Parabéns!

Você concluiu o codelab e criou um app adaptável do Flutter que pode ser executado nas seis plataformas para as quais o Flutter oferece suporte. Você adaptou o código para lidar com as diferenças na disposição das telas, na interação com o texto, no carregamento das imagens e no funcionamento da autenticação.

É possível fazer muito mais adaptações nos seus aplicativos. Para conhecer outras maneiras de adaptar seu código aos diferentes ambientes onde ele será executado, consulte Como criar apps adaptáveis.