1. Introduction
Flutter est un kit d'interface utilisateur Google qui permet de développer des applications esthétiques compilées de manière native pour les mobiles, le Web et les ordinateurs de bureau, à partir d'un seul codebase. Dans cet atelier de programmation, vous apprendrez à créer une application Flutter qui s'adapte à la plate-forme sur laquelle elle est exécutée, que ce soit sur Android, iOS, le Web, Windows, macOS ou Linux.
Points abordés
- Comment étendre une application Flutter initialement conçue pour les mobiles, afin qu'elle fonctionne sur les six plates-formes avec lesquelles Flutter est compatible.
- Quand utiliser chacune des différentes API Flutter permettant de détecter la plate-forme.
- Comment s'adapter aux restrictions et aux attentes vis-à-vis d'une application exécutée sur le Web.
- Comment utiliser différents packages en parallèle afin de prendre en charge toutes les plateformes avec lesquelles Flutter est compatible.
Objectifs de l'atelier
Dans cet atelier, vous allez d'abord créer une application Flutter compatible avec Android et iOS, dont la fonction sera d'explorer les playlists YouTube de Flutter. Vous adapterez ensuite cette application pour lui permettre de fonctionner sur les trois systèmes d'exploitation d'ordinateur (Windows, macOS et Linux). Pour cela, vous allez modifier la manière dont les informations sont affichées en fonction de la taille de la fenêtre de l'application. Vous adapterez alors l'application au Web en rendant les éléments de texte sélectionnables, conformément aux attentes des internautes. Enfin, vous ajouterez une méthode d'authentification à l'application, afin qu'elle permette d'explorer vos propres playlists plutôt que celles créées par l'équipe Flutter. Vous devrez adopter différentes approches pour Android, iOS et le Web, et pour les plates-formes de bureau Windows, macOS, and Linux.
Capture d'écran de l'application Flutter sur Android et iOS :
Capture d'écran de l'application exécutée sur macOS et mise en page sur grand écran :
Cet atelier est consacré à la transformation d'une application mobile Flutter en une application adaptative fonctionnant sur chacune des six plates-formes avec lesquelles Flutter est compatible. Les concepts et les blocs de code non pertinents ne sont pas abordés, mais vous sont fournis afin que vous puissiez simplement les copier et les coller.
Qu'attendez-vous de cet atelier de programmation ?
2. Configurer votre environnement de développement Flutter
Pour cet atelier, vous avez besoin de deux logiciels : le SDK Flutter et un éditeur.
Vous pouvez exécuter l'atelier de programmation sur l'un des appareils suivants :
- Un appareil Android ou iOS physique connecté à votre ordinateur et réglé en mode développeur
- Le simulateur iOS (les outils Xcode doivent être installés)
- L'émulateur Android (doit être configuré dans Android Studio)
- Un navigateur (Chrome est requis pour le débogage)
- En tant qu'application de bureau Windows, Linux ou macOS. Vous devez développer votre application sur la plate-forme où vous comptez la déployer. Par exemple, si vous voulez développer une application de bureau Windows, vous devez le faire sous Windows pour accéder à la chaîne de compilation appropriée. Les exigences spécifiques aux systèmes d'exploitation sont détaillées sur docs.flutter.dev/desktop.
3. Premiers pas
Confirmer l'environnement de développement
La manière la plus simple de vérifier que tout est prêt pour le développement est d'exécuter la commande suivante :
$ flutter doctor
S'il manque une coche pour l'un des éléments, exécutez la commande suivante pour obtenir davantage d'informations sur le problème :
$ flutter doctor -v
Vous devrez peut-être installer les outil de développement pour mobile ou ordinateur. Consultez la documentation sur l'installation de Flutter pour plus de détails sur la configuration de vos outils en fonction du système d'exploitation hôte.
Créer un projet Flutter
Pour bien commencer, utilisez l'outil de ligne de commande Flutter afin de créer un projet Flutter. Votre IDE peut aussi fournir, via son interface utilisateur, le workflow nécessaire pour créer un projet Flutter.
$ 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.
Pour être sûr que tout fonctionne, exécutez l'application Flutter standard en tant qu'application mobile, comme indiqué ci-dessous. Vous pouvez aussi ouvrir ce projet dans votre IDE et utiliser les outils qui s'y trouvent pour exécuter l'application. Grâce à l'étape précédente, l'exécution en tant qu'application de bureau devrait être la seule option 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=/
L'application devrait à présent s'exécuter. Modifiez le contenu dans lib/main.dart comme indiqué ci-dessous, puis effectuez un hot reload pour le mettre à jour. La procédure de hot reload change selon que l'application est exécutée via ligne de commande (saisissez "r" dans la console) ou via un éditeur (dans ce cas, il suffit probablement d'enregistrer le fichier pour déclencher un hot reload).
lib/main.dart
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({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';
}
}
}
L'application ci-dessus est conçue de façon à mettre en lumière la manière dont les différentes plates-formes peuvent être détectées et prises en charge. Voici à quoi ressemble l'exécution native sur Android et iOS :
Et voici le même code exécuté en natif sur macOS et dans Chrome (également sur macOS).
Ce qui importe, c'est qu'à première vue, Flutter fait son possible pour adapter le contenu à la surface d'affichage de sa plate-forme d'exécution. L'ordinateur portable dont proviennent ces captures d'écran dispose d'un écran Mac haute résolution, raison pour laquelle les versions macOS et Web sont toutes deux restituées avec un rapport de pixels de 2. Sur l'iPhone 12, ce ratio est de 3, contre 2,63 sur le Pixel 2. Dans tous ces exemples, le texte affiché est à peu près similaire, ce qui simplifie considérablement votre travail de développeur.
Second aspect point à relever : les deux options permettant de vérifier la plate-forme sur laquelle le code est exécuté produisent des résultats différents. La première option inspecte l'objet Platform
importé depuis dart:io
, tandis que la seconde (disponible uniquement dans la méthode build
du widget) récupère l'objet Theme
de l'argument BuildContext
.
Si les résultats de ces deux méthodes sont différents, c'est parce que leur fonction est différente. L'objet Platform
importé depuis dart:io
doit permettre de prendre des décisions qui ne dépendent pas de l'approche choisie pour l'affichage. Cela permet, par exemple, de déterminer le plug-in à utiliser, qui peut ou non disposer des implémentations natives correspondantes pour une plate-forme physique donnée.
Extraire le Theme
de BuildContext
sert à prendre des décisions se rapportant principalement au thème. Il s'agit, par exemple, de déterminer s'il faut utiliser le curseur Material ou le curseur Cupertino, comme abordé dans Slider.adaptive
.
Dans la prochaine section, vous allez créer une application basique capable d'explorer des playlists YouTube et uniquement optimisée pour Android et iOS. Dans les sections suivantes, vous ajouterez diverses adaptations pour permettre à l'application de mieux fonctionner sur ordinateur et sur navigateur.
4. Créer une application mobile
Ajouter des packages
Dans cette application, vous utiliserez différents packages Flutter afin d'accéder à l'API YouTube Data, à la gestion des états et à quelques éléments de thématisation.
$ 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!
Le premier package, googleapis
, est une bibliothèque Dart générée pour accéder aux API 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!
Le package http
servira à développer la capacité d'accès à l'API YouTube Data à l'aide de clés API.
$ flutter pub add provider Resolving dependencies... async 2.8.2 (2.9.0 available) characters 1.2.0 (1.2.1 available) clock 1.1.0 (1.1.1 available) fake_async 1.3.0 (1.3.1 available) matcher 0.12.11 (0.12.12 available) material_color_utilities 0.1.4 (0.1.5 available) meta 1.7.0 (1.8.0 available) + nested 1.0.0 path 1.8.1 (1.8.2 available) + provider 6.0.3 source_span 1.8.2 (1.9.0 available) string_scanner 1.1.0 (1.1.1 available) term_glyph 1.2.0 (1.2.1 available) test_api 0.4.9 (0.4.12 available) Changed 2 dependencies!
Utilisez provider
pour la gestion des états.
$ 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!
Utilisez url_launcher
pour accéder aux vidéos de la playlist. Comme le montrent les dépendances résolues, url_launcher
dispose d'implémentations pour Windows, macOS, Linux et le Web, en plus d'Android et iOS, qui sont pris en charge par défaut. Vous n'aurez donc pas besoin de créer du code spécifique à la plate-forme pour obtenir cette capacité.
$ 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
Ce package sert uniquement à fournir à l'application un joli jeu de couleurs par défaut. Reportez-vous à la documentation sur flex_color_scheme
pour un aperçu plus complet des possibilités.
$ 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!
Pour mettre en œuvre la navigation entre les écrans, ajoutez go_router au projet.
Ce package fournit une API pratique basée sur les URL pour naviguer avec le routage Flutter.
Configurer les applications mobiles pour url_launcher
Le plug-in url_launcher
nécessite de configurer les exécuteurs Android et iOS. Dans l'exécuteur Flutter pour iOS, ajoutez les lignes suivantes au dictionnaire plist
.
ios/Runner/Info.plist
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
<string>http</string>
<string>tel</string>
<string>mailto</string>
</array>
Dans l'exécuteur Flutter pour Android, ajoutez les lignes suivantes à Manifest.xml
. Ajoutez ce nœud queries
en tant qu'enfant direct du nœud manifest
et pair du nœud 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>
Pour en savoir plus sur les modifications devant être apportées à la configuration, consultez la documentation url_launcher
.
Accéder à l'API YouTube Data
Pour accéder à l'API YouTube Data et répertorier les playlists, vous devez créer un projet d'API afin de générer les clés API requises. Ces instructions supposent que vous disposiez déjà d'un compte Google. Si ce n'est pas le cas, créez-en un.
Accédez à la console développeur afin de créer un projet d'API :
Une fois le projet créé, accédez à la bibliothèque d'API. Dans le champ de recherche, saisissez "youtube" puis sélectionnez l'API YouTube Data v3.
Sur la page d'informations de l'API YouTube Data v3, activez l'API.
Lorsque vous avez activé l'API, accédez à la page Identifiants et créez une clé API.
Après quelques instants, votre nouvelle clé API devrait apparaître dans une boîte de dialogue. Vous l'utiliserez prochainement.
Ajouter du code
Le reste de cette étape consiste à copier-coller beaucoup de code afin de créer l'application mobile. Nous ne commenterons pas le code. L'objectif de cet atelier est d'utiliser l'application mobile comme point de départ, et de l'adapter afin qu'elle fonctionne sur ordinateur et sur navigateur. Pour une introduction plus détaillée à la création d'applications mobiles Flutter, reportez-vous aux ateliers Écrire votre première application Flutter (partie 1 et partie 2) et Mettre en valeur votre application Flutter.
Ajoutez les fichiers suivants, en commençant par l'objet état pour l'application.
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));
}
}
Ensuite, ajoutez la page d'information des playlist individuelles.
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,
),
),
],
);
}
}
Ajoutez la liste des playlists.
lib/src/playlists.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:provider/provider.dart';
import 'app_state.dart';
class Playlists extends StatelessWidget {
const Playlists({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('FlutterDev Playlists'),
),
body: Consumer<FlutterDevPlaylists>(
builder: (context, flutterDev, child) {
final playlists = flutterDev.playlists;
if (playlists.isEmpty) {
return const Center(
child: CircularProgressIndicator(),
);
}
return _PlaylistsListView(items: playlists);
},
),
);
}
}
class _PlaylistsListView extends StatelessWidget {
const _PlaylistsListView({required this.items});
final List<Playlist> items;
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
var playlist = items[index];
return Padding(
padding: const EdgeInsets.all(8.0),
child: ListTile(
leading: Image.network(
playlist.snippet!.thumbnails!.default_!.url!,
),
title: Text(playlist.snippet!.title!),
subtitle: Text(
playlist.snippet!.description!,
),
onTap: () {
context.go(
Uri(
path: '/playlist/${playlist.id}',
queryParameters: <String, String>{
'title': playlist.snippet!.title!
},
).toString(),
);
},
),
);
},
);
}
}
Remplacez le contenu du fichier main.dart
par ce qui suit :
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,
);
}
}
Le code est presque prêt pour l'exécution sur Android et iOS. Il reste une dernière chose à modifier : à la ligne line 14, remplacez la constante youTubeApiKey
par la clé API YouTube générée à l'étape précédente.
lib/main.dart
// TODO: Replace with your YouTube API Key
const youTubeApiKey = 'AIzaNotAnApiKey';
Pour exécuter cette application sur macOS, vous devez lui permettre d'effectuer des requêtes HTTP. Modifiez les fichiers DebugProfile.entitlements
et Release.entitilements
comme suit :
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>
Exécuter l'application
Maintenant que votre application est complète, vous devriez pouvoir l'exécuter sur un émulateur Android ou un simulateur d'iPhone. Vous verrez la liste des playlists de Flutter. Lorsque vous sélectionnez une playlist, les vidéos qui la composent s'affichent, et lorsque vous appuyez sur le bouton de lecture, l'application lance l'expérience de visionnage YouTube.
Cependant, si vous exécutez l'application sur un ordinateur de bureau, vous constaterez que la mise en page étendue à une taille de fenêtre de bureau normale n'est pas optimale. Nous aborderons les manières d'adapter la mise en page à l'étape suivante.
5. Adapter l'application aux ordinateurs
Le problème avec les ordinateurs de bureau
Si vous exécutez l'application sur Windows, macOS ou Linux, vous constaterez qu'il y a un problème sur les plates-formes de bureau natives. L'application fonctionne, mais l'esthétique est… discutable.
Une solution à ce problème consiste à fractionner l'affichage, avec les playlists à gauche et les vidéos à droite. En revanche, cette mise en page ne doit s'activer que lorsque le code est exécuté sur une plate-forme autre qu'Android ou iOS, et que la fenêtre est suffisamment large. Les instructions suivantes indiquent comment implémenter cette fonctionnalité.
D'abord, ajoutez le package split_view
pour aider à construire la mise en page.
$ flutter pub add split_view Resolving dependencies... + split_view 3.1.0 test_api 0.4.3 (0.4.8 available) Changed 1 dependency!
Intégrer des widgets adaptatifs
La méthode retenue pour cet atelier consiste à intégrer des widgets adaptatifs qui déterminent l'implémentation à adopter en fonction d'attributs tels que la largeur de l'écran ou le thème de la plate-forme, entre autres. Dans le cas présent, vous allez introduire un widget AdaptivePlaylists
qui modifie la manière dont Playlists
et PlaylistDetails
interagissent. Modifiez le fichier lib/main.dart
comme suit :
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,
);
}
}
Ensuite, créez le fichier pour le widget AdaptivePlaylist :
lib/src/adaptive_playlists.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:split_view/split_view.dart';
import 'playlist_details.dart';
import 'playlists.dart';
class AdaptivePlaylists extends StatelessWidget {
const AdaptivePlaylists({super.key});
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final targetPlatform = Theme.of(context).platform;
if (targetPlatform == TargetPlatform.android ||
targetPlatform == TargetPlatform.iOS ||
screenWidth <= 600) {
return const NarrowDisplayPlaylists();
} else {
return const WideDisplayPlaylists();
}
}
}
class NarrowDisplayPlaylists extends StatelessWidget {
const NarrowDisplayPlaylists({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('FlutterDev Playlists')),
body: Playlists(
playlistSelected: (playlist) {
context.go(
Uri(
path: '/playlist/${playlist.id}',
queryParameters: <String, String>{
'title': playlist.snippet!.title!
},
).toString(),
);
},
),
);
}
}
class WideDisplayPlaylists extends StatefulWidget {
const WideDisplayPlaylists({super.key});
@override
State<WideDisplayPlaylists> createState() => _WideDisplayPlaylistsState();
}
class _WideDisplayPlaylistsState extends State<WideDisplayPlaylists> {
Playlist? selectedPlaylist;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: selectedPlaylist == null
? const Text('FlutterDev Playlists')
: Text('FlutterDev Playlist: ${selectedPlaylist!.snippet!.title!}'),
),
body: SplitView(
viewMode: SplitViewMode.Horizontal,
children: [
Playlists(playlistSelected: (playlist) {
setState(() {
selectedPlaylist = playlist;
});
}),
if (selectedPlaylist != null)
PlaylistDetails(
playlistId: selectedPlaylist!.id!,
playlistName: selectedPlaylist!.snippet!.title!)
else
const Center(
child: Text('Select a playlist'),
),
],
),
);
}
}
Ce fichier présente certains aspects intéressants. Premièrement, il inspecte à la fois la largeur de la fenêtre (avec MediaQuery.of(context).size.width
) et le thème (avec Theme.of(context).platform
) pour choisir entre la mise en page large (avec widget SplitView
) ou la mise en page étroite (sans le widget).
Deuxième aspect intéressant : il prend en charge la gestion de la navigation, jusqu'ici codée en dur. Pour cela, le widget Playlists
reçoit un argument de rappel, qui indique que au code environnant que l'utilisateur a sélectionné une playlist et qu'il faut maintenant procéder à toutes les opérations requises pour afficher cette playlist. Notez également que Scaffold
a été exclu des widgets Playlists
et PlaylistDetails
, ces derniers n'étant plus au premier niveau.
Ensuite, modifiez le fichier src/lib/playlists.dart
comme suit :
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);
},
),
);
},
);
}
}
Il y a beaucoup à modifier dans ce fichier. En plus de l'introduction d'un rappel "playlistSelected", mentionnée précédemment, et de l'élimination du widget Scaffold
, le widget _PlaylistsListView
doit être converti de sans état à avec état. Cette modification est nécessaire suite à l'introduction d'un ScrollController
lui appartenant, qui doit être construit puis détruit.
L'introduction d'un ScrollController
est intéressante, car elle est rendue obligatoire par la présence de deux widgets ListView
côte à côte dans la mise en page large. Sur un téléphone, la mise en page classique n'utilise qu'un seul ListView
, ce qui permet de conserver un ScrollController unique et persistant auquel se lient et délient tous les éléments ListView
au cours de leurs cycles de vie individuels. Dans le monde des ordinateurs de bureau, en revanche, avoir plusieurs éléments ListView
côte à côte fait sens.
Terminez en modifiant le fichier lib/src/playlist_details.dart comme suit :
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,
),
),
],
);
}
}
Comme pour le widget Playlists
précédemment, de nombreuses modifications sont nécessaires dans ce fichier, pour éliminer le widget Scaffold
et introduire un ScrollController
approprié.
Exécuter à nouveau l'application
Exécutez l'application sur l'ordinateur de votre choix, sur Windows, macOS ou Linux. L'application devrait fonctionner conformément aux attentes.
6. Adapter l'application au Web
Ça fait un peu fouillis, non ?
Si vous essayez d'exécuter l'application en l'état dans un navigateur, même après les modifications précédemment apportées à la mise en page, vous constaterez qu'il vous reste du travail pour l'adapter à l'environnement Web :
La console de débogage vous donne un indice quant à la suite des opérations.
════════ 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 ═════════════════════════════════════════════════════════════════════════
Créer un proxy CORS
L'une des manières de résoudre les problèmes d'affichage des images consiste à introduire un service Web de proxy pour l'ajouter dans les en-têtes Cross-Origin Resource Sharing (CORS) requis. Ouvrez un terminal et créez un serveur Web Dart comme présenté ci-dessous :
$ 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
Changez de répertoire pour accéder au serveur yt_cors_proxy
et ajoutez les quelques dépendances requises :
$ 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!
Certaines des dépendances actuelles ne sont plus nécessaires. Procédez comme suit pour les éliminer :
$ 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!
Ensuite, modifiez le contenu du fichier server.dart afin de correspondre à ce qui suit :
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}');
}
Vous pouvez exécuter ce serveur de la manière suivante :
$ dart run bin/server.dart Server listening on port 8080
Vous pouvez également le compiler en tant qu'image Docker et exécuter l'image obtenue comme suit :
$ 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
Modifiez le code Flutter pour tirer parti du proxy CORS, mais seulement lorsque l'application s'exécute dans un navigateur Web.
Une paire de widgets adaptatifs
Le premier widget de la paire régit l'utilisation du proxy CORS par votre application.
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);
}
}
Il est intéressant de noter que vous utilisez kIsWeb
car la différence ne vient pas du thème mais de la plate-forme d'exécution. L'autre widget adaptatif permet de répondre aux attentes des internautes et permettant de sélectionner les éléments textuels :
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);
}
}
}
À présent, propagez ces adaptations dans votre codebase :
lib/src/playlist_details.dart
import 'package:flutter/material.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/link.dart';
import 'adaptive_image.dart'; // Add this line,
import 'adaptive_text.dart'; // and this line
import 'app_state.dart';
class PlaylistDetails extends StatelessWidget {
const PlaylistDetails(
{required this.playlistId, required this.playlistName, 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,
),
),
],
);
}
}
Dans le code ci-dessus, les widgets Image.network
et Text
ont été adaptés. Adaptez maintenant le 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);
},
),
);
},
);
}
}
Cette fois-ci, seul le widget Image.network
a été adapté. Les deux widgets Text
sont intentionnellement laissés en l'état, car leur adaptation bloquerait la fonctionnalité onTap
de ListTile
lorsque l'utilisateur appuie sur du texte.
Exécuter l'application sur le Web, correctement
Avec le proxy CORS, vous devriez pouvoir exécuter la version Web de l'application et obtenir un résultat qui ressemble à l'image ci-dessous :
7. Authentification adaptative
Dans cette étape, vous allez élargir les fonctions de l'application pour lui permettre d'authentifier l'utilisateur, puis d'afficher ses playlists. Vous devrez utiliser plusieurs plug-ins pour couvrir les différentes plates-formes sur lesquelles l'application peut être exécutée, car OAuth est géré très différemment entre Android, iOS, le Web, Windows, macOS et Linux.
Ajouter des plug-ins pour l'authentification Google
Vous allez installer trois packages pour prendre en charge l'authentification 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!
Utilisez le plug-in googleapis_auth
pour authentifier l'utilisateur sur Windows, macOS et Linux en passant par le navigateur Web. Sur Android, iOS et le Web, utilisez google_sign_in
avec extension_google_sign_in_as_googleapis_auth
comme shim d'interopérabilité entre les deux packages.
Mettre à jour le code
Commencez votre mise à jour en créant une nouvelle abstraction réutilisable, le widget AdaptiveLogin. Ce widget est conçu pour que vous puissiez le réutiliser, ce nécessite une configuration préalable :
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(),
),
);
}
}
Il se passe beaucoup de choses dans ce fichier. Ce qu'il faut en retenir, c'est que dans la méthode build
d'AdaptiveLogin
, la plate-forme d'exécution est inspectée à l'aide d'une combinaison de kIsWeb
et des appels Platform.isXXX
de dart:io
, qui instancie le widget _GoogleSignInLogin
avec état pour Android, iOS et le Web, tandis qu'un widget _GoogleApisAuthLogin
avec état est construit pour Windows, macOS et Linux.
Un travail de configuration supplémentaire est nécessaire pour utiliser ces classes. Nous y viendrons plus tard, une fois le reste de votre codebase mis à jour pour utiliser ce nouveau widget. Commencez par renommer FlutterDevPlaylists
en AuthedUserPlaylists
, pour mieux correspondre à sa nouvelle fonction. Mettez à jour votre code pour refléter le fait que http.Client
est désormais transmis après la construction. Dernier point, la classe _ApiKeyClient
n'est plus nécessaire :
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
À présent, mettez à jour le nom du widget PlaylistDetails
pour l'objet état fourni pour l'application :
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);
},
);
}
}
Faites de même pour le 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,
);
},
);
}
}
Enfin, mettez à jour le fichier main.dart
afin qu'il utilise bien le nouveau 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,
);
}
}
Les modifications apportées à ce fichier traduisent le changement de fonction. Au lieu des playlists YouTube de Flutter, ce sont les playlists de l'utilisateur authentifié qui sont affichées. Le code est terminé, mais il reste une série de modifications à apporter à ce fichier et aux autres fichiers des différents exécuteurs afin de configurer correctement les packages google_sign_in
et googleapis_auth
pour l'authentification.
Configurer googleapis_auth
Pour configurer l'authentification, vous devez commencer par supprimer la clé API que vous aviez configurée et utilisée jusqu'à présent. Accédez à la page Identifiants de votre projet d'API et supprimez la clé API :
Appuyez sur le bouton "Supprimer" lorsque l'invite de confirmation apparaît :
Ensuite, créez un ID client OAuth :
Choisissez le type d'application "Application de bureau".
Validez le nom et cliquez sur Créer.
Cette opération génère l'identifiant et le code secret du client, que vous devez ajouter à lib/main.dart
pour configurer le flux googleapis_auth
. Détail important, le flux googleapis_auth utilise un serveur Web temporaire exécuté sur localhost afin de capturer le jeton OAuth généré. Vous devez modifier le fichier macos/Runner/Release.entitlements
comme suit pour faire fonctionner cette implémentation sur macOS :
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>
Vous n'avez pas besoin d'apporter la même modification au fichier macos/Runner/DebugProfile.entitlements
, car il dispose déjà d'un droit d'accès à com.apple.security.network.server
afin de prendre en charge le hot reload et les outils de débogage de Dart VM.
Votre application devrait désormais fonctionner sur Windows, macOS et Linux (à condition d'avoir été compilée pour ces plates-formes).
Configurer google_sign_in
pour Android
Revenez à la page Identifiants de votre projet d'API et créez un autre ID client OAuth, cette fois en sélectionnant Android :
Dans la suite du formulaire, renseignez le champ "Nom du package" avec celui du package déclaré dans android/app/src/main/AndroidManifest.xml
. Si vous n'avez pas dévié des consignes, cela devrait être com.example.adaptive_app
. Procédez à l'extraction de l'empreinte du certificat SHA-1 en suivant les instructions de la page d'aide de la console Google Cloud Platform :
Cette opération suffit pour permettre à l'application de fonctionner sur Android. Selon votre sélection d'API Google, vous devrez peut-être ajouter le fichier JSON généré à votre app bundle.
Configurer google_sign_in
pour iOS
Revenez à la page Identifiants de votre projet d'API et créez un autre ID client OAuth, cette fois en sélectionnant iOS :
.
Dans la suite du formulaire, pour renseigner le champ "ID du bundle", ouvrez ios/Runner.xcworkspace
dans Xcode. Accédez au navigateur de projets, sélectionnez le Runner (exécuteur) et ouvrez l'onglet General (Général). Copiez l'identifiant du bundle. Si vous n'avez pas dévié des consignes, cela devrait être com.example.adaptiveApp
.
Pour le moment, ignorez les champs "ID sur l'App Store" et "ID d'équipe", qui sont optionnels pour le développement en local :
Téléchargez le fichier .plist
généré. Son nom est basé sur votre ID client. Remplacez le nom du fichier téléchargé par GoogleService-Info.plist
. Ensuite, faites-le glisser dans votre éditeur Xcode, au même endroit que le fichier Info.plist
, sous Runner/Runner
, dans le navigateur de gauche. Dans la boîte de dialogue des options de Xcode, sélectionnez Copy items if needed (Copier les éléments si nécessaire), Create folder references (Créer les références de dossier) et Runner dans Add to targets (Ajouter aux cibles).
Quittez Xcode. Dans l'IDE de votre choix, ajoutez ce qui suit à votreInfo.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>
Vous devrez modifier la valeur afin qu'elle corresponde à l'entrée du fichier GoogleService-Info.plist
que vous avez généré. Vous devrez également définir la version minimale sur iOS 9. Modifiez ios/Podfile
comme suit :
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
Exécutez votre application. Une fois connecté, vous devriez voir vos playlists.
Configurer google_sign_in
pour le Web
Revenez à la page Identifiants de votre projet d'API et créez un autre ID client OAuth, cette fois en sélectionnant application Web :
Dans la suite du formulaire, renseignez la section "Origines JavaScript autorisées" comme suit :
Cela génère un ID client. Ajoutez la balise meta
suivante à web/index.html
après l'avoir modifiée pour intégrer l'ID client obtenu :
web/index.html
<meta name="google-signin-client_id" content="YOUR_GOOGLE_SIGN_IN_OAUTH_CLIENT_ID.apps.googleusercontent.com">
L'exécution n'est pas tout à fait autonome. Vous devez exécuter le proxy CORS que vous avez créé précédemment et orienter votre application Web Flutter vers le port spécifié dans le formulaire d'ID client OAuth. Suivez ces instructions.
Dans un premier terminal, exécutez le serveur proxy CORS comme suit :
$ dart run bin/server.dart Server listening on port 8080
Dans un autre terminal, exécutez l'application Flutter comme suit :
$ 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".
Une fois reconnecté, vous devriez voir vos playlists.
8. Étapes suivantes
Félicitations !
Vous avez terminé cet atelier de programmation et créé une application Flutter adaptative capable de s'exécuter sur les six plates-formes avec lesquelles Flutter est compatible. Vous avez adapté le code afin de prendre en charge les différentes mises en page à l'écran, les interactions avec le texte, le chargement des images et le fonctionnement de l'authentification.
De nombreux autres éléments peuvent être adaptés dans vos applications. Pour explorer d'autres possibilités d'adapter votre code à différents environnements d'exécution, consultez la page Building adaptive apps (Créer des applications adaptatives).