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:
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?
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:
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.
En la página de detalles de la versión 3 de la API de YouTube Data, habilita la API.
Una vez que hayas habilitado la API, navega a la página de Credenciales y crea una clave de API.
Al cabo de algunos segundos, deberías ver un diálogo con tu nueva clave de API. Usarás esta clave en breve.
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.
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:
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:
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:
Esto genera una ventana emergente que aceptarás cuando presiones el botón Borrar:
Luego, crea un ID de cliente de OAuth:
En Tipo de aplicación, selecciona App de escritorio.
Acepta el nombre y haz clic en Crear.
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).
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:
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:
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.
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:
.
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
.
Ignora el ID de App Store y el ID de equipo por el momento, ya que no son necesarios para el desarrollo local:
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.
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.
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:
Para el resto del formulario, completa los Orígenes autorizados de JavaScript como se indica a continuación:
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:
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.