1. Introduzione
Flutter è il toolkit dell'interfaccia utente di Google che consente di creare fantastiche applicazioni compilate in modo nativo per dispositivi mobili, web e computer a partire da un unico codebase. In questo codelab, imparerai a creare un'app Flutter che si adatta alla piattaforma su cui viene eseguita, che sia Android, iOS, il web, Windows, macOS o Linux.
Obiettivi didattici
- Come sviluppare un'app Flutter progettata per dispositivi mobili 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.
- Adattarsi alle restrizioni 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 di Flutter.
Cosa creerai
In questo codelab, creerai inizialmente un'app Flutter per Android e iOS che esplora le playlist di YouTube di Flutter. Successivamente, adatterai l'applicazione in modo che funzioni sulle tre piattaforme desktop (Windows, macOS e Linux) modificando il modo in cui le informazioni vengono visualizzate in base alla dimensione della finestra dell'applicazione. Quindi adatterai l'applicazione al Web rendendo selezionabile il testo visualizzato nell'app, come si aspettano gli utenti web. Infine, aggiungerai l'autenticazione all'app in modo da poter esplorare le tue playlist, diversamente da quelle create dal team di Flutter, che richiedono 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 eseguita in widescreen su macOS dovrebbe essere simile allo screenshot seguente.
Questo codelab è incentrato sulla trasformazione di un'app mobile Flutter in un'app adattiva compatibile con tutte e sei le piattaforme Flutter. I concetti e i blocchi di codice non pertinenti sono trattati solo superficialmente e sono forniti solo per operazioni di copia e incolla.
Cosa ti piacerebbe imparare da questo codelab?
2. Configura l'ambiente di sviluppo di Flutter
Per completare questo lab sono necessari due software: l'SDK Flutter e l'editor.
Puoi eseguire il codelab utilizzando uno di questi dispositivi:
- Un dispositivo fisico Android o iOS connesso al computer e impostato sulla modalità sviluppatore.
- Il simulatore iOS (richiede l'installazione degli strumenti Xcode).
- L'emulatore Android (richiede la configurazione in Android Studio).
- Un browser (per il debug è richiesto Chrome).
- Come applicazione desktop Windows, Linux o macOS. Devi svilupparle sulla piattaforma in cui prevedi di eseguire il deployment. Quindi, se vuoi sviluppare un'app desktop per Windows, devi sviluppare su Windows per accedere alla catena di build appropriata. Alcuni requisiti specifici del sistema operativo sono descritti in dettaglio all'indirizzo docs.flutter.dev/desktop.
3. Inizia
Conferma dell'ambiente di sviluppo
Il modo più semplice per assicurarsi che tutto sia pronto per lo sviluppo, esegui questo comando:
$ flutter doctor
Nel caso in cui non sia presente un segno di spunta, esegui il comando riportato di seguito per visualizzare 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 sull'installazione di Flutter.
Creazione di un progetto Flutter
Un modo semplice per iniziare a scrivere Flutter per app desktop è utilizzare lo strumento a riga di comando Flutter per creare un progetto Flutter. In alternativa, il tuo IDE potrebbe fornire un flusso di lavoro per creare 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 correttamente, esegui l'applicazione Flutter boilerplate come app mobile, come mostrato di seguito. In alternativa, apri questo progetto nel tuo 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=/
A questo punto, l'app dovrebbe essere in esecuzione. I contenuti devono essere aggiornati.
Per aggiornare i contenuti, aggiorna il codice in lib/main.dart
con il seguente codice. Per cambiare ciò che viene visualizzato nell'app, esegui un ricaricamento a caldo.
- Se esegui l'app utilizzando la riga di comando, digita
r
nella console per eseguire il ricaricamento a caldo. - 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 mostrata sopra è progettata per darti un'idea di come è possibile individuare e adattare le diverse piattaforme. Ecco l'app in esecuzione in modo nativo su Android e iOS:
Qui vediamo lo stesso codice eseguito in modo nativo su macOS e all'interno di Chrome, sempre in esecuzione su macOS.
Il punto importante da notare è che, a prima vista, Flutter sta facendo il possibile per adattare i contenuti al display su cui vengono visualizzati. Il laptop su cui sono stati acquisiti questi screenshot ha un display Mac ad alta risoluzione, motivo per cui sia la versione macOS che quella web dell'app vengono visualizzate con il rapporto pixel del dispositivo pari a 2. Nel frattempo, su iPhone 12, vedi un rapporto di 3 e 2, 63 su Pixel 2. In tutti i casi il testo visualizzato è più o meno simile, semplificando notevolmente 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 controlla 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
.
Il motivo per cui questi due metodi restituiscono risultati diversi è che il loro scopo è diverso. L'oggetto Platform
importato da dart:io
è destinato a essere utilizzato per prendere decisioni indipendenti dalle scelte di rendering. Un ottimo esempio è la scelta dei plug-in da utilizzare, che possono avere implementazioni native corrispondenti per una specifica piattaforma fisica.
L'estrazione di Theme
da BuildContext
ha lo scopo di prendere decisioni di implementazione incentrate sul tema. Un ottimo esempio è la scelta di utilizzare il dispositivo di scorrimento Materiale o quello di Cupertino, come discusso in Slider.adaptive
.
Nella sezione successiva creerai un'app Esplora 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
Aggiungi pacchetti
In questa app utilizzerai una serie di pacchetti Flutter per ottenere l'accesso all'API YouTube Data, alla gestione degli stati e a una serie di temi.
$ flutter pub add googleapis http provider url_launcher flex_color_scheme go_router Resolving dependencies... Downloading packages... + _discoveryapis_commons 1.0.6 + flex_color_scheme 7.3.1 + flex_seed_scheme 1.5.0 + flutter_web_plugins 0.0.0 from sdk flutter + go_router 14.0.1 + googleapis 13.1.0 + http 1.2.1 + http_parser 4.0.2 leak_tracker 10.0.4 (10.0.5 available) leak_tracker_flutter_testing 3.0.3 (3.0.5 available) + logging 1.2.0 material_color_utilities 0.8.0 (0.11.1 available) meta 1.12.0 (1.14.0 available) + nested 1.0.0 + plugin_platform_interface 2.1.8 + provider 6.1.2 test_api 0.7.0 (0.7.1 available) + typed_data 1.3.2 + url_launcher 6.2.6 + url_launcher_android 6.3.1 + url_launcher_ios 6.2.5 + url_launcher_linux 3.1.1 + url_launcher_macos 3.1.0 + url_launcher_platform_interface 2.3.2 + url_launcher_web 2.3.1 + url_launcher_windows 3.1.1 + web 0.5.1 Changed 22 dependencies! 5 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 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
: offre la possibilità di passare direttamente a un video da una playlist. Come mostrato dalle dipendenze risolte,url_launcher
offre implementazioni per Windows, macOS, Linux e per il web, oltre alle implementazioni predefinite per Android e iOS. Se utilizzi questo pacchetto, non dovrai creare una piattaforma specifica per questa funzionalità.flex_color_scheme
: offre all'app una combinazione di colori predefinita efficace. Per saperne di più, consulta la documentazione dell'APIflex_color_scheme
.go_router
: implementa la navigazione tra le diverse schermate. Questo pacchetto fornisce una pratica API basata su URL per la navigazione con il router di Flutter.
Configurazione delle app mobile per url_launcher
in corso...
Il plug-in url_launcher
richiede la configurazione delle applicazioni runner per Android e iOS. In Flutter Runner 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 runner Android Flutter, aggiungi le seguenti righe a Manifest.xml
. Aggiungi questo nodo queries
come elemento figlio diretto del nodo manifest
e 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 richieste alla configurazione, consulta la documentazione di url_launcher
.
Accesso all'API YouTube Data
Per accedere alla YouTube Data API per 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 già uno a portata di mano.
Vai alla Developer Console per creare un progetto API:
Una volta creato un progetto, vai alla pagina della Libreria API. Nella casella di ricerca, inserisci "youtube" e seleziona youtube data api v3.
Attiva l'API nella pagina dei dettagli della YouTube Data API v3.
Dopo aver abilitato l'API, vai alla pagina Credenziali e crea una chiave API.
Dopo un paio di secondi, dovrebbe apparire una finestra di dialogo con la nuova chiave API. Utilizzerai questa chiave a breve.
Aggiungi codice
Per il resto di questo passaggio, taglia e incolla molto codice per creare un'app mobile, senza alcun commento sul codice. Lo scopo di questo codelab è adattare l'app mobile sia al desktop sia al web. Per un'introduzione più dettagliata sulla creazione di app Flutter per dispositivi mobili, consulta gli articoli Write Your First Flutter App, parte 1, parte 2 e Creare fantastiche UI con Flutter.
Aggiungi i file seguenti, in primo luogo l'oggetto di 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));
}
}
Successivamente, aggiungi 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,
),
),
],
);
}
}
Quindi, 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(),
);
},
),
);
},
);
}
}
e sostituisci i contenuti di main.dart
file 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,
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,
);
}
}
Sei quasi pronto per eseguire questo codice su Android e iOS. Un'altra cosa da cambiare è modificare la costante youTubeApiKey
alla riga 14 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 abilitare l'app per effettuare richieste HTTP come indicato di seguito. 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 per iPhone. Vedrai un elenco di playlist di Flutter, quando selezioni una playlist vedrai i video al suo interno e, infine, se fai clic sul pulsante Riproduci, si aprirà l'esperienza di YouTube e potrai guardare il video.
Se, tuttavia, tenti di eseguire l'app da computer, vedrai che il layout non è corretto quando viene espanso in una normale finestra di dimensioni desktop. Nel prossimo passaggio vedremo come adattarti a questo aspetto.
5. Adattarsi al desktop
Il problema del desktop
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 consiste nell'aggiungere una visualizzazione divisa, con l'elenco delle playlist a sinistra e dei video a destra. Tuttavia, vuoi che questo layout venga attivato solo quando il codice non è in esecuzione su Android o iOS e la finestra è sufficientemente larga. Le seguenti istruzioni mostrano come implementare questa funzionalità.
Per prima cosa, aggiungi il pacchetto split_view
per creare il layout.
$ flutter pub add split_view Resolving dependencies... + split_view 3.1.0 test_api 0.4.3 (0.4.8 available) Changed 1 dependency!
Presentazione dei widget adattivi
Il pattern che utilizzerai in questo codelab consiste nell'introdurre i widget adattivi che consentono di effettuare scelte di implementazione in base ad attributi come la larghezza dello schermo, il tema della piattaforma e così via. In questo caso, presenterai un widget AdaptivePlaylists
che rielabora la modalità di interazione tra Playlists
e PlaylistDetails
. 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,
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,
);
}
}
Quindi, crea 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 (utilizzando MediaQuery.of(context).size.width
) sia stai ispezionando il tema (utilizzando Theme.of(context).platform
) per decidere se visualizzare un layout largo con il widget SplitView
o un display stretto senza questo widget.
In secondo luogo, questa sezione riguarda la gestione della navigazione hardcoded. Mostra un argomento di callback nel widget Playlists
. Il callback comunica al codice circostante che l'utente ha selezionato una playlist. Il codice deve quindi eseguire il lavoro per visualizzare la playlist. Di conseguenza, non sarà più necessario usare Scaffold
nei widget Playlists
e PlaylistDetails
. Ora che non sono di primo livello, devi rimuovere Scaffold
dai widget.
A questo punto, modifica il file src/lib/playlists.dart
come segue:
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);
},
),
);
},
);
}
}
Sono state apportate molte modifiche in questo file. A parte l'introduzione già citata del callback playlistSelected e l'eliminazione del widget Scaffold
, il widget _PlaylistsListView
è stato convertito da stateless a stateful. Questa modifica è necessaria a causa dell'introduzione di un ScrollController
di proprietà che deve essere costruito e distrutto.
L'introduzione di ScrollController
è interessante perché è obbligatoria perché in un layout ampio ci sono due widget ListView
uno accanto all'altro. Su un cellulare è tradizionale avere un singolo ListView
, quindi può esserci un unico Scorri controller di lunga durata che tutti i ListView
si attaccano e da cui si scollegano durante i cicli di vita individuali. Il desktop è diverso, in un mondo in cui più ListView
affiancati hanno senso.
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,
),
),
],
);
}
}
Analogamente al widget Playlists
sopra, questo file presenta anche 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 si tratti di Windows, macOS o Linux. Ora dovrebbe funzionare come previsto.
6. Adattamento al web
Cosa c'è con queste immagini, eh?
Se si tenta di eseguire questa app sul web, ora è necessario un maggior lavoro per adattarsi ai browser web.
Se dai un'occhiata nella console di debug, vedrai un piccolo suggerimento su cosa devi fare.
══╡ 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) ════════════════════════════════════════════════════════════════════════════════════════════════════
Creazione di un proxy CORS
Un modo per affrontare i problemi di rendering delle immagini è introdurre un servizio web proxy per aggiungere le intestazioni richieste di condivisione delle risorse tra origini. 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
Passa alla directory nel server yt_cors_proxy
e aggiungi un paio di dipendenze obbligatorie:
$ 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.
Alcune dipendenze attuali non sono più necessarie. Tagliali 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.
Quindi, modifica il contenuto del file server.arrow in modo che corrisponda 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 eseguire l'immagine Docker risultante 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
Successivamente, modifica il codice Flutter per sfruttare questo proxy CORS, ma solo quando viene eseguito all'interno di un browser web.
Una coppia di widget adattabili
Il primo della coppia di widget è il modo in cui 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, diffondi questi adattamenti in tutto il 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 i widget Image.network
e Text
. Quindi, 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 solo adattato il widget Image.network
, ma i due widget Text
sono rimasti invariati. Questo è stato 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 riuscire a eseguire la versione web dell'app e avere il seguente aspetto:
7. Autenticazione adattiva
In questo passaggio amplierai l'app dando la possibilità di autenticare l'utente, quindi visualizzerai 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 viene eseguita in modo molto diverso tra Android, iOS, il web, Windows, macOS e Linux.
Aggiunta di plug-in per attivare l'autenticazione Google
Stai per 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 eseguire l'autenticazione su Windows, macOS e Linux, utilizza il pacchetto googleapis_auth
. Queste piattaforme desktop eseguono l'autenticazione tramite un browser web. Per eseguire l'autenticazione su Android, iOS e sul web, usa 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
Avvia l'aggiornamento creando una nuova astrazione riutilizzabile, il widget AdaptiveLogin. Questo widget è progettato per essere riutilizzato e, come tale, richiede una 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) {
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(),
),
);
}
}
Questo file fa molto. Il metodo build
di AdaptiveLogin
si occupa del lavoro pesante. Chiamata a Platform.isXXX
sia di kIsWeb
sia di dart:io
, questo metodo controlla la piattaforma di runtime. Per Android, iOS e il web, crea un'istanza per il widget stateful _GoogleSignInLogin
. Per Windows, macOS e Linux, crea un'istanza per un widget stateful _GoogleApisAuthLogin
.
È necessaria un'ulteriore configurazione per utilizzare queste classi, che saranno successive, dopo l'aggiornamento del resto del codebase per utilizzare questo nuovo widget. Inizia rinominando FlutterDevPlaylists
in AuthedUserPlaylists
per rispecchiare meglio il suo nuovo scopo nella vita e aggiornando il codice per riflettere che il http.Client
ora viene superato dopo la costruzione. Infine, il corso _ApiKeyClient
non è più necessario:
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
A questo punto, aggiorna il widget PlaylistDetails
con il nuovo nome dell'oggetto di 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);
},
);
}
}
Allo stesso modo, aggiorna il 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,
);
},
);
}
}
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,
useMaterial3: true,
).toTheme,
darkTheme: FlexColorScheme.dark(
scheme: FlexScheme.red,
useMaterial3: true,
).toTheme,
themeMode: ThemeMode.dark, // Or ThemeMode.System
debugShowCheckedModeBanner: false,
routerConfig: _router,
);
}
}
Le modifiche in questo file riflettono la modifica dalla semplice visualizzazione delle playlist di YouTube di Flutter alla visualizzazione delle playlist dell'utente autenticato. Mentre il codice è ora completo, sono ancora necessarie una serie di modifiche al file e ai file nelle 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à, dovrai 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.
Configurazione di googleapis_auth
Il primo passaggio per configurare l'autenticazione consiste nell'eliminare la chiave API che hai configurato e utilizzato in precedenza. Vai alla pagina delle credenziali del progetto API ed elimina la chiave API:
In questo modo viene generato un popup che confermi di accettare premendo il pulsante Elimina:
Quindi, crea un ID client OAuth:
Per Tipo di applicazione, seleziona App desktop.
Accetta il nome e fai clic su Crea.
Questa operazione crea l'ID client e il client secret da aggiungere a lib/main.dart
per configurare il flusso googleapis_auth
. Un importante dettaglio di 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
, in quanto dispone già del diritto di consentire a com.apple.security.network.server
di abilitare la ricarica rapida e gli strumenti di debug per VM Dart.
Ora dovresti essere in grado di eseguire l'app su Windows, macOS o Linux (se l'app è stata compilata in questi target).
Configurazione di 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:
Nella parte restante del modulo, compila il campo Nome del pacchetto con il pacchetto dichiarato in android/app/src/main/AndroidManifest.xml
. Se hai seguito le istruzioni indicate, dovrebbe essere com.example.adaptive_app
. Estrai l'impronta digitale del certificato SHA-1 seguendo le istruzioni riportate nella pagina di assistenza della console di Google Cloud Platform:
Questo è sufficiente per far funzionare l'app su Android. A seconda delle API di Google che utilizzi, potresti dover aggiungere il file JSON generato all'applicazione bundle.
Configurazione di 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:
di Google.
Nella parte rimanente del modulo, compila l'ID pacchetto aprendo ios/Runner.xcworkspace
in Xcode. Vai al Navigatore progetto, seleziona Runner nel navigatore, quindi seleziona la scheda Generale e copia l'identificatore pacchetto. Se hai seguito questo codelab passo passo, dovrebbe essere com.example.adaptiveApp
.
Nella parte rimanente del modulo, compila l'ID pacchetto. Apri ios/Runner.xcworkspace
in Xcode. Vai al navigatore dei progetti. Vai a Runner > Scheda Generale. Copia l'identificatore pacchetto. Se hai seguito questo codelab passo passo, il suo valore dovrebbe essere com.example.adaptiveApp
.
Ignora l'ID App Store e l'ID team per il momento, poiché non sono necessari per lo sviluppo locale:
Scarica il file .plist
generato. Il nome è basato 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. Per la finestra di dialogo delle opzioni in Xcode, seleziona Copia elementi se necessario, Crea riferimenti cartella e Aggiungi al target di Runner.
Esci da Xcode e poi, nell'IDE di tua scelta, 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. Dopo aver effettuato l'accesso, dovresti visualizzare le tue playlist.
Configurazione di google_sign_in
per il web in corso...
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:
In questo modo viene generato un Client-ID. Aggiungi il seguente tag meta
a web/index.html
, aggiornato in modo da 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 esempio richiede una mano. Devi eseguire il proxy CORS che hai creato nel passaggio precedente ed eseguire l'app web Flutter sulla porta specificata nel modulo ID client OAuth dell'applicazione web utilizzando le seguenti istruzioni.
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 nuovamente l'accesso, dovresti visualizzare le tue playlist:
8. Passaggi successivi
Complimenti!
Hai completato il codelab e creato un'app Flutter adattiva che può essere eseguita su tutte e sei le piattaforme supportate da Flutter. Hai adattato il codice per gestire le differenze di layout delle schermate, di interazione del testo, di caricamento delle immagini e di funzionamento dell'autenticazione.
Ci sono molti altri aspetti che puoi adattare nelle tue applicazioni. Per scoprire altri modi per adattare il codice ai diversi ambienti in cui verrà eseguito, consulta Creazione di app adattive.