Apps adaptables en Flutter

1. Introducción

Flutter es el kit de herramientas de IU de Google diseñado para crear aplicaciones atractivas compiladas de forma nativa que funcionen en dispositivos móviles, la Web y computadoras de escritorio a partir de una base de código única. En este codelab, aprenderás a compilar una app de Flutter que se adapte a la plataforma en la que se ejecuta, sea Android, iOS, la Web, Windows, macOS y Linux.

Qué aprenderás

  • Cómo mejorar una app de Flutter diseñada para dispositivos móviles de modo que funcione en las seis plataformas que Flutter admite
  • Cuáles son las distintas APIs de Flutter que detectan la plataforma y cuándo usar cada una
  • Cómo adaptar el trabajo a las restricciones y expectativas relacionadas con ejecutar una app en la Web
  • Cómo usar los diferentes paquetes de forma conjunta para admitir todas las plataformas de Flutter

Qué compilarás

En este codelab, comenzarás compilando una app de Flutter para iOS y Android que explora las listas de reproducción de Flutter en YouTube. Luego, adaptarás la aplicación de modo que funcione en las tres plataformas de escritorio (Windows, macOS y Linux) modificando la forma en que se muestra la información en función del tamaño de la ventana de la aplicación. A continuación, harás una adaptación para la Web, por lo que harás que el texto que se muestra en la app se pueda seleccionar, tal como esperan los usuarios de la Web. Por último, agregarás la autenticación a la app de modo que puedas explorar tus propias listas de reproducción, en lugar de las que crea el equipo de Flutter, lo que requiere enfoques diferentes de autenticación para Android, iOS y la Web, en comparación con las tres plataformas de escritorio (Windows, macOS y Linux).

A continuación, se muestra una captura de pantalla de la app de Flutter en iOS y Android:

A continuación, se muestra una captura de pantalla de la app ejecutándose en macOS, en el diseño de pantalla panorámica:

b424266e6fd4b3c3.png

Este codelab se concentra en transformar una app de Flutter para dispositivos móviles en una app adaptable que funcione en las seis plataformas de Flutter. Los conceptos y los bloques de código que no son relevantes no se explican, pero se proporcionan para que los copies y pegues.

¿Qué te gustaría aprender de este codelab?

Desconozco el tema y me gustaría obtener una buena descripción general. Tengo algunos conocimientos sobre este tema, pero me gustaría repasarlos. Estoy buscando código de ejemplo para usar en mi proyecto. Estoy buscando una explicación sobre un tema específico.

2. Configura tu entorno de desarrollo de Flutter

Para completar este codelab, necesitas dos tipos de software: el SDK de Flutter y un editor.

Puedes ejecutar el codelab con cualquiera de estos dispositivos o modalidades:

  • Un dispositivo físico Android o iOS conectado a tu computadora y configurado en el modo de desarrollador
  • El simulador de iOS (requiere la instalación de herramientas de Xcode)
  • Android Emulator (requiere configuración en Android Studio)
  • Un navegador (para la depuración, se requiere Chrome)
  • Una aplicación para computadoras de escritorio que ejecuten Windows, Linux o macOS (debes desarrollar contenido en la plataforma donde tengas pensado realizar la implementación. Por lo tanto, si quieres desarrollar una app de escritorio para Windows, debes desarrollarla en ese SO para obtener acceso a la cadena de compilación correcta. Encuentra detalles sobre los requisitos específicos del sistema operativo en docs.flutter.dev/desktop)

3. Primeros pasos

Confirma tu entorno de desarrollo

La manera más fácil de asegurarte de que todo esté listo para el desarrollo es ejecutando el siguiente comando:

$ flutter doctor

Si algo se muestra sin un tilde, ejecuta lo siguiente para obtener más detalles sobre lo que no está bien:

$ flutter doctor -v

Es posible que debas instalar herramientas para desarrolladores para poder compilar para dispositivos móviles y computadoras. Si deseas obtener más detalles para configurar tus herramientas en función de tu sistema operativo, consulta la documentación de instalación de Flutter.

Crea un proyecto de Flutter

