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 adatta alla piattaforma su cui viene eseguita, che si tratti di Android, iOS, web, Windows, macOS o Linux.
Cosa imparerai
- 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.
- Adattarsi alle limitazioni e alle aspettative di esecuzione di un'app sul web.
- Come utilizzare diversi pacchetti insieme per supportare l'intera gamma di piattaforme di Flutter.
Cosa creerai
In questo codelab, inizialmente creerai un'app Flutter per Android e iOS che esplora le playlist di YouTube di Flutter. Dovrai quindi adattare questa applicazione per funzionare sulle tre piattaforme desktop (Windows, macOS e Linux) modificando la modalità di visualizzazione delle informazioni in base alle dimensioni della finestra dell'applicazione. Dopodiché, adatterai l'applicazione per il web rendendo selezionabile il testo visualizzato nell'app, come si aspettano gli utenti web. Infine, aggiungerai l'autenticazione all'app per poter esplorare le tue playlist, anziché quelle create dal team di Flutter, che richiedono approcci diversi all'autenticazione per Android, iOS e 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 essere simile allo screenshot seguente.
Questo codelab si concentra sulla trasformazione di un'app mobile Flutter in un'app adattiva 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.
Che cosa ti piacerebbe imparare da questo codelab?
2. Configura l'ambiente di sviluppo Flutter
Per completare questo lab, hai bisogno di due software: l'SDK Flutter e un editor.
Puoi eseguire il codelab utilizzando uno qualsiasi 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 (Chrome è necessario per il debug).
- Come applicazione desktop Windows, Linux o macOS. Devi sviluppare sulla piattaforma in cui prevedi di eseguire il deployment. Pertanto, se vuoi sviluppare un'app desktop Windows, devi svilupparla su Windows per accedere alla catena di build appropriata. Esistono requisiti specifici per il sistema operativo che sono trattati in dettaglio su 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 questo comando:
flutter doctor
Se viene visualizzato un elemento senza segno di spunta, esegui il comando seguente per ottenere ulteriori dettagli sul problema:
flutter doctor -v
Potresti dover installare strumenti per sviluppatori per lo sviluppo di app mobile o per computer. Per maggiori dettagli sulla configurazione degli strumenti in base al sistema operativo host, consulta la documentazione nella documentazione sull'installazione di Flutter.
Creare 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, il tuo 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 verificare che tutto funzioni, esegui l'applicazione Flutter boilerplate come app mobile, come mostrato di seguito. In alternativa, apri questo progetto nel tuo IDE ed esegui i suoi 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 nell'app, esegui un hot reload.
- Se esegui l'app utilizzando la riga di comando, digita
r
nella console per il ricaricamento rapido. - 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 darti un'idea di come le diverse piattaforme possono essere rilevate e adattate. Ecco l'app in esecuzione nativa su Android e iOS:
Ed ecco lo stesso codice eseguito in modo nativo su macOS e all'interno di Chrome, sempre su macOS.
Il punto importante da notare qui è che, a prima vista, Flutter fa il possibile per adattare i contenuti al display su cui viene eseguito. Il laptop su cui sono stati acquisiti questi screenshot ha un display Mac ad alta risoluzione, motivo per cui sia la versione web che quella macOS dell'app vengono visualizzate con un rapporto pixel dispositivo di 2. Nel frattempo, su iPhone 12 vedi un rapporto di 3 e su Pixel 2 di 2,63. In tutti i casi, il testo visualizzato è più o meno simile, il che semplifica notevolmente il nostro lavoro di sviluppatori.
Il secondo punto da notare è che le due opzioni per verificare su quale piattaforma viene eseguito il codice restituiscono valori diversi. La prima opzione esamina l'oggetto Platform
importato da dart:io
, mentre la seconda opzione (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 esempio lampante è la scelta dei plug-in da utilizzare, che potrebbero corrispondere o meno alle implementazioni native per una piattaforma fisica specifica.
L'estrazione di Theme
da BuildContext
è destinata alle decisioni di implementazione incentrate sul tema. Un esempio lampante è la decisione se utilizzare lo slider Material o lo slider Cupertino, come descritto in Slider.adaptive
.
Nella sezione successiva creerai un'app di esplorazione di base delle playlist di YouTube ottimizzata esclusivamente per Android e iOS. Nelle sezioni seguenti aggiungerai vari adattamenti per migliorare il funzionamento dell'app su computer e 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 characters 1.4.0 (1.4.1 available) + flex_color_scheme 8.3.0 + flex_seed_scheme 3.5.1 > flutter_lints 6.0.0 (was 5.0.0) + flutter_web_plugins 0.0.0 from sdk flutter + go_router 16.2.0 + googleapis 14.0.0 + http 1.5.0 + http_parser 4.1.2 > lints 6.0.0 (was 5.1.1) + logging 1.3.0 material_color_utilities 0.11.1 (0.13.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.6 (0.7.7 available) + typed_data 1.4.0 + url_launcher 6.3.2 + url_launcher_android 6.3.17 + url_launcher_ios 6.3.4 + url_launcher_linux 3.2.1 + url_launcher_macos 3.2.3 + url_launcher_platform_interface 2.3.2 + url_launcher_web 2.4.1 + url_launcher_windows 3.1.4 + web 1.1.1 Changed 24 dependencies! 4 packages have newer versions incompatible with dependency constraints. Try `flutter pub outdated` for more information.
Questo comando aggiunge una serie di pacchetti all'applicazione:
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 nativi e web.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 a quelle predefinite per Android e iOS. L'utilizzo di questo pacchetto significa che non dovrai creare una piattaforma specifica per questa funzionalità.flex_color_scheme
: fornisce all'app una 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 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 runner Android e iOS. Nel runner 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 runner Flutter per Android, aggiungi le seguenti righe a Manifest.xml
. Aggiungi questo nodo queries
come elemento secondario diretto del nodo manifest
e come nodo di pari livello 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 obbligatorie, consulta la documentazione di url_launcher
.
Accedere all'API YouTube Data
Per accedere all'API YouTube Data 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 console per gli sviluppatori 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.
Nella pagina dei dettagli dell'API YouTube Data v3, abilita l'API.
Dopo aver abilitato 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. Utilizzerai questa chiave a breve.
Aggiungi codice
Per il resto di questo passaggio, copierai e incollerai 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 dispositivi mobili, 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));
}
}
A questo punto, 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,
),
),
],
);
}
}
Poi 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 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,
);
}
}
A breve potrai 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 nel seguente modo. 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 iPhone. Vedrai un elenco delle playlist di Flutter. Se selezioni una playlist, vedrai i video contenuti al suo interno. Infine, se fai clic sul pulsante Riproduci, si aprirà YouTube e potrai guardare il video.
Se, tuttavia, provi a eseguire questa app sul computer, vedrai che il layout non è corretto quando viene espanso in una finestra di dimensioni normali per il computer. Nel passaggio successivo esamineremo i modi per adattarsi a questa situazione.
5. Adattarsi al computer
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 è 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 viene eseguito su Android o iOS e la finestra è sufficientemente ampia. Le seguenti istruzioni mostrano come implementare questa funzionalità.
Per prima cosa, aggiungi il pacchetto split_view
per facilitare la creazione del layout.
$ flutter pub add split_view Resolving dependencies... Downloading packages... characters 1.4.0 (1.4.1 available) material_color_utilities 0.11.1 (0.13.0 available) meta 1.16.0 (1.17.0 available) + split_view 3.2.1 test_api 0.7.6 (0.7.7 available) Changed 1 dependency! 4 packages have newer versions incompatible with dependency constraints. Try `flutter pub outdated` for more information.
Introduzione dei widget adattivi
Il pattern che utilizzerai in questo codelab consiste nell'introdurre widget adattivi che effettuano scelte di implementazione in base ad attributi come la larghezza dello schermo, il tema della piattaforma e così via. In questo caso, introdurrai un widget AdaptivePlaylists
che modifica il modo in cui interagiscono 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).toTheme,
darkTheme: FlexColorScheme.dark(scheme: FlexScheme.red).toTheme,
themeMode: ThemeMode.dark, // Or ThemeMode.System if you'd prefer
debugShowCheckedModeBanner: false,
routerConfig: _router,
);
}
}
Successivamente, 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 (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 stretta senza.
In secondo luogo, questa sezione riguarda la 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. In questo modo, non è più necessario 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);
},
),
);
},
);
}
}
Sono presenti molte modifiche in questo file. A parte l'introduzione del callback playlistSelected
e l'eliminazione del widget Scaffold
, il widget _PlaylistsListView
viene 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 un ScrollController
è interessante perché è necessario perché in un layout ampio hai due widget ListView
uno accanto all'altro. Su un cellulare è convenzionale avere un singolo ListView
, quindi può esserci un singolo ScrollController
di lunga durata a cui tutti i ListView
si collegano e da cui si disconnettono durante i loro singoli cicli di vita. 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
precedente, anche questo file presenta 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 desktop che preferisci, che sia Windows, macOS o Linux. Ora dovrebbe funzionare come previsto.
6. Adattarsi al web
Che cosa ne pensi di queste immagini?
Se tenti di eseguire questa app sul web, ora viene visualizzato un messaggio che indica che è necessario altro lavoro per adattarla ai browser web.
Se dai un'occhiata alla console di debug, vedrai un 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) ════════════════════════════════════════════════════════════════════════════════════════════════════
Crea un proxy CORS
Un modo per risolvere i problemi di rendering delle immagini è introdurre un servizio web proxy per aggiungere le intestazioni di condivisione delle risorse tra origini richieste. 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 del 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... Downloading packages... http 1.5.0 (from dev dependency to direct dependency) + shelf_cors_headers 0.1.5 Changed 2 dependencies!
Esiste una dipendenza attuale che non è più necessaria. Taglia questo valore come segue:
$ dart pub remove shelf_router Resolving dependencies... Downloading packages... These packages are no longer being depended on: - http_methods 1.1.1 - shelf_router 1.1.4 Changed 2 dependencies!
Successivamente, 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 crearlo come immagine Docker ed eseguire l'immagine Docker risultante nel seguente modo:
$ 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 all'interno di un browser web.
Una coppia di widget adattabili
Il primo widget della coppia indica come 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 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 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 precedente hai adattato sia i widget Image.network
che Text
. Poi, 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 i due widget Text
così com'erano. Questa scelta è stata intenzionale perché, se adatti i widget di testo, la funzionalità onTap
di ListTile
viene bloccata quando l'utente tocca il testo.
Esegui l'app sul web correttamente
Con il proxy CORS in esecuzione, dovresti essere in grado di eseguire la versione web dell'app e visualizzare un risultato simile al seguente:
7. Autenticazione adattiva
In questo passaggio estenderai l'app per consentirle di autenticare l'utente e 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 viene eseguita in modo molto diverso tra Android, iOS, web, Windows, macOS e Linux.
Aggiungere plug-in per attivare l'autenticazione Google
Installerai tre pacchetti per gestire l'autenticazione Google.
$ flutter pub add googleapis_auth google_sign_in \ extension_google_sign_in_as_googleapis_auth logging Resolving dependencies... Downloading packages... + args 2.7.0 characters 1.4.0 (1.4.1 available) + crypto 3.0.6 + extension_google_sign_in_as_googleapis_auth 3.0.0 + google_identity_services_web 0.3.3+1 + google_sign_in 7.1.1 + google_sign_in_android 7.0.3 + google_sign_in_ios 6.1.0 + google_sign_in_platform_interface 3.0.0 + google_sign_in_web 1.0.0 + googleapis_auth 2.0.0 logging 1.3.0 (from transitive dependency to direct dependency) material_color_utilities 0.11.1 (0.13.0 available) meta 1.16.0 (1.17.0 available) test_api 0.7.6 (0.7.7 available) Changed 11 dependencies! 4 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 eseguono l'autenticazione utilizzando un browser web. Per l'autenticazione su Android, iOS e 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 configurazione:
lib/src/adaptive_login.dart
import 'dart:async';
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:logging/logging.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/link.dart';
import 'app_state.dart';
final _log = Logger('AdaptiveLogin');
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.instance;
_googleSignIn.initialize();
_authEventsSubscription = _googleSignIn.authenticationEvents.listen((
event,
) async {
_log.fine('Google Sign-In authentication event: $event');
if (event is GoogleSignInAuthenticationEventSignIn) {
final googleSignInClientAuthorization = await event
.user
.authorizationClient
.authorizationForScopes(widget.scopes);
if (googleSignInClientAuthorization == null) {
_log.warning('Google Sign-In authenticated client creation failed');
return;
}
_log.fine('Google Sign-In authenticated client created');
final context = this.context;
if (context.mounted) {
context.read<AuthedUserPlaylists>().authClient =
googleSignInClientAuthorization.authClient(scopes: widget.scopes);
context.go('/');
}
}
});
// Check if user is already authenticated
_log.fine('Attempting lightweight authentication');
_googleSignIn.attemptLightweightAuthentication();
}
@override
dispose() {
_authEventsSubscription.cancel();
super.dispose();
}
late final GoogleSignIn _googleSignIn;
late final StreamSubscription _authEventsSubscription;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: widget.button(
onPressed: () {
_googleSignIn.authenticate();
},
),
),
);
}
}
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 molte cose. Il metodo build
di AdaptiveLogin
fa il lavoro pesante. Chiamando sia kIsWeb
che dart:io
's Platform.isXXX
, questo metodo controlla la piattaforma di runtime. Per Android, iOS e il web, viene creata un'istanza del widget stateful _GoogleSignInLogin
. Per Windows, macOS e Linux, viene creata un widget stateful _GoogleApisAuthLogin
.
Per utilizzare queste classi è necessaria una configurazione aggiuntiva, che verrà eseguita in un secondo momento, dopo aver aggiornato il resto del codebase per utilizzare questo nuovo widget. Inizia rinominando FlutterDevPlaylists
in AuthedUserPlaylists
per riflettere meglio il suo nuovo scopo nella vita e aggiornando il codice per indicare che http.Client
viene ora 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
Successivamente, 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);
},
);
}
}
Allo stesso modo, 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 apportate a questo file riflettono il passaggio dalla visualizzazione delle playlist di YouTube di Flutter alla visualizzazione delle playlist dell'utente autenticato. Sebbene il codice sia ora completo, sono ancora necessarie 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 che hai configurato e utilizzato in precedenza. Vai alla pagina delle credenziali del progetto API ed elimina la chiave API:
Viene generata una finestra di dialogo che devi confermare premendo il pulsante Elimina:
Poi, crea un ID client OAuth:
Per Tipo di applicazione, seleziona App per computer.
Accetta il nome e fai clic su Crea.
In questo modo vengono creati l'ID client e il client secret che devi aggiungere a lib/main.dart
per configurare il flusso googleapis_auth
. Un dettaglio di implementazione importante è 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à di un diritto per com.apple.security.network.server
per abilitare 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 campo Nome pacchetto con il pacchetto dichiarato in android/app/src/main/AndroidManifest.xml
. Se hai seguito le indicazioni 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 bundle dell'applicazione.
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 bundle aprendo ios/Runner.xcworkspace
in Xcode. Vai al navigatore del progetto, seleziona Runner nel navigatore, quindi seleziona la scheda Generale e copia l'identificatore bundle. Se hai seguito questo codelab passo dopo passo, dovrebbe essere com.example.adaptiveApp
.
Per il resto del modulo, compila l'ID bundle. Apri ios/Runner.xcworkspace
in Xcode. Vai al navigatore del progetto. Vai a Runner > scheda Generale. Copia l'identificatore bundle. Se hai seguito questo codelab passo dopo passo, il suo valore dovrebbe essere com.example.adaptiveApp
.
Per ora ignora l'ID App Store e l'ID team, in quanto non sono obbligatori 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
, quindi trascinalo nell'editor Xcode in esecuzione, accanto al file Info.plist
in Runner/Runner
nel navigatore a sinistra. Per la finestra di dialogo delle opzioni in Xcode, seleziona Copia elementi, se necessario, Crea riferimenti alle cartelle e Aggiungi a 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 visualizzare 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 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 un po' di assistenza. Devi eseguire il proxy CORS che hai creato nel passaggio precedente e 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 nel seguente modo:
$ 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 visualizzare le tue playlist:
8. Passaggi successivi
Complimenti!
Hai completato il codelab e creato un'app Flutter adattiva che viene eseguita su tutte e sei le piattaforme supportate da Flutter. Hai adattato il codice per gestire le differenze nel layout delle schermate, nell'interazione con il testo, nel caricamento delle immagini e nel funzionamento dell'autenticazione.
Ci sono molte altre cose 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.