Informazioni su questo codelab
1. Introduzione
Flutter è il toolkit UI di Google per la creazione di bellissime applicazioni compilate in modo nativo per mobile, web e desktop da un unico codebase. In questo codelab imparerai a creare un'app Flutter che si adatti alla piattaforma su cui è in esecuzione, che si tratti di Android, iOS, web, Windows, macOS o Linux.
Obiettivi didattici
- Come far crescere un'app Flutter progettata per il mobile in modo che funzioni su tutte e sei le piattaforme supportate da Flutter.
- Le diverse API Flutter per il rilevamento della piattaforma e quando utilizzare ciascuna API.
- Adattamento alle limitazioni e alle aspettative relative all'esecuzione di un'app sul web.
- Come utilizzare pacchetti diversi uno accanto all'altro per supportare l'intera gamma di piattaforme Flutter.
Cosa creerai
In questo codelab, inizialmente creerai un'app Flutter per Android e iOS che esplora le playlist di YouTube di Flutter. Poi adatti questa applicazione in modo che funzioni sulle tre piattaforme desktop (Windows, macOS e Linux) modificando il modo in cui le informazioni vengono visualizzate in base alle dimensioni della finestra dell'applicazione. Poi, dovrai adattare l'applicazione per il web rendendo selezionabile il testo visualizzato nell'app, come previsto dagli utenti web. Infine, aggiungerai l'autenticazione all'app per poter esplorare le tue playlist, a differenza di quelle create dal team di Flutter, che richiede approcci diversi all'autenticazione per Android, iOS e il web rispetto alle tre piattaforme desktop Windows, macOS e Linux.
Ecco uno screenshot dell'app Flutter su Android e iOS:
Questa app in esecuzione in formato widescreen su macOS dovrebbe avere l'aspetto dello screenshot seguente.
Questo codelab si concentra sulla trasformazione di un'app mobile Flutter in un'app adattabile che funzioni su tutte e sei le piattaforme Flutter. Concetti e blocchi di codice non pertinenti sono trattati solo superficialmente e sono forniti solo per operazioni di copia e incolla.
Cosa vuoi imparare da questo codelab?
2. Configurare l'ambiente di sviluppo Flutter
Per completare questo laboratorio, hai bisogno di due software: l'SDK Flutter e un editor.
Puoi eseguire il codelab utilizzando uno di questi dispositivi:
- Un dispositivo Android o iOS fisico collegato al computer e impostato sulla modalità Sviluppatore.
- Il simulatore iOS (è richiesta l'installazione degli strumenti Xcode).
- L'emulatore Android (richiede la configurazione in Android Studio).
- Un browser (è necessario Chrome per il debug).
- Come applicazione desktop per Windows, Linux o macOS. Devi sviluppare sulla piattaforma in cui prevedi di eseguire il deployment. Pertanto, se vuoi sviluppare un'app desktop per Windows, devi eseguire lo sviluppo su Windows per accedere alla catena di build appropriata. Esistono requisiti specifici per il sistema operativo che sono descritti in dettaglio all'indirizzo docs.flutter.dev/desktop.
3. Inizia
Conferma dell'ambiente di sviluppo
Il modo più semplice per assicurarti che tutto sia pronto per lo sviluppo è eseguire il seguente comando:
flutter doctor
Se viene visualizzato un elemento senza un segno di spunta, esegui quanto segue per avere ulteriori dettagli sul problema:
flutter doctor -v
Potresti dover installare strumenti per sviluppatori per lo sviluppo mobile o desktop. Per ulteriori dettagli sulla configurazione degli strumenti in base al sistema operativo host, consulta la documentazione relativa all'installazione di Flutter.
Creazione di un progetto Flutter
Un modo per iniziare a scrivere Flutter per le app desktop è utilizzare lo strumento a riga di comando Flutter per creare un progetto Flutter. In alternativa, l'IDE potrebbe fornire un flusso di lavoro per la creazione di un progetto Flutter tramite la sua UI.
$ flutter create adaptive_app Creating project adaptive_app... Resolving dependencies in adaptive_app... (1.8s) Got dependencies in adaptive_app. Wrote 129 files. All done! You can find general documentation for Flutter at: https://docs.flutter.dev/ Detailed API documentation is available at: https://api.flutter.dev/ If you prefer video documentation, consider: https://www.youtube.com/c/flutterdev In order to run your application, type: $ cd adaptive_app $ flutter run Your application code is in adaptive_app/lib/main.dart.
Per assicurarti che tutto funzioni, esegui l'applicazione boilerplate Flutter come app mobile come mostrato di seguito. In alternativa, apri questo progetto nell'IDE e utilizza i relativi strumenti per eseguire l'applicazione. Grazie al passaggio precedente, l'esecuzione come applicazione desktop dovrebbe essere l'unica opzione disponibile.
$ flutter run Launching lib/main.dart on iPhone 15 in debug mode... Running Xcode build... └─Compiling, linking and signing... 6.5s Xcode build done. 24.6s Syncing files to device iPhone 15... 46ms 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). A Dart VM Service on iPhone 15 is available at: http://127.0.0.1:50501/JHGBwC_hFJo=/ The Flutter DevTools debugger and profiler on iPhone 15 is available at: http://127.0.0.1:9102?uri=http://127.0.0.1:50501/JHGBwC_hFJo=/
Ora dovresti vedere l'app in esecuzione. I contenuti devono essere aggiornati.
Per aggiornare i contenuti, aggiorna il codice in lib/main.dart
con il codice seguente. Per modificare ciò che viene visualizzato nella tua app, esegui un ricaricamento dinamico.
- Se esegui l'app utilizzando la riga di comando, digita
r
nella console per eseguire il ricaricamento dinamico. - Se esegui l'app utilizzando un IDE, l'app viene ricaricata quando salvi il file.
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(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
),
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.headlineSmall,
),
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'app è progettata per farti capire in che modo è possibile rilevare e adattarsi a piattaforme diverse. Ecco l'app in esecuzione in modo nativo su Android e iOS:
Ed ecco lo stesso codice in esecuzione in modo nativo su macOS e all'interno di Chrome, sempre su macOS.
Il punto importante da notare è che, a prima vista, Flutter fa il possibile per adattare i contenuti al display su cui è in esecuzione. Il laptop su cui sono stati acquisiti questi screenshot ha un display Mac ad alta risoluzione, motivo per cui sia le versioni per macOS che quelle web dell'app vengono visualizzate con un rapporto dispositivo pixel di 2. Su iPhone 12, invece, il rapporto è 3, mentre su Pixel 2 è 2,63. In tutti i casi, il testo visualizzato è approssimativamente simile, il che semplifica molto il nostro lavoro di sviluppatori.
Il secondo punto da notare è che le due opzioni per verificare su quale piattaforma è in esecuzione il codice generano valori diversi. La prima opzione ispeziona l'oggetto Platform
importato da dart:io
, mentre la seconda (disponibile solo all'interno del metodo build
del widget) recupera l'oggetto Theme
dall'argomento BuildContext
.
Questi due metodi restituiscono risultati diversi perché hanno intenti diversi. L'oggetto Platform
importato da dart:io
è destinato a essere utilizzato per prendere decisioni indipendenti dalle scelte di rendering. Un primo esempio è la scelta dei plug-in da utilizzare, che potrebbero o meno corrispondere alle implementazioni native per una piattaforma fisica specifica.
L'estrazione di Theme
da BuildContext
è pensata per le decisioni di implementazione incentrate sul tema. Un primo esempio è decidere se utilizzare il cursore Material o il cursore Cupertino, come discusso in Slider.adaptive
.
Nella sezione successiva, creerai un'app di esplorazione delle playlist di YouTube di base ottimizzata esclusivamente per Android e iOS. Nelle sezioni seguenti aggiungerai vari adattamenti per migliorare il funzionamento dell'app su computer e sul web.
4. Crea un'app mobile
Aggiungere pacchetti
In questa app utilizzerai una serie di pacchetti Flutter per accedere all'API YouTube Data, alla gestione dello stato e a un tocco di temi.
$ flutter pub add googleapis http provider url_launcher flex_color_scheme go_router Resolving dependencies... Downloading packages... + _discoveryapis_commons 1.0.7 + flex_color_scheme 8.2.0 + flex_seed_scheme 3.5.1 + flutter_web_plugins 0.0.0 from sdk flutter + go_router 15.1.2 + googleapis 14.0.0 + http 1.4.0 + http_parser 4.1.2 leak_tracker 10.0.9 (11.0.1 available) leak_tracker_flutter_testing 3.0.9 (3.0.10 available) leak_tracker_testing 3.0.1 (3.0.2 available) lints 5.1.1 (6.0.0 available) + logging 1.3.0 material_color_utilities 0.11.1 (0.12.0 available) meta 1.16.0 (1.17.0 available) + nested 1.0.0 + plugin_platform_interface 2.1.8 + provider 6.1.5 test_api 0.7.4 (0.7.6 available) + typed_data 1.4.0 + url_launcher 6.3.1 + url_launcher_android 6.3.16 + url_launcher_ios 6.3.3 + url_launcher_linux 3.2.1 + url_launcher_macos 3.2.2 + url_launcher_platform_interface 2.3.2 + url_launcher_web 2.4.1 + url_launcher_windows 3.1.4 vector_math 2.1.4 (2.1.5 available) + web 1.1.1 Changed 22 dependencies! 8 packages have newer versions incompatible with dependency constraints. Try `flutter pub outdated` for more information.
Questo comando aggiunge all'applicazione una serie di pacchetti:
googleapis
: una libreria Dart generata che fornisce l'accesso alle API di Google.http
: una libreria per la creazione di richieste HTTP che nasconde le differenze tra browser web e nativi.provider
: fornisce la gestione dello stato.url_launcher
: consente di passare a un video da una playlist. Come mostrato dalle dipendenze risolte,url_launcher
ha implementazioni per Windows, macOS, Linux e il web, oltre ad Android e iOS predefiniti. Se utilizzi questo pacchetto, non dovrai creare una piattaforma specifica per questa funzionalità.flex_color_scheme
: assegna all'app una bella combinazione di colori predefinita. Per saperne di più, consulta la documentazione dell'APIflex_color_scheme
.go_router
: implementa la navigazione tra le diverse schermate. Questo pacchetto fornisce un'API comoda e basata su URL per la navigazione utilizzando il router di Flutter.
Configurazione delle app mobile per url_launcher
Il plug-in url_launcher
richiede la configurazione delle applicazioni di runner per Android e iOS. Nel programma di esecuzione Flutter per iOS, aggiungi le seguenti righe al dizionario plist
.
ios/Runner/Info.plist
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
<string>http</string>
<string>tel</string>
<string>mailto</string>
</array>
Nel programma di esecuzione Flutter per Android, aggiungi le seguenti righe a Manifest.xml
. Aggiungi questo nodo queries
come elemento secondario diretto del nodo manifest
e come peer 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>
Per ulteriori dettagli su queste modifiche alla configurazione richieste, consulta la documentazione di url_launcher
.
Accedere all'API YouTube Data
Per accedere all'API YouTube Data ed elencare le playlist, devi creare un progetto API per generare le chiavi API richieste. Questi passaggi presuppongono che tu abbia già un Account Google, quindi creane uno se non ne hai ancora uno a portata di mano.
Vai a Developer Console per creare un progetto API:
Una volta creato un progetto, vai alla pagina Libreria API. Nella casella di ricerca, inserisci "youtube" e seleziona API YouTube Data v3.
Nella pagina dei dettagli dell'API YouTube Data v3, attiva l'API.
Dopo aver attivato l'API, vai alla pagina Credenziali e crea una chiave API.
Dopo un paio di secondi, dovresti visualizzare una finestra di dialogo con la tua nuova chiave API. A breve utilizzerai questa chiave.
Aggiungi codice
Per il resto di questo passaggio, dovrai tagliare e incollare molto codice per creare un'app mobile, senza alcun commento sul codice. Lo scopo di questo codelab è prendere l'app mobile e adattarla sia al computer che al web. Per un'introduzione più dettagliata alla creazione di app Flutter per il mobile, consulta La tua prima app Flutter.
Aggiungi i seguenti file, innanzitutto l'oggetto stato per l'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();
nextPageToken = response.nextPageToken;
} 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));
}
}
Aggiungi poi la pagina dei dettagli della singola playlist.
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).colorScheme.surface],
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.bodyLarge!.copyWith(
fontSize: 18,
// fontWeight: FontWeight.bold,
),
),
if (playlistItem.snippet!.videoOwnerChannelTitle != null)
Text(
playlistItem.snippet!.videoOwnerChannelTitle!,
style: Theme.of(
context,
).textTheme.bodyMedium!.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,
),
),
],
);
}
}
Aggiungi l'elenco delle playlist.
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(),
);
},
),
);
},
);
}
}
Sostituisci i contenuti del file main.dart
come segue:
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.uri.queryParameters['title']!;
final id = state.pathParameters['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).toTheme,
darkTheme: FlexColorScheme.dark(scheme: FlexScheme.red).toTheme,
themeMode: ThemeMode.dark, // Or ThemeMode.System if you'd prefer
debugShowCheckedModeBanner: false,
routerConfig: _router,
);
}
}
È quasi tutto pronto per eseguire questo codice su Android e iOS. Devi modificare solo un'altra cosa: la costante youTubeApiKey
con la chiave API di YouTube generata nel passaggio precedente.
lib/main.dart
// TODO: Replace with your YouTube API Key
const youTubeApiKey = 'AIzaNotAnApiKey';
Per eseguire questa app su macOS, devi consentire all'app di effettuare richieste HTTP come segue. Modifica i file DebugProfile.entitlements
e Release.entitilements
come segue:
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>
Esegui l'app
Ora che hai un'applicazione completa, dovresti essere in grado di eseguirla correttamente su un emulatore Android o un simulatore di iPhone. Vedrai un elenco delle playlist di Flutter. Quando selezioni una playlist, vedrai i video al suo interno e, infine, se fai clic sul pulsante Riproduci, verrà avviata l'esperienza di YouTube per guardare il video.
Tuttavia, se provi a eseguire questa app sul computer, noterai che il layout non è corretto quando viene espanso in una normale finestra di dimensioni del computer. Nel passaggio successivo esaminerai i modi per adattarti a questa situazione.
5. Adattarsi al computer
Il problema del computer
Se esegui l'app su una delle piattaforme desktop native, Windows, macOS o Linux, noterai un problema interessante. Funziona, ma sembra… strano.
Una soluzione a questo problema è aggiungere una visualizzazione divisa, con le playlist a sinistra e i video a destra. Tuttavia, vuoi che questo layout venga attivato solo quando il codice non è in esecuzione su Android o iOS e la finestra è abbastanza ampia. Le istruzioni riportate di seguito mostrano come implementare questa funzionalità.
Innanzitutto, aggiungi il pacchetto split_view
per facilitare la costruzione del layout.
$ flutter pub add split_view Resolving dependencies... Downloading packages... leak_tracker 10.0.9 (11.0.1 available) leak_tracker_flutter_testing 3.0.9 (3.0.10 available) leak_tracker_testing 3.0.1 (3.0.2 available) lints 5.1.1 (6.0.0 available) material_color_utilities 0.11.1 (0.12.0 available) meta 1.16.0 (1.17.0 available) + split_view 3.2.1 test_api 0.7.4 (0.7.6 available) vector_math 2.1.4 (2.1.5 available) Changed 1 dependency! 8 packages have newer versions incompatible with dependency constraints. Try `flutter pub outdated` for more information.
Introduzione ai widget adattivi
Il pattern che utilizzerai in questo codelab è quello di introdurre widget adattabili che fanno scelte di implementazione in base ad attributi come la larghezza dello schermo, il tema della piattaforma e così via. In questo caso, dovrai introdurre un widget AdaptivePlaylists
che modifichi il modo in cui Playlists
e PlaylistDetails
interagiscono. Modifica il file lib/main.dart
come segue:
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'; // Add this import
import 'src/app_state.dart';
import 'src/playlist_details.dart';
// Remove the src/playlists.dart import
// From https://www.youtube.com/channel/UCwXdFgeE9KYzlDdR7TG9cMw
const flutterDevAccountId = 'UCwXdFgeE9KYzlDdR7TG9cMw';
// TODO: Replace with your YouTube API Key
const youTubeApiKey = 'AIzaNotAnApiKey';
final _router = GoRouter(
routes: <RouteBase>[
GoRoute(
path: '/',
builder: (context, state) {
return const AdaptivePlaylists(); // Modify this line
},
routes: <RouteBase>[
GoRoute(
path: 'playlist/:id',
builder: (context, state) {
final title = state.uri.queryParameters['title']!;
final id = state.pathParameters['id']!;
return Scaffold( // Modify from here
appBar: AppBar(title: Text(title)),
body: PlaylistDetails(playlistId: id, playlistName: title),
); // To here.
},
),
],
),
],
);
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).toTheme,
darkTheme: FlexColorScheme.dark(scheme: FlexScheme.red).toTheme,
themeMode: ThemeMode.dark, // Or ThemeMode.System if you'd prefer
debugShowCheckedModeBanner: false,
routerConfig: _router,
);
}
}
Crea quindi il file per il 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: switch (selectedPlaylist?.snippet?.title) {
String title => Text('FlutterDev Playlist: $title'),
_ => const Text('FlutterDev Playlists'),
},
),
body: SplitView(
viewMode: SplitViewMode.Horizontal,
children: [
Playlists(
playlistSelected: (playlist) {
setState(() {
selectedPlaylist = playlist;
});
},
),
switch ((selectedPlaylist?.id, selectedPlaylist?.snippet?.title)) {
(String id, String title) => PlaylistDetails(
playlistId: id,
playlistName: title,
),
_ => const Center(child: Text('Select a playlist')),
},
],
),
);
}
}
Questo file è interessante per diversi motivi. Innanzitutto, utilizza sia la larghezza della finestra (con MediaQuery.of(context).size.width
) sia l'ispezione del tema (con Theme.of(context).platform
) per decidere se visualizzare un layout ampio con il widget SplitView
o una visualizzazione ristretta senza.
In secondo luogo, questa sezione tratta della gestione hardcoded della navigazione. Mostra un argomento di callback nel widget Playlists
. Questo callback comunica al codice circostante che l'utente ha selezionato una playlist. Il codice deve quindi eseguire il lavoro per visualizzare la playlist. Ciò cambia la necessità di Scaffold
nei widget Playlists
e PlaylistDetails
. Ora che non sono di primo livello, devi rimuovere Scaffold
da questi widget.
Successivamente, modifica il file src/lib/playlists.dart
in modo che corrisponda al seguente codice:
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);
},
),
);
},
);
}
}
Questo file contiene molte modifiche. A parte l'introduzione di cui sopra di un callback playlistSelected
e l'eliminazione del widget Scaffold
, il widget _PlaylistsListView
viene convertito da stato nullo a stato. Questa modifica è necessaria a causa dell'introduzione di un ScrollController
di proprietà che deve essere costruito e distrutto.
L'introduzione di un ScrollController
è interessante perché è obbligatoria, in quanto in un layout ampio sono presenti due widget ListView
affiancati. Su un cellulare è consuetudine avere un solo ListView
, quindi può esserci un singolo ListView
a lungo termine a cui tutti i ListView
si connettono e scollegano durante i rispettivi cicli di vita.ScrollController
Il computer è diverso, in un mondo in cui ha senso avere più ListView
affiancati.
Infine, modifica il file lib/src/playlist_details.dart
come segue:
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).colorScheme.surface],
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.bodyLarge!.copyWith(
fontSize: 18,
// fontWeight: FontWeight.bold,
),
),
if (playlistItem.snippet!.videoOwnerChannelTitle != null)
Text(
playlistItem.snippet!.videoOwnerChannelTitle!,
style: Theme.of(
context,
).textTheme.bodyMedium!.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,
),
),
],
);
}
}
Come per il widget Playlists
sopra, anche questo file contiene modifiche per l'eliminazione del widget Scaffold
e l'introduzione di un ScrollController
di proprietà.
Esegui di nuovo l'app.
Eseguire l'app sul computer che preferisci, che sia Windows, macOS o Linux. Ora dovrebbe funzionare come previsto.
6. Adattarsi al web
Che cosa c'è che non va con queste immagini?
Il tentativo di eseguire questa app sul web ora richiede un maggiore impegno per l'adattamento ai browser web.
Se dai un'occhiata alla console di debug, vedrai un piccolo suggerimento su cosa fare dopo.
══╡ EXCEPTION CAUGHT BY IMAGE RESOURCE SERVICE ╞════════════════════════════════════════════════════ The following ProgressEvent$ object was thrown resolving an image codec: [object ProgressEvent] When the exception was thrown, this was the stack Image provider: NetworkImage("https://i.ytimg.com/vi/4AoFA19gbLo/default.jpg", scale: 1.0) Image key: NetworkImage("https://i.ytimg.com/vi/4AoFA19gbLo/default.jpg", scale: 1.0) ════════════════════════════════════════════════════════════════════════════════════════════════════
Creare un proxy CORS
Un modo per risolvere i problemi di rendering delle immagini è introdurre un servizio web proxy per aggiungere le intestazioni richieste per la Condivisione delle risorse tra origini (CORS). Apri un terminale e crea un server web Dart come segue:
$ 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 directory nel server yt_cors_proxy
e aggiungi un paio di dipendenze richieste:
$ cd yt_cors_proxy $ dart pub add shelf_cors_headers http "http" was found in dev_dependencies. Removing "http" and adding it to dependencies instead. Resolving dependencies... http 1.1.2 (from dev dependency to direct dependency) js 0.6.7 (0.7.0 available) lints 2.1.1 (3.0.0 available) + shelf_cors_headers 0.1.5 Changed 2 dependencies! 2 packages have newer versions incompatible with dependency constraints. Try `dart pub outdated` for more information.
Esistono alcune dipendenze attuali che non sono più necessarie. Ritagliali come segue:
$ dart pub remove args shelf_router Resolving dependencies... args 2.4.2 (from direct dependency to transitive dependency) js 0.6.7 (0.7.0 available) lints 2.1.1 (3.0.0 available) These packages are no longer being depended on: - http_methods 1.1.1 - shelf_router 1.1.4 Changed 3 dependencies! 2 packages have newer versions incompatible with dependency constraints. Try `dart pub outdated` for more information.
A questo punto, modifica i contenuti del file server.dart in modo che corrispondano a quanto segue:
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}');
}
Puoi eseguire questo server nel seguente modo:
$ dart run bin/server.dart Server listening on port 8080
In alternativa, puoi crearla come immagine Docker ed eseguirla come segue:
$ docker build . -t yt-cors-proxy [+] Building 2.7s (14/14) FINISHED $ docker run -p 8080:8080 yt-cors-proxy Server listening on port 8080
A questo punto, modifica il codice Flutter per sfruttare questo proxy CORS, ma solo quando viene eseguito in un browser web.
Un paio di widget adattabili
Il primo della coppia di widget indica in che modo la tua app utilizzerà il proxy 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);
}
}
Questa app utilizza la costante kIsWeb
a causa delle differenze tra le piattaforme di runtime. L'altro widget adattabile modifica l'app in modo che funzioni come le altre pagine web. Gli utenti del browser si aspettano che il testo sia selezionabile.
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) {
return switch (Theme.of(context).platform) {
TargetPlatform.android || TargetPlatform.iOS => Text(data, style: style),
_ => SelectableText(data, style: style),
};
}
}
Ora, distribuisci queste adattamenti nell'intero 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).colorScheme.surface],
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( // Also, this line
playlistItem.snippet!.title!,
style: Theme.of(context).textTheme.bodyLarge!.copyWith(
fontSize: 18,
// fontWeight: FontWeight.bold,
),
),
if (playlistItem.snippet!.videoOwnerChannelTitle != null)
AdaptiveText( // And this line
playlistItem.snippet!.videoOwnerChannelTitle!,
style: Theme.of(
context,
).textTheme.bodyMedium!.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,
),
),
],
);
}
}
Nel codice riportato sopra hai adattato sia i widget Image.network
sia Text
. A questo punto, adatta il 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);
},
),
);
},
);
}
}
Questa volta hai adattato solo il widget Image.network
, ma hai lasciato invariati i due widget Text
. Questo è intenzionale perché, se adatti i widget di testo, la funzionalità onTap
di ListTile
viene bloccata quando l'utente tocca il testo.
Esegui correttamente l'app sul web
Con il proxy CORS in esecuzione, dovresti essere in grado di eseguire la versione web dell'app, che dovrebbe avere il seguente aspetto:
7. Autenticazione adattiva
In questo passaggio espandi l'app assegnandole la possibilità di autenticare l'utente e poi mostrare le sue playlist. Dovrai utilizzare più plug-in per coprire le diverse piattaforme su cui può essere eseguita l'app, perché la gestione di OAuth è molto diversa tra Android, iOS, il web, Windows, macOS e Linux.
Aggiungere plug-in per attivare l'autenticazione Google
Dovrai installare tre pacchetti per gestire l'autenticazione Google.
$ flutter pub add googleapis_auth google_sign_in \ extension_google_sign_in_as_googleapis_auth Resolving dependencies... + args 2.4.2 + crypto 3.0.3 + extension_google_sign_in_as_googleapis_auth 2.0.12 + google_identity_services_web 0.3.0+2 + google_sign_in 6.2.1 + google_sign_in_android 6.1.21 + google_sign_in_ios 5.7.2 + google_sign_in_platform_interface 2.4.4 + google_sign_in_web 0.12.3+2 + googleapis_auth 1.4.1 + js 0.6.7 (0.7.0 available) matcher 0.12.16 (0.12.16+1 available) material_color_utilities 0.5.0 (0.8.0 available) meta 1.10.0 (1.11.0 available) path 1.8.3 (1.9.0 available) test_api 0.6.1 (0.7.0 available) web 0.3.0 (0.4.0 available) Changed 11 dependencies! 7 packages have newer versions incompatible with dependency constraints. Try `flutter pub outdated` for more information.
Per l'autenticazione su Windows, macOS e Linux, utilizza il pacchetto googleapis_auth
. Queste piattaforme desktop si autenticano utilizzando un browser web. Per eseguire l'autenticazione su Android, iOS e sul web, utilizza i pacchetti google_sign_in
e extension_google_sign_in_as_googleapis_auth
. Il secondo pacchetto funge da shim di interoperabilità tra i due pacchetti.
Aggiorna il codice
Inizia l'aggiornamento creando una nuova astrazione riutilizzabile, il widget AdaptiveLogin. Questo widget è progettato per essere riutilizzato e, pertanto, richiede una certa configurazione:
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) {
final context = this.context;
if (authClient != null && context.mounted) {
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) {
final context = this.context;
if (context.mounted) {
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()));
}
}
Questo file fa molto. Il metodo build
di AdaptiveLogin
si occupa del lavoro pesante. Chiamando sia Platform.isXXX
di kIsWeb
sia Platform.isXXX
di dart:io
, questo metodo controlla la piattaforma di runtime. Per Android, iOS e il web, viene creato un widget _GoogleSignInLogin
con stato. Per Windows, macOS e Linux, viene creato un widget _GoogleApisAuthLogin
con stato.
Per utilizzare queste classi è necessaria una configurazione aggiuntiva, che verrà eseguita in un secondo momento, dopo l'aggiornamento del resto della base di codice per l'utilizzo di questo nuovo widget. Inizia rinominando FlutterDevPlaylists
in AuthedUserPlaylists
per riflettere meglio il suo nuovo scopo e aggiorna il codice in modo che rifletta il fatto che http.Client
ora viene passato dopo la costruzione. Infine, la classe _ApiKeyClient
non è più obbligatoria:
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();
nextPageToken = response.nextPageToken;
} while (nextPageToken != null);
}
YouTubeApi? _api; // Convert 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
Aggiorna il widget PlaylistDetails
con il nuovo nome dell'oggetto stato dell'applicazione fornito:
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, playlists, _) {
final playlistItems = playlists.playlistItems(playlistId: playlistId);
if (playlistItems.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
return _PlaylistDetailsListView(playlistItems: playlistItems);
},
);
}
}
Analogamente, aggiorna il widget Playlists
:
lib/src/playlists.dart
class Playlists extends StatelessWidget {
const Playlists({super.key, required this.playlistSelected});
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,
);
},
);
}
}
Infine, aggiorna il file main.dart
per utilizzare correttamente il nuovo 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.uri.queryParameters['title']!;
final id = state.pathParameters['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.router(
title: 'Your Playlists', // Change FlutterDev to Your
theme: FlexColorScheme.light(scheme: FlexScheme.red).toTheme,
darkTheme: FlexColorScheme.dark(scheme: FlexScheme.red).toTheme,
themeMode: ThemeMode.dark, // Or ThemeMode.System
debugShowCheckedModeBanner: false,
routerConfig: _router,
);
}
}
Le modifiche in questo file riflettono il passaggio dalla visualizzazione delle playlist di YouTube di Flutter alla visualizzazione delle playlist dell'utente autenticato. Anche se il codice è completo, sono necessarie ancora una serie di modifiche a questo file e ai file delle rispettive app Runner per configurare correttamente i pacchetti google_sign_in
e googleapis_auth
per l'autenticazione.
L'app ora mostra le playlist di YouTube dell'utente autenticato. Una volta completate le funzionalità, devi attivare l'autenticazione. Per farlo, configura i pacchetti google_sign_in
e googleapis_auth
. Per configurare i pacchetti, devi modificare il file main.dart
e i file per le app Runner.
Configura googleapis_auth
Il primo passaggio per configurare l'autenticazione consiste nell'eliminare la chiave API configurata e utilizzata in precedenza. Vai alla pagina delle credenziali del progetto API ed elimina la chiave API:
Viene generata una finestra di dialogo che devi confermare facendo clic sul pulsante Elimina:
Quindi, crea un ID client OAuth:
Per Tipo di applicazione, seleziona App desktop.
Accetta il nome e fai clic su Crea.
Verranno creati l'ID client e il client secret che devi aggiungere a lib/main.dart
per configurare il flusso googleapis_auth
. Un dettaglio importante dell'implementazione è che il flusso googleapis_auth utilizza un server web temporaneo in esecuzione su localhost per acquisire il token OAuth generato, che su macOS richiede una modifica al file 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>
Non è necessario apportare questa modifica al file macos/Runner/DebugProfile.entitlements
perché dispone già di un diritto per com.apple.security.network.server
per attivare il ricaricamento rapido e gli strumenti di debug della VM Dart.
Ora dovresti essere in grado di eseguire l'app su Windows, macOS o Linux (se l'app è stata compilata su questi target).
Configurare google_sign_in
per Android
Torna alla pagina delle credenziali del progetto API e crea un altro ID client OAuth, ma questa volta seleziona Android:
Per il resto del modulo, compila il nome del pacchetto con il pacchetto dichiarato in android/app/src/main/AndroidManifest.xml
. Se hai seguito le istruzioni alla lettera, dovrebbe essere com.example.adaptive_app
. Estrai l'impronta digitale del certificato SHA-1 seguendo le istruzioni riportate nella pagina Guida della console Google Cloud:
Questo è sufficiente per far funzionare l'app su Android. A seconda delle API di Google che utilizzi, potresti dover aggiungere il file JSON generato al tuo bundle di applicazioni.
Configurare google_sign_in
per iOS
Torna alla pagina delle credenziali del progetto API e crea un altro ID client OAuth, ma questa volta seleziona iOS:
Per il resto del modulo, compila l'ID pacchetto aprendo ios/Runner.xcworkspace
in Xcode. Vai a Progetto Navigator, seleziona il Runner nel navigator, poi seleziona la scheda Generale e copia l'identificatore del bundle. Se hai seguito questo codelab passo passo, dovrebbe essere com.example.adaptiveApp
.
Per il resto del modulo, inserisci l'ID pacchetto. Apri ios/Runner.xcworkspace
in Xcode. Vai a Progetto Navigator. Vai a Runner > scheda Generale. Copia l'identificatore del pacchetto. Se hai seguito questo codelab passo passo, il valore dovrebbe essere com.example.adaptiveApp
.
Per il momento, ignora l'ID App Store e l'ID team, in quanto non sono necessari per lo sviluppo locale:
Scarica il file .plist
generato, il cui nome si basa sull'ID client generato. Rinomina il file scaricato in GoogleService-Info.plist
e trascinalo nell'editor Xcode in esecuzione, insieme al file Info.plist
in Runner/Runner
nel riquadro di navigazione a sinistra. Nella finestra di dialogo delle opzioni in Xcode, seleziona Copia elementi, se necessario, Crea riferimenti alle cartelle e Aggiungi al target Runner.
Esci da Xcode e, nell'IDE che preferisci, aggiungi quanto segue a Info.plist
:
ios/Runner/Info.plist
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<!-- TODO Replace this value: -->
<!-- Copied from GoogleService-Info.plist key REVERSED_CLIENT_ID -->
<string>com.googleusercontent.apps.TODO-REPLACE-ME</string>
</array>
</dict>
</array>
Devi modificare il valore in modo che corrisponda alla voce nel file GoogleService-Info.plist
generato. Esegui l'app e, dopo aver eseguito l'accesso, dovresti vedere le tue playlist.
Configurare google_sign_in
per il web
Torna alla pagina delle credenziali del progetto API e crea un altro ID client OAuth, ma questa volta seleziona Applicazione web:
Per il resto del modulo, compila le origini JavaScript autorizzate come segue:
Viene generato un ID client. Aggiungi il seguente tag meta
a web/index.html
, aggiornato per includere l'ID client generato:
web/index.html
<meta name="google-signin-client_id" content="YOUR_GOOGLE_SIGN_IN_OAUTH_CLIENT_ID.apps.googleusercontent.com">
L'esecuzione di questo sample richiede un po' di assistenza. Devi eseguire il proxy CORS creato nel passaggio precedente ed eseguire l'app web Flutter sulla porta specificata nel modulo ID client OAuth per l'applicazione web seguendo le istruzioni riportate di seguito.
In un terminale, esegui il server proxy CORS come segue:
$ dart run bin/server.dart Server listening on port 8080
In un altro terminale, esegui l'app Flutter come segue:
$ 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".
Dopo aver eseguito di nuovo l'accesso, dovresti vedere le tue playlist:
8. Passaggi successivi
Complimenti!
Hai completato il codelab e hai creato un'app Flutter adattabile che funziona su tutte e sei le piattaforme supportate da Flutter. Hai adattato il codice per gestire le differenze nella disposizione delle schermate, nell'interazione con il testo, nel caricamento delle immagini e nel funzionamento dell'autenticazione.
Esistono molti altri elementi che puoi adattare nelle tue applicazioni. Per scoprire altri modi per adattare il codice ai diversi ambienti in cui verrà eseguito, consulta Creare app adattabili.