Una manera fácil de comenzar a escribir código de Flutter para apps de escritorio es usar la herramienta de línea de comandos de Flutter para crear un proyecto en ese framework. De manera alternativa, tu IDE puede brindar un flujo de trabajo para crear un proyecto de Flutter a través de su 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 asegurarte de que todo funcione correctamente, ejecuta la aplicación estándar de Flutter como una app para dispositivos móviles, de la manera que se muestra más adelante. De forma alternativa, abre este proyecto en tu IDE y usa sus herramientas para ejecutar la aplicación. Gracias al paso anterior, ejecutar la aplicación como una de escritorio debería ser la única opción disponible.

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

Ahora deberías ver que la app se ejecuta. Modifica el contenido en lib/main.dart como se indica a continuación y realiza una recarga en caliente para actualizar el contenido. La forma en que se realiza la recarga en caliente depende de si ejecutas la app a través de la línea de comandos (ingresa "r" en la ventana de la consola) o un editor (en cuyo caso guardar el archivo probablemente será suficiente para activar la recarga en caliente).

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';
    }
  }
}

La app de arriba se diseñó para mostrarte cómo detectar diferentes plataformas y adaptarse a ellas. Esta es la app que se ejecuta de forma nativa en iOS y Android:

Este es el mismo código que se ejecuta de forma nativa en macOS y en Chrome, que también se ejecuta en macOS.

Lo más importante para que observes aquí es que, a primera vista, Flutter hace lo que puede para adaptar el contenido a la pantalla en la que se está ejecutando. La laptop en la que se tomaron estas capturas de pantalla tiene una pantalla Mac de alta resolución, lo que explica el motivo por el que las versiones web y macOS de la app se renderizan en una proporción de píxeles del dispositivo igual a 2. En cambio, en el iPhone 12, verás una proporción de 3 y, en el Pixel 2, una de 2.63. En todos los casos, el texto que se muestra es más o menos similar, lo que hace nuestro trabajo cuando desarrollamos sea mucho más fácil.

El segundo aspecto para observar es que las dos opciones que comprueban la plataforma en la que se ejecuta el código muestran diferentes valores. La primera opción inspecciona el objeto Platform importado desde dart:io, mientras que la segunda opción (solo disponible dentro del método build del widget) recupera el objeto Theme del argumento BuildContext.

El motivo por el cual estos dos métodos muestran resultados diferentes es que su intent es diferente. El objeto Platform importado desde dart:io se usa para tomar decisiones que son independientes de las elecciones de renderización. Un buen ejemplo de esto es decidir los complementos a usar, lo que puede o no tener implementaciones nativas coincidentes para una plataforma física específica.

La extracción del Theme de BuildContext está destinada a implementar decisiones que se centran en el tema. Un buen ejemplo de esto es decidir si usar el control deslizante de Material o el de Cupertino, tal como se describe en Slider.adaptive.

En la próxima sección, compilarás una app básica de exploración de listas de reproducción de YouTube optimizada únicamente para Android y iOS. En las siguientes secciones, agregarás varias adaptaciones para hacer que la app funcione mejor en computadoras de escritorio y la Web.

4. Compila una app para dispositivos móviles

Agrega paquetes

En esta app usarás una variedad de paquetes de Flutter para obtener acceso a la API de YouTube Data, la gestión del estado y la aplicación de un poco de estilo.

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

El primer paquete, googleapis, es una biblioteca generada de Dart para acceder a las APIs de 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!

El paquete http es imprescindible a la hora de lograr la capacidad de acceder a la API de YouTube Data usando las claves de APIs.

$ 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 la gestión del estado, usa 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!

Usa url_launcher como una manera de saltar a un video desde una lista de reproducción. Como puedes ver a partir de las dependencias resueltas, url_launcher tiene implementaciones para Windows, macOS, Linux y la Web, además de las predeterminadas para iOS y Android. Este es un atributo para el cual no necesitarás crear código específico para cada 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

Este paquete solo le da a la app un buen esquema predeterminado de colores. Consulta la documentación de flex_color_scheme para comprender los diferentes atributos.

$ 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 la navegación entre pantallas distintas, agrega go_router al proyecto.

Este paquete brinda una API conveniente y basada en URLs para navegar con el router de Flutter.

Cómo configurar las apps para dispositivos móviles para url_launcher

