App adattive in Flutter

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:

L'app completata sull'emulatore Android

L'app completata in esecuzione sul simulatore iOS

Questa app eseguita in widescreen su macOS dovrebbe essere simile allo screenshot seguente.

L'app completata in esecuzione su macOS

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?

Non ho mai affrontato questo argomento e vorrei una panoramica completa. So qualcosa su questo argomento, ma vorrei rinfrescarti un po'. Sto cercando un codice di esempio da utilizzare nel mio progetto. Vorrei avere una spiegazione su qualcosa di specifico.

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:

Visualizzazione delle proprietà delle finestre nell&#39;emulatore Android

Visualizzazione delle proprietà della finestra nel simulatore iOS

Qui vediamo lo stesso codice eseguito in modo nativo su macOS e all'interno di Chrome, sempre in esecuzione su macOS.

Visualizzazione delle proprietà delle finestre su macOS

Visualizzazione delle proprietà delle finestre nel browser Chrome

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'API flex_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:

Visualizzazione della console di Google Cloud durante il flusso di creazione del progetto

Una volta creato un progetto, vai alla pagina della Libreria API. Nella casella di ricerca, inserisci "youtube" e seleziona youtube data api v3.

Selezione dell&#39;API YouTube Data v3 nella console di Google Cloud

Attiva l'API nella pagina dei dettagli della YouTube Data API v3.

5a877ea82b83ae42.png

Dopo aver abilitato l'API, vai alla pagina Credenziali e crea una chiave API.

Creazione delle credenziali nella console Google Cloud

Dopo un paio di secondi, dovrebbe apparire una finestra di dialogo con la nuova chiave API. Utilizzerai questa chiave a breve.

Il popup creato per la chiave API che mostra la chiave API creata

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.

L&#39;app che mostra le playlist dell&#39;account YouTube di FlutterDev

Visualizzazione dei video in una playlist specifica

Un video selezionato riprodotto all&#39;interno del player di YouTube

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.

L&#39;app in esecuzione su macOS che mostra un elenco di playlist dalle proporzioni strane

I video di una playlist, su macOS

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.

L&#39;app eseguita su macOS con visualizzazione divisa

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.

L&#39;app in esecuzione nel browser Chrome, senza miniature di immagini di YouTube

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:

L&#39;app in esecuzione nel browser Chrome, con le miniature delle immagini di YouTube compilate

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:

Pagina delle credenziali del progetto API nella console di Google Cloud

In questo modo viene generato un popup che confermi di accettare premendo il pulsante Elimina:

Popup Elimina credenziale

Quindi, crea un ID client OAuth:

Creazione di un ID client OAuth

Per Tipo di applicazione, seleziona App desktop.

Selezione del tipo di applicazione desktop

Accetta il nome e fai clic su Crea.

Denominare l&#39;ID client

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).

L&#39;app che mostra le playlist per l&#39;utente che ha eseguito l&#39;accesso

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:

Selezione del tipo di applicazione 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:

Denominazione dell&#39;ID client Android

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.

Esecuzione dell&#39;app su Android

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. Selezione del tipo di applicazione iOS

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.

Dove trovare l&#39;identificatore pacchetto in Xcode

Ignora l'ID App Store e l'ID team per il momento, poiché non sono necessari per lo sviluppo locale:

Denominazione dell&#39;ID client iOS

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.

Aggiunta del file plist generato all&#39;app per iOS in Xcode

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.

App in esecuzione su iOS

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:

Selezionare il tipo di applicazione web

Per il resto del modulo, compila le origini JavaScript autorizzate come segue:

Denominazione dell&#39;ID client dell&#39;applicazione web

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:

L&#39;app in esecuzione nel browser Chrome

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.