El complemento url_launcher requiere la configuración de las aplicaciones de Runner de Android y iOS. En el Runner de Flutter de iOS, agrega las siguientes líneas al diccionario de plist.

ios/Runner/Info.plist

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

En el Runner de Flutter de Android, agrega las siguientes líneas al archivo Manifest.xml. Agrega este nodo de queries como un elemento secundario directo del nodo manifest y uno similar del nodo 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 obtener más información sobre estos cambios de configuración requeridos, consulta la documentación de url_launcher.

Cómo acceder a la API de YouTube Data

Para acceder a la API de YouTube Data para mostrar las listas de reproducción, necesitas crear un proyecto de API con el objeto de generar las claves de APIs requeridas. En estos pasos, se asume que ya tienes una Cuenta de Google. Si no tienes una, créala.

Navega a Play Console para crear un proyecto de API:

7fe39926b91104c3.png

Una vez que tengas un proyecto, navega a la página de la biblioteca de APIs. En el cuadro de búsqueda, ingresa "YouTube" y selecciona la versión 3 de la API de YouTube Data.

26ac7d6164430ece.png

En la página de detalles de la versión 3 de la API de YouTube Data, habilita la API.

5a877ea82b83ae42.png

Una vez que hayas habilitado la API, navega a la página de Credenciales y crea una clave de API.

a75ba6e17bef352.png

Al cabo de algunos segundos, deberías ver un diálogo con tu nueva clave de API. Usarás esta clave en breve.

d808e4a25d448ecc.png

Agrega código

Para lo que resta de este paso, cortarás y pegarás bastante código para compilar una app para dispositivos móviles, sin comentarios en el código. El objetivo de este codelab es tomar esa app y adaptarla tanto para computadoras de escritorio como para la Web. Si deseas una introducción más detallada sobre la compilación de apps de Flutter para dispositivos móviles, consulta Cómo escribir tu primera app de Flutter, parte 1, parte 2 y Cómo compilar IU atractivas con Flutter.

Agrega los siguientes archivos, en primer lugar, el objeto de estado para la 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));
  }
}

A continuación, agrega la página de detalles de la lista de reproducción individual.

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

Luego, agrega el listado de listas de reproducción.

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

Además, reemplaza el contenido del archivo main.dart como se indica a continuación:

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

Ya está casi todo listo para ejecutar este código en Android y iOS. Solo resta cambiar una cosa más: modifica la constante youTubeApiKey en la línea 14 con la clave de API de YouTube generada en el paso anterior.

lib/main.dart

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

Para ejecutar esta app en macOS, necesitas habilitar la app de modo que realice solicitudes HTTP como se indica a continuación. Edita los archivos DebugProfile.entitlements y Release.entitilements de la siguiente manera:

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>

Ejecuta la app

Ahora que tienes una aplicación completa, deberías poder ejecutarla con éxito en un emulador de Android o en un simulador de iPhone. Verás un listado de listas de reproducción de Flutter. Cuando selecciones una de ellas, verás los videos de esa lista de reproducción. Por último, si haces clic en el botón Reproducir, se iniciará la experiencia de YouTube para ver el video.

Sin embargo, si intentas ejecutar esta app en una computadora de escritorio, notarás que el diseño se ve mal cuando se la expande a una ventana normal de tamaño de escritorio. Descubrirás maneras de adaptar esto en el próximo paso.

5. Adapta la app a computadoras de escritorio

El problema de las computadoras de escritorio

Si ejecutas la app en una de las plataformas nativas para computadoras de escritorio (Windows, macOS o Linux), notarás un problema interesante. Funciona, pero se ve extraña.

Una solución para esto es agregar una vista dividida, mostrando las listas de reproducción en la parte izquierda y los videos en la derecha. Sin embargo, solo querrás que aparezca este diseño cuando el código no se ejecuta en Android ni iOS, y cuando la ventana sea lo suficientemente ancha. Las siguientes instrucciones muestran cómo implementar esta función.

Primero, agrega el paquete split_view para ayudar a construir el diseño.

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

Introducción a los widgets adaptables

El patrón que usarás en este codelab consiste en la introducción de widgets adaptables que seleccionen las implementaciones con base en atributos como el ancho de la pantalla y el tema de la plataforma, por ejemplo. En este caso, introducirás un widget AdaptivePlaylists con el que se vuelva a plantear la forma en que interactúan Playlists y PlaylistDetails. Edita el archivo lib/main.dart de la siguiente manera:

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 continuación, crea el archivo para el widget de 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 archivo es interesante por diferentes motivos. Primero, usa el ancho de la ventana (con MediaQuery.of(context).size.width) y, por tu parte, inspeccionas el tema (con Theme.of(context).platform) para decidir si mostrar un diseño ancho con el widget SplitView o una pantalla estrecha sin él.

Lo segundo para destacar es que se ocupa del control de la navegación codificado con anterioridad. Esto se logra mostrando un argumento de devolución de llamada en el widget Playlists que notifica al código que lo rodea que el usuario seleccionó una lista de reproducción y que necesita hacer lo que sea necesario para mostrar esa lista. Además, observa que Scaffold se factorizó de los widgets Playlists y PlaylistDetails ahora que estos no están en el nivel superior.

A continuación, edita el archivo src/lib/playlists.dart como se indica a continuación:

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

Hay muchos cambios en este archivo. Además de la presentación mencionada de una devolución de llamada de playlistSelected y de la eliminación del widget Scaffold, el widget _PlaylistsListView pasa de ser uno sin estado a uno con estado. Se requiere este cambio debido a la presentación de un ScrollController de tu propiedad que debe construirse y destruirse.

La presentación de un ScrollController es interesante porque se requiere debido al hecho de que en un diseño ancho tienes dos widgets ListView, uno junto al otro. En un teléfono celular, suele haber un único elemento ListView y, por lo tanto, puede haber un único ScrollController de larga duración al que todos los elementos ListView se adjuntan, y del que estos se separan, durante sus ciclos de vida individuales. El funcionamiento en computadoras de escritorio es diferente: allí, varios elementos ListView, uno junto al otro, resultan útiles.

Por último, edita el archivo lib/src/playlist_details.dart como se indica a continuación:

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

Similar al widget Playlists de arriba, este archivo también incluye cambios para la eliminación del widget Scaffold y la presentación de un ScrollController con propietario.

Vuelve a ejecutar la app

Ejecuta la app en el entorno de escritorio que prefieras, sea Windows, macOS o Linux. Ahora debería funcionar como esperabas.

c356b0976c708cdb.png

6. Adapta la app a la Web

¿Qué ocurre con esas imágenes?

Si intentas ejecutar esta app en la Web tal como está, incluso con los cambios de diseño del paso anterior, verás que todavía falta hacer algunos ajustes para adaptar la app a un entorno de navegador web:

2413ac49488025b4.png

Si consultas la consola de depuración, verás una sugerencia sobre lo que debes hacer a continuación.

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

Crea un proxy de CORS

Una forma de abordar los problemas de renderización de imágenes es introducir un servicio web de proxy para agregar los encabezados requeridos de uso compartido de recursos entre dominios. Abre una terminal y crea un servidor web de Dart de la siguiente manera:

$ 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

Cambia el directorio del servidor yt_cors_proxy y agrega algunas de las dependencias requeridas:

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

Algunas de las dependencias actuales ya no se requieren. Recórtalas como se indica a continuación:

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

Luego, modifica el contenido del archivo server.dart para que coincida con lo siguiente:

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

Puedes ejecutar el servidor de esta manera:

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

También puedes compilarlo como una imagen de Docker y ejecutar esa imagen resultante como se indica a continuación:

$ 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

Luego, modifica el código de Flutter para aprovechar este proxy de CORS, pero solo cuando se ejecute dentro de un navegador web.

Un par de widgets adaptables

El primer widget del par consiste en la forma en que tu app usará el proxy de 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);
  }
}

Lo interesante es que estás usando kIsWeb dado que no se trata de una diferencia debida al tema, sino a la plataforma del entorno de ejecución. El otro widget adaptable se ocupa del hecho de que los usuarios del navegador web esperan que el texto se pueda seleccionar:

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

Ahora, expande estas adaptaciones a toda la 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,
          ),
        ),
      ],
    );
  }
}

En el código anterior, adaptaste los widgets Image.network y Text. Ahora, adapta el 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);
            },
          ),
        );
      },
    );
  }
}

Esta vez, solo adaptaste el widget Image.network, pero dejaste los widgets Text tal como estaban. Esto fue intencional, ya que, si adaptas los widgets de texto, se bloquea la funcionalidad onTap de ListTile cuando el usuario presiona el texto.

Ejecuta la app en la Web de forma correcta

Mientras se ejecuta el proxy de CORS, deberías poder ejecutar la versión web de la app y que esta tenga el siguiente aspecto:

1e4f272524ebedb0.png

7. Autenticación adaptable

En este paso, extenderás la app brindándole la capacidad de autenticar el usuario y, luego, mostrarás las listas de reproducción de ese usuario. Tendrás que usar varios complementos para cubrir las distintas plataformas en las que la app puede ejecutarse, ya que el control de OAuth se realiza de forma diferente en Android, iOS, la Web, Windows, macOS y Linux.

Agrega los complementos para permitir la autenticación de Google

Instalarás tres paquetes para controlar la autenticación de 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!

Usa el complemento googleapis_auth para realizar la autenticación en Windows, macOS y Linux usando el navegador web del usuario. En Android, iOS y la Web, usa google_sign_in, con extension_google_sign_in_as_googleapis_auth actuando como una corrección de compatibilidad de interoperabilidad entre los dos paquetes.

Actualiza el código

Comienza la actualización creando una nueva abstracción reutilizable, el widget AdaptiveLogin. Este widget está diseñado para que lo reutilices y, por lo tanto, requiere cierta configuración:

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

Hay mucho que explorar en este archivo. La parte importante es que, en el método build de AdaptiveLogin, se verifica la plataforma del entorno de ejecución usando una combinación de kIsWeb y llamadas a Platform.isXXX de dart:io, creando una instancia del widget _GoogleSignInLogin con estado para Android, iOS y la Web; mientras que, para Windows, macOS y Linux, se construye un widget _GoogleApisAuthLogin con estado.

Se requiere configuración adicional para usar estas clases, lo que veremos más adelante, luego de actualizar el resto de la base de código para usar este widget nuevo. Primero, cambia el nombre de FlutterDevPlaylists por AuthedUserPlaylists para reflejar mejor su nuevo propósito y actualiza el código para reflejar que http.Client ahora se pasa después de la construcción. Por último, ya no se requiere la clase _ApiKeyClient:

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

A continuación, actualiza el widget PlaylistDetails con el nuevo nombre del objeto de estado de la aplicación que se proporcionó:

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

De manera similar, actualiza el 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,
        );
      },
    );
  }
}

Por último, actualiza el archivo main.dart de modo que use correctamente el nuevo 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,
    );
  }
}

Los cambios en este archivo reflejan la evolución de mostrar únicamente las listas de reproducción de YouTube de Flutter a mostrar las del usuario autenticado. Si bien el código ya está completo, todavía hay una serie de modificaciones requeridas para este archivo, y para los archivos de las apps de Runner correspondientes, relativos a configurar correctamente los paquetes google_sign_in y googleapis_auth para la autenticación.

Configura googleapis_auth

El primer paso para configurar la autenticación consiste en eliminar la clave de API que configuraste y usaste antes. Navega a la página de Credenciales del proyecto de API y borra la clave de API:

e7bf4977a5dcf985.png

Esto genera una ventana emergente que aceptarás cuando presiones el botón Borrar:

eb8b6787c2f2c951.png

Luego, crea un ID de cliente de OAuth:

af07105da9fc35d2.png

En Tipo de aplicación, selecciona App de escritorio.

1958672268c3283e.png

Acepta el nombre y haz clic en Crear.

85c36e94f304f71f.png

Esto crea el ID de cliente y el Secreto de cliente que debes agregar a lib/main.dart para configurar el flujo de googleapis_auth. Un detalle importante de la implementación es que el flujo de googleapis_auth usa un servidor web temporal que se ejecuta en localhost para capturar el token de OAuth generado, lo que en macOS requiere modificar el archivo 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>

No necesitas editar esto en el archivo macos/Runner/DebugProfile.entitlements, dado que ya tiene una autorización de com.apple.security.network.server para habilitar la recarga en caliente y las herramientas de depuración Dart VM.

Ahora deberías poder ejecutar tu app en Windows, macOS o Linux (si la app se compiló segmentada para esos objetivos).

4f323b032dd9a419.png

Configura google_sign_in para Android

Vuelve a la página de Credenciales de tu proyecto de API y crea otro ID de cliente de OAuth, pero esta vez selecciona Android:

17687358b5a61a5b.png

Para el resto del formulario, completa el Nombre del paquete con el paquete declarado en android/app/src/main/AndroidManifest.xml. Si seguiste los pasos al pie de la letra, debería ser com.example.adaptive_app. Extrae la huella digital del certificado SHA-1 usando las instrucciones de la página de ayuda de la Consola de Google Cloud Platform:

45b22059ae417ce2.png

Esto es suficiente para que la app funcione en Android. Dependiendo de las APIs de Google que usas, quizás necesites agregar el archivo JSON generado al paquete de tu aplicación.

4b4c03d9655b02c.png

Configura google_sign_in para iOS

Vuelve a la página de Credenciales de tu proyecto de API y crea otro ID de cliente de OAuth, pero esta vez selecciona iOS:

. 86a84eb772759f1f.png

Para el resto del formulario, completa el ID del paquete abriendo ios/Runner.xcworkspace en Xcode. Navega a Project Navigator, selecciona Runner en el navegador y, luego, selecciona la pestaña General y copia el Identificador de paquete. Si seguiste este codelab paso a paso, debería ser com.example.adaptiveApp.

7cda08d3408046a1.png

Ignora el ID de App Store y el ID de equipo por el momento, ya que no son necesarios para el desarrollo local:

577c52bce54ad7c6.png

Descarga el archivo .plist generado. Su nombre se basa en tu ID de cliente generado. Cambia el nombre del archivo descargado a GoogleService-Info.plist y, luego, arrástralo a tu editor Xcode en ejecución junto con el archivo Info.plist en Runner/Runner, en el navegador de la izquierda. Para el diálogo de opciones de Xcode, selecciona Copy items if needed, Create folder references y Runner en Add to targets.

5e6ad6dbf468585f.png

Sal de Xcode. Luego, en el IDE que prefieras, agrega lo siguiente en 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>

Debes editar el valor de modo que coincida con la entrada en tu archivo GoogleService-Info.plist generado. También debes establecer la versión mínima de iOS en 9. Edita ios/Podfile como se indica a continuación:

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

Ejecuta tu app. Luego de acceder, deberías ver tus listas de reproducción.

fe0d11203497c860.png

Configura google_sign_in para la Web

Vuelve a la página de Credenciales de tu proyecto de API y crea otro ID de cliente de OAuth, pero esta vez selecciona la opción de aplicación web:

7f745c53956c1572.png

Para el resto del formulario, completa los Orígenes autorizados de JavaScript como se indica a continuación:

d45fb0e23e874e34.png

Esto genera un ID de cliente. Agrega la etiqueta meta siguiente a web/index.html, actualizada para que incluya el ID de cliente generado:

web/index.html

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

Ejecutar este ejemplo lleva un poco más de tiempo. Debes ejecutar el proxy de CORS que creaste en el paso anterior y la app web de Flutter en el puerto especificado en el formulario de ID de cliente de OAuth de la aplicación web con las siguientes instrucciones.

En una terminal, ejecuta el servidor proxy de CORS como se indica a continuación:

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

En otra terminal, ejecuta la app de Flutter de la siguiente manera:

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

Luego de acceder una vez más, deberías ver tus listas de reproducción:

c9c43252341fa197.png

8. Próximos pasos

¡Felicitaciones!

Completaste este codelab y compilaste una app de Flutter adaptable que se ejecuta en las seis plataformas que Flutter admite. Adaptaste el código de modo que controle las diferencias según el diseño de las pantallas, la interacción con el texto, la carga de imágenes y el funcionamiento de la autenticación.

Hay muchas más cosas que puedes adaptar en tus aplicaciones. Para aprender maneras adicionales de adaptar tu código a diferentes entornos en los que se ejecutará, consulta Cómo compilar apps adaptables.