Adaptive Apps in Flutter

1. Einführung

Flutter ist das UI-Toolkit von Google, mit dem Sie mit einer einzigen Codebasis ansprechende, nativ kompilierte Apps für Mobilgeräte, Web und Desktop erstellen können. In diesem Codelab erfahren Sie, wie Sie eine Flutter-App erstellen, die sich an die Plattform anpasst, auf der sie ausgeführt wird – Android, iOS, das Web, Windows, macOS oder Linux.

Lerninhalte

  • Hier erfahren Sie, wie Sie eine Flutter-App entwickeln, die für Mobilgeräte entwickelt wurde und auf allen sechs von Flutter unterstützten Plattformen funktioniert.
  • Die verschiedenen Flutter APIs für die Plattformerkennung und wann die einzelnen APIs verwendet werden
  • Sich an die Einschränkungen und Erwartungen anpassen, die beim Ausführen einer App im Web mit sich bringen.
  • Hier erfahren Sie, wie Sie verschiedene Pakete parallel verwenden, um die gesamte Bandbreite der Flutter-Plattformen zu unterstützen.

Aufgaben

In diesem Codelab erstellen Sie zuerst eine Flutter-App für Android und iOS, mit der Sie sich die YouTube-Playlists von Flutter ansehen können. Anschließend passen Sie diese Anwendung für die drei Desktop-Plattformen (Windows, macOS und Linux) an, indem Sie die Darstellung der Informationen angesichts der Größe des Anwendungsfensters anpassen. Anschließend passen Sie die Anwendung für das Web an, indem Sie den in der App angezeigten Text so anpassen, wie es die Webnutzer erwarten. Zuletzt fügen Sie der App eine Authentifizierung hinzu, damit Sie Ihre eigenen Playlists entdecken können – im Gegensatz zu den Playlists des Flutter-Teams, die andere Authentifizierungsmethoden für Android, iOS und das Web erfordern als die drei Desktop-Plattformen Windows, macOS und Linux.

Hier ist ein Screenshot der Flutter-App für Android und iOS:

Die fertige App, die im Android-Emulator ausgeführt wird

Die fertige App, die auf dem iOS-Simulator ausgeführt wird

Diese App, die unter macOS im Breitbildformat ausgeführt wird, sollte dem folgenden Screenshot ähneln.

Die fertige App, die unter macOS ausgeführt wird

In diesem Codelab geht es um die Umwandlung einer mobilen Flutter-App in eine adaptive App, die auf allen sechs Flutter-Plattformen funktioniert. Irrelevante Konzepte und Codeblöcke werden nicht berücksichtigt und können einfach kopiert und eingefügt werden.

Was möchten Sie in diesem Codelab lernen?

<ph type="x-smartling-placeholder"></ph> Ich kenne dieses Thema noch nicht und möchte einen guten Überblick erhalten. Ich weiß etwas über dieses Thema, möchte aber meine Kenntnisse auffrischen. Ich suche nach Beispielcode für mein Projekt. Ich suche nach einer Erklärung zu etwas Bestimmtem.

2. Flutter-Entwicklungsumgebung einrichten

Für dieses Lab benötigen Sie zwei Softwareprogramme: das Flutter SDK und einen Editor.

Sie können das Codelab auf jedem dieser Geräte ausführen:

  • Ein physisches Android- oder iOS, das mit Ihrem Computer verbunden ist und sich im Entwicklermodus befindet.
  • Den iOS-Simulator (erfordert die Installation von Xcode-Tools).
  • Android-Emulator (Einrichtung in Android Studio erforderlich)
  • Ein Browser (zur Fehlerbehebung wird Chrome benötigt)
  • Als Windows-, Linux- oder macOS-Desktopanwendung Die Entwicklung muss auf der Plattform erfolgen, auf der Sie die Bereitstellung planen. Wenn Sie also eine Windows-Desktop-App entwickeln möchten, müssen Sie die Entwicklung unter Windows ausführen, damit Sie auf die entsprechende Build-Kette zugreifen können. Es gibt betriebssystemspezifische Anforderungen, die unter docs.flutter.dev/desktop ausführlich beschrieben werden.

3. Erste Schritte

Entwicklungsumgebung bestätigen

Um sicherzugehen, dass alles bereit für die Entwicklung ist, führen Sie am einfachsten den folgenden Befehl aus:

$ flutter doctor

Wenn etwas ohne Häkchen angezeigt wird, führen Sie den folgenden Befehl aus, um weitere Details zu erhalten:

$ flutter doctor -v

Möglicherweise müssen Sie Entwicklertools für die mobile oder Desktop-Entwicklung installieren. Weitere Informationen zur Konfiguration der Tools je nach Hostbetriebssystem finden Sie in der Dokumentation zur Flutter-Installation.

Flutter-Projekt erstellen

Eine einfache Möglichkeit, Flutter für Desktop-Anwendungen zu schreiben, besteht darin, mit dem Flutter-Befehlszeilentool ein Flutter-Projekt zu erstellen. Alternativ kann Ihre IDE einen Workflow zum Erstellen eines Flutter-Projekts über die Benutzeroberfläche bereitstellen.

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

Um sicherzugehen, dass alles funktioniert, führen Sie die Boilerplate Flutter-Anwendung wie unten gezeigt als mobile App aus. Alternativ können Sie dieses Projekt in Ihrer IDE öffnen und die Anwendung mit den zugehörigen Tools ausführen. Dank des vorherigen Schritts sollte die Ausführung als Desktop-Anwendung die einzige verfügbare Option sein.

$ 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=/

Sie sollten jetzt sehen, dass die App ausgeführt wird. Der Inhalt muss aktualisiert werden.

Aktualisieren Sie Ihren Code in lib/main.dart mit dem folgenden Code, um den Inhalt zu aktualisieren. Wenn Sie ändern möchten, was in Ihrer App angezeigt wird, führen Sie einen Hot Refresh durch.

  • Wenn Sie die Anwendung über die Befehlszeile ausführen, geben Sie für einen Hot Refresh in der Konsole r ein.
  • Wenn Sie die Anwendung in einer IDE ausführen, wird sie beim Speichern der Datei neu geladen.

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';
    }
  }
}

Die oben genannte App soll dir ein Gefühl dafür vermitteln, wie verschiedene Plattformen erkannt und angepasst werden können. Hier sehen Sie die App, die nativ unter Android und iOS ausgeführt wird:

Fenstereigenschaften im Android-Emulator werden angezeigt

Fenstereigenschaften im iOS-Simulator anzeigen

Hier ist der gleiche Code, der nativ unter macOS und in Chrome ausgeführt wird und auch unter macOS läuft.

Fenstereigenschaften unter macOS werden angezeigt

Fenstereigenschaften im Chrome-Browser anzeigen

Wichtig dabei ist, dass Flutter auf den ersten Blick alles tut, um die Inhalte an das Display anzupassen, auf dem es läuft. Der Laptop, auf dem diese Screenshots aufgenommen wurden, hat einen hochauflösenden Mac-Bildschirm. Deshalb werden sowohl die macOS- als auch die Webversion der App mit dem Geräte-Pixel-Verhältnis 2 gerendert. Auf dem iPhone 12 hat Pixel 2 ein Verhältnis von 3 und 2,63. In allen Fällen ist der angezeigte Text ungefähr ähnlich, was unsere Arbeit als Entwickler erheblich erleichtert.

Der zweite wichtige Punkt ist, dass die beiden Optionen zum Ermitteln der Plattform, auf der der Code ausgeführt wird, zu unterschiedlichen Werten führen. Mit der ersten Option wird das aus dart:io importierte Platform-Objekt geprüft. Die zweite Option (nur in der Methode build des Widgets verfügbar) ruft das Objekt Theme aus dem Argument BuildContext ab.

Der Grund für die unterschiedlichen Ergebnisse dieser beiden Methoden ist, dass sie eine andere Absicht haben. Das aus dart:io importierte Platform-Objekt ist für Entscheidungen gedacht, die unabhängig von Rendering-Optionen sind. Ein hervorragendes Beispiel hierfür ist die Entscheidung, welche Plug-ins verwendet werden sollen. Diese Plug-ins haben entweder passende native Implementierungen für eine bestimmte physische Plattform oder nicht.

Das Extrahieren des Theme aus dem BuildContext ist für themenbezogene Implementierungsentscheidungen vorgesehen. Ein hervorragendes Beispiel hierfür ist die Entscheidung, ob der Schieberegler „Material“ oder der Schieberegler „Cupertino“ verwendet werden soll, wie in Slider.adaptive erläutert.

Im nächsten Abschnitt erstellen Sie eine einfache YouTube Playlist Explorer-App, die ausschließlich für Android und iOS optimiert ist. In den folgenden Abschnitten nehmen Sie verschiedene Anpassungen vor, damit die App auf Computern und im Web besser funktioniert.

4. Mobile App erstellen

Pakete hinzufügen

In dieser App verwendest du verschiedene Flutter-Pakete für den Zugriff auf die YouTube Data API, die Statusverwaltung und verschiedene Themen.

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

Dieser Befehl fügt der Anwendung eine Reihe von Paketen hinzu:

  • googleapis: Eine generierte Dart-Bibliothek, die Zugriff auf Google APIs bietet.
  • http: Eine Bibliothek zum Erstellen von HTTP-Anfragen, in der die Unterschiede zwischen nativen und Webbrowsern ausgeblendet werden.
  • provider: Bietet Statusverwaltung.
  • url_launcher: Bietet die Möglichkeit, aus einer Playlist zu einem Video zu springen. Wie die aufgelösten Abhängigkeiten zeigen, verfügt url_launcher neben den standardmäßigen Android- und iOS-Implementierungen auch für Windows, macOS, Linux und das Web. Wenn Sie dieses Paket verwenden, müssen Sie keine plattformspezifische Funktion für diese Funktion erstellen.
  • flex_color_scheme: Verleiht der App ein schönes Standardfarbschema. Weitere Informationen finden Sie in der API-Dokumentation zu flex_color_scheme.
  • go_router: Implementiert die Navigation zwischen den verschiedenen Bildschirmen. Dieses Paket bietet eine praktische, URL-basierte API für die Navigation mit dem Flutter-Router.

Mobile Apps für url_launcher werden konfiguriert

Das url_launcher-Plug-in erfordert die Konfiguration der Android- und iOS-Runner-Apps. Fügen Sie im Flutter-Runner von iOS die folgenden Zeilen dem Wörterbuch plist hinzu.

ios/Runner/Info.plist

<key>LSApplicationQueriesSchemes</key>
<array>
        <string>https</string>
        <string>http</string>
        <string>tel</string>
        <string>mailto</string>
</array>

Fügen Sie im Android Flutter-Runner die folgenden Zeilen zum Manifest.xml hinzu. Fügen Sie diesen queries-Knoten als direktes untergeordnetes Element des Knotens manifest und als Peer des Knotens application hinzu.

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>

Weitere Informationen zu diesen erforderlichen Konfigurationsänderungen finden Sie in der Dokumentation zu url_launcher.

Zugriff auf die YouTube Data API

Wenn du auf die YouTube Data API zugreifen möchtest, um Playlists aufzulisten, musst du ein API-Projekt zum Generieren der erforderlichen API-Schlüssel erstellen. Für diese Schritte wird vorausgesetzt, dass Sie bereits ein Google-Konto haben. Erstellen Sie also eines, falls Sie noch keines zur Hand haben.

Gehen Sie zur Developer Console, um ein API-Projekt zu erstellen:

GCP Console während der Projekterstellung anzeigen

Wenn Sie ein Projekt haben, rufen Sie die Seite „API-Bibliothek“ auf. Gib im Suchfeld „youtube“ ein und wähle youtube data api v3 aus.

YouTube Data API Version 3 in der GCP Console auswählen

Aktiviere die API auf der Detailseite der YouTube Data API Version 3.

5a877ea82b83ae42.png

Nachdem Sie die API aktiviert haben, rufen Sie die Seite Anmeldedaten auf und erstellen Sie einen API-Schlüssel.

Anmeldedaten in der GCP Console erstellen

Nach einigen Sekunden sollte ein Dialogfeld mit Ihrem neuen API-Schlüssel angezeigt werden. Du wirst diesen Schlüssel in Kürze verwenden.

Pop-up für den erstellten API-Schlüssel mit dem erstellten API-Schlüssel

Code hinzufügen

Für den Rest dieses Schritts werden Sie viel Code ausschneiden und einfügen, um eine mobile App zu erstellen, ohne Kommentare zum Code. In diesem Codelab wird die mobile App an Computer und das Web angepasst. Eine ausführlichere Einführung zum Erstellen von Flutter-Apps für Mobilgeräte finden Sie in Write Your First Flutter App, Teil 1 und Teil 2 und Ansprechende Benutzeroberflächen mit Flutter erstellen.

Fügen Sie die folgenden Dateien hinzu, zuerst das Zustandsobjekt für die 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));
  }
}

Füge als Nächstes die Detailseite der jeweiligen Playlist hinzu.

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,
          ),
        ),
      ],
    );
  }
}

Füge als Nächstes die Playlist-Liste hinzu.

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(),
              );
            },
          ),
        );
      },
    );
  }
}

Und ersetzen Sie den Inhalt der Datei main.dart so:

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,
    );
  }
}

Du bist fast bereit, diesen Code auf Android- und iOS-Geräten auszuführen. Nur noch eine Sache zu ändern: Ändere die youTubeApiKey-Konstante in Zeile 14 mit dem YouTube API-Schlüssel, den du im vorherigen Schritt generiert hast.

lib/main.dart

// TODO: Replace with your YouTube API Key
const youTubeApiKey = 'AIzaNotAnApiKey';

Wenn Sie diese App unter macOS ausführen möchten, müssen Sie sie so aktivieren, dass sie HTTP-Anfragen senden kann. Bearbeiten Sie die Dateien DebugProfile.entitlements und Release.entitilements so:

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>

Anwendung ausführen

Jetzt, da Sie über eine vollständige Anwendung verfügen, sollten Sie sie erfolgreich in einem Android-Emulator oder iPhone-Simulator ausführen können. Du siehst eine Liste mit Flutter-Playlists. Wenn du eine Playlist auswählst, werden die Videos in dieser Playlist angezeigt. Wenn du schließlich auf die Wiedergabeschaltfläche klickst, wirst du zu YouTube weitergeleitet, wo du dir das Video ansehen kannst.

Die App mit den Playlists für das YouTube-Konto von FlutterDev

Videos in einer bestimmten Playlist anzeigen

Ausgewähltes Video, das im YouTube-Player wiedergegeben wird

Wenn Sie jedoch versuchen, diese App auf dem Desktop auszuführen, werden Sie feststellen, dass das Layout nicht stimmt, wenn Sie es in ein normales Desktop-Fenster erweitern. Im nächsten Schritt werden Sie nach Möglichkeiten suchen, sich daran anzupassen.

5. An den Desktop anpassen

Das Desktop-Problem

Wenn Sie die App auf einer der nativen Desktopplattformen (Windows, macOS oder Linux) ausführen, werden Sie ein interessantes Problem feststellen. Es funktioniert, aber es sieht ... seltsam aus.

Die unter macOS ausgeführte App, die eine Liste von Playlists anzeigt, die seltsam proportional aussehen

Videos in einer Playlist unter macOS

Eine Lösung für dieses Problem ist, eine geteilte Ansicht hinzuzufügen, bei der die Playlists auf der linken und die Videos auf der rechten Seite aufgelistet werden. Dieses Layout soll jedoch nur dann zum Tragen kommen, wenn der Code unter Android oder iOS nicht ausgeführt wird und das Fenster groß genug ist. Die folgenden Anweisungen zeigen, wie Sie diese Funktion implementieren können.

Füge zuerst das Paket split_view hinzu, um das Layout zu erstellen.

$ flutter pub add split_view
Resolving dependencies...
+ split_view 3.1.0
  test_api 0.4.3 (0.4.8 available)
Changed 1 dependency!

Jetzt neu: adaptive Widgets

Mit dem Muster, das Sie in diesem Codelab verwenden, stellen Sie adaptive Widgets vor, die Implementierungsentscheidungen anhand von Attributen wie Bildschirmbreite, Plattformdesign usw. treffen. In diesem Fall stellen Sie ein AdaptivePlaylists-Widget vor, das die Interaktion von Playlists und PlaylistDetails überarbeitet. Bearbeiten Sie die Datei lib/main.dart so:

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,
    );
  }
}

Erstellen Sie als Nächstes die Datei für das AdaptivePlaylist-Widget:

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')),
          },
        ],
      ),
    );
  }
}

Diese Datei ist aus mehreren Gründen interessant. Zuerst wird sowohl die Breite des Fensters (mithilfe von MediaQuery.of(context).size.width) als auch das Design (mit Theme.of(context).platform) verwendet, um zu entscheiden, ob ein breites Layout mit dem Widget SplitView oder ein schmales Display ohne das Widget angezeigt werden soll.

In diesem Abschnitt geht es dann um die hartcodierte Navigation. Sie zeigt im Playlists-Widget ein Callback-Argument an. Dieser Callback benachrichtigt den umgebenden Code, dass der Nutzer eine Playlist ausgewählt hat. Der Code muss dann die erforderlichen Schritte ausführen, um diese Playlist anzuzeigen. Dadurch ändert sich die Notwendigkeit von Scaffold im Playlists- und PlaylistDetails-Widget. Da sie sich nicht mehr auf der obersten Ebene befinden, müssen Sie das Scaffold aus diesen Widgets entfernen.

Bearbeiten Sie als Nächstes die Datei src/lib/playlists.dart so:

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);
            },
          ),
        );
      },
    );
  }
}

In dieser Datei gibt es viele Änderungen. Abgesehen von der oben erwähnten Einführung eines „playlistSelected“-Callbacks und der Entfernung des Scaffold-Widgets wird das _PlaylistsListView-Widget von zustandslos in zustandsorientiert konvertiert. Diese Änderung ist aufgrund der Einführung einer eigenen ScrollController erforderlich, die gebaut und zerstört werden muss.

Die Einführung eines ScrollController ist interessant, weil es erforderlich ist, weil in einem breiten Layout zwei ListView-Widgets nebeneinander vorhanden sind. Bei Mobiltelefonen ist es üblich, einen einzigen ListView zu verwenden. Daher kann es einen einzigen langlebigen ScrollController geben, an den alle ListViews während ihres individuellen Lebenszyklus angehängt werden und von diesem getrennt werden. In einer Welt, in der mehrere ListViews sinnvoll sind, ist der Desktop anders.

Bearbeiten Sie abschließend die lib/src/playlist_details.dart-Datei so:

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,
          ),
        ),
      ],
    );
  }
}

Ähnlich wie das Playlists-Widget oben enthält diese Datei auch Änderungen zur Entfernung des Scaffold-Widgets und zur Einführung einer eigenen ScrollController.

Führen Sie die App noch einmal aus.

Ausführen der App auf einem Desktop Ihrer Wahl unter Windows, macOS oder Linux Es sollte jetzt wie erwartet funktionieren.

Die unter macOS ausgeführte App mit geteilter Ansicht

6. Anpassung an das Web

Was ist mit diesen Bildern los, was?

Beim Versuch, diese App im Web auszuführen, ist jetzt mehr Arbeit erforderlich, um die App an den Webbrowser anzupassen.

Die im Chrome-Browser ausgeführte App ohne Miniaturansichten von YouTube-Bildern

Wenn Sie einen Blick in die Debugging-Konsole werfen, erhalten Sie einen sanften Hinweis, was Sie als Nächstes tun müssen.

══╡ 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)
════════════════════════════════════════════════════════════════════════════════════════════════════

CORS-Proxy erstellen

Eine Möglichkeit, Probleme mit dem Rendern von Bildern zu umgehen, ist die Einführung eines Proxy-Webdienstes, der die erforderlichen Cross-Origin Resource Sharing-Header einfügt. Rufen Sie ein Terminal auf und erstellen Sie einen Dart-Webserver:

$ 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

Wechseln Sie zum Verzeichnis des yt_cors_proxy-Servers und fügen Sie einige erforderliche Abhängigkeiten hinzu:

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

Es gibt einige aktuelle Abhängigkeiten, die nicht mehr benötigt werden. So schneidest du sie zu:

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

Ändern Sie als Nächstes den Inhalt der Datei server.dart wie folgt:

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}');
}

Sie können diesen Server so ausführen:

$ dart run bin/server.dart 
Server listening on port 8080

Alternativ können Sie es als Docker-Image erstellen und das resultierende Docker-Image so ausführen:

$ 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

Ändern Sie als Nächstes den Flutter-Code, um diesen CORS-Proxy zu nutzen, aber nur, wenn er in einem Webbrowser ausgeführt wird.

Zwei anpassbare Widgets

Über das erste Widget-Paar wird festgelegt, wie Ihre App den CORS-Proxy verwendet.

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);
  }
}

Diese App verwendet aufgrund von Laufzeitplattformunterschieden die kIsWeb-Konstante. Mit dem anderen anpassbaren Widget lässt sich die App wie andere Webseiten anpassen. Browsernutzer erwarten, dass Text ausgewählt werden kann.

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)
    };
  }
}

Verteilen Sie diese Anpassungen nun auf die gesamte Codebasis:

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,
          ),
        ),
      ],
    );
  }
}

Im Code oben haben Sie sowohl das Image.network- als auch das Text-Widget angepasst. Passen Sie als Nächstes das Playlists-Widget an.

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);
            },
          ),
        );
      },
    );
  }
}

Dieses Mal hast du nur das Image.network-Widget angepasst, aber die beiden Text-Widgets unverändert belassen. Das war beabsichtigt, weil die onTap-Funktion von ListTile blockiert wird, wenn der Nutzer auf den Text tippt, wenn du die Text-Widgets anpasst.

App richtig im Web ausführen

Wenn der CORS-Proxy ausgeführt wird, sollten Sie die Webversion der Anwendung ausführen können und sie ungefähr so aussehen:

Die im Chrome-Browser ausgeführte App mit den Miniaturansichten von YouTube-Bildern

7. Adaptive Authentifizierung

In diesem Schritt erweitern Sie die App, indem Sie ihr die Möglichkeit geben, den Nutzer zu authentifizieren, und zeigen dann die Playlists dieses Nutzers an. Sie müssen mehrere Plug-ins verwenden, um die verschiedenen Plattformen abzudecken, auf denen die App ausgeführt werden kann, da die Verarbeitung von OAuth bei Android, iOS, dem Web, Windows, macOS und Linux sehr unterschiedlich ist.

Plug-ins zum Aktivieren der Google-Authentifizierung hinzufügen

Sie werden drei Pakete für die Google-Authentifizierung installieren.

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

Verwenden Sie zur Authentifizierung unter Windows, macOS und Linux das googleapis_auth-Paket. Bei diesen Desktop-Plattformen erfolgt die Authentifizierung über einen Webbrowser. Verwenden Sie zur Authentifizierung unter Android, iOS und im Web die Pakete google_sign_in und extension_google_sign_in_as_googleapis_auth. Das zweite Paket fungiert als Interoperabilitäts-Shim zwischen den beiden Paketen.

Code aktualisieren

Starten Sie das Update, indem Sie eine neue wiederverwendbare Abstraktion, das AdaptiveLogin-Widget, erstellen. Dieses Widget ist für eine Wiederverwendung vorgesehen und erfordert daher eine gewisse Konfiguration:

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(),
      ),
    );
  }
}

Diese Datei ist sehr nützlich. Die schwierige Aufgabe erfolgt über die build-Methode von AdaptiveLogin. Diese Methode prüft die Laufzeitplattform, indem sie sowohl kIsWeb als auch Platform.isXXX von dart:io aufruft. Für Android, iOS und das Web wird das zustandsorientierte Widget _GoogleSignInLogin instanziiert. Unter Windows, macOS und Linux wird ein zustandsorientiertes _GoogleApisAuthLogin-Widget instanziiert.

Zur Verwendung dieser Klassen ist eine zusätzliche Konfiguration erforderlich. Diese wird später nach der Aktualisierung der restlichen Codebasis zur Verwendung dieses neuen Widgets ausgeführt. Benennen Sie zuerst die FlutterDevPlaylists in AuthedUserPlaylists um, um den neuen Zweck besser widerzuspiegeln. Aktualisieren Sie dann den Code, um zu verdeutlichen, dass die http.Client jetzt nach der Konstruktion übergeben wird. Außerdem ist die Klasse _ApiKeyClient nicht mehr erforderlich:

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

Aktualisieren Sie als Nächstes das PlaylistDetails-Widget mit dem neuen Namen für das angegebene Anwendungsstatusobjekt:

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);
      },
    );
  }
}

Aktualisieren Sie auf ähnliche Weise das Playlists-Widget:

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,
        );
      },
    );
  }
}

Aktualisieren Sie abschließend die Datei main.dart, damit das neue AdaptiveLogin-Widget richtig verwendet wird:

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,
    );
  }
}

Die Änderungen in dieser Datei spiegeln die Änderung wider, dass nicht nur die YouTube-Playlists von Flutter, sondern die Playlists des authentifizierten Nutzers angezeigt werden. Obwohl der Code jetzt vollständig ist, sind noch eine Reihe von Änderungen an dieser Datei und den Dateien unter den jeweiligen Runner-Apps erforderlich, um die google_sign_in- und googleapis_auth-Pakete für die Authentifizierung ordnungsgemäß zu konfigurieren.

Die App zeigt nun YouTube-Playlists des authentifizierten Nutzers an. Wenn die Funktionen vollständig sind, müssen Sie die Authentifizierung aktivieren. Konfigurieren Sie dazu die Pakete google_sign_in und googleapis_auth. Zum Konfigurieren der Pakete müssen Sie die Datei main.dart und die Dateien für die Runner-Anwendungen ändern.

googleapis_auth wird konfiguriert

Der erste Schritt zur Konfiguration der Authentifizierung besteht darin, den API-Schlüssel zu entfernen, den Sie zuvor konfiguriert und verwendet haben. Rufen Sie die Seite Anmeldedaten Ihres API-Projekts auf und löschen Sie den API-Schlüssel:

Seite mit den Anmeldedaten des API-Projekts in der GCP Console

Daraufhin wird ein Popup-Fenster eingeblendet, das Sie durch Klicken auf die Schaltfläche „Delete“ (Löschen) bestätigen:

Das Pop-up-Fenster „Anmeldedaten löschen“

Erstellen Sie dann eine OAuth-Client-ID:

OAuth-Client-ID erstellen

Wählen Sie als Anwendungstyp „Desktop-App“ aus.

Anwendungstyp der Desktop-App auswählen

Übernehmen Sie den Namen und klicken Sie auf Erstellen.

Client-ID benennen

Dadurch werden die Client-ID und der Clientschlüssel erstellt, die du lib/main.dart hinzufügen musst, um den googleapis_auth-Ablauf zu konfigurieren. Ein wichtiges Detail der Implementierung ist, dass der googleapis_auth-Ablauf einen temporären Webserver verwendet, der auf localhost ausgeführt wird, um das generierte OAuth-Token zu erfassen. Bei macOS ist dafür eine Änderung an der Datei macos/Runner/Release.entitlements erforderlich:

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>

Sie müssen die Datei macos/Runner/DebugProfile.entitlements nicht ändern, da sie bereits die Berechtigung com.apple.security.network.server hat, Hot Refresh und die Dart VM-Debugging-Tools zu aktivieren.

Sie sollten Ihre App jetzt unter Windows, macOS oder Linux ausführen können, sofern die App für diese Ziele kompiliert wurde.

Die App, die die Playlists für den angemeldeten Nutzer anzeigt

google_sign_in für Android wird konfiguriert

Gehen Sie zurück zur Seite "Anmeldedaten" Ihres API-Projekts und erstellen Sie eine weitere OAuth-Client-ID. Wählen Sie diesmal jedoch Android aus:

Android-Anwendungstyp auswählen

Geben Sie für den Rest des Formulars den Paketnamen mit dem in android/app/src/main/AndroidManifest.xml deklarierten Paket ein. Wenn Sie die Anweisungen zum Buchstaben befolgt haben, sollte es com.example.adaptive_app lauten. Extrahieren Sie den SHA-1-Zertifikatfingerabdruck anhand der Anleitung auf der Hilfeseite der Google Cloud Platform Console:

Android-Client-ID benennen

Das reicht aus, damit die App unter Android funktioniert. Je nachdem, für welche Google APIs Sie sich entscheiden, müssen Sie Ihrem Anwendungs-Bundle möglicherweise die generierte JSON-Datei hinzufügen.

App auf Android ausführen

google_sign_in für iOS konfigurieren

Gehen Sie zurück zur Seite "Anmeldedaten" Ihres API-Projekts und erstellen Sie eine weitere OAuth-Client-ID. Wählen Sie diesmal jedoch iOS aus:

. iOS-Anwendungstyp auswählen

Geben Sie für den Rest des Formulars die Bundle-ID ein, indem Sie ios/Runner.xcworkspace in Xcode öffnen. Navigieren Sie zum Project Navigator, wählen Sie im Navigator den Runner aus, klicken Sie dann auf den Tab Allgemein und kopieren Sie den Bundle Identifier. Wenn Sie dieses Codelab Schritt für Schritt ausgeführt haben, sollte es com.example.adaptiveApp lauten.

Geben Sie für den Rest des Formulars die Bundle-ID ein. Öffnen Sie ios/Runner.xcworkspace in Xcode. Gehen Sie zum Project Navigator. Runner aufrufen > Tab „Allgemein“. Kopieren Sie den Paket-Identifikator. Wenn Sie dieses Codelab Schritt für Schritt ausgeführt haben, sollte der Wert com.example.adaptiveApp lauten.

Paket-ID in Xcode finden

Ignorieren Sie die App Store-ID und die Team-ID vorerst, da diese für die lokale Entwicklung nicht erforderlich sind:

iOS-Client-ID benennen

Laden Sie die generierte .plist-Datei herunter. Der Name basiert auf der generierten Client-ID. Benennen Sie die heruntergeladene Datei in GoogleService-Info.plist um und ziehen Sie sie dann im linken Navigationsbereich neben der Datei Info.plist unter Runner/Runner in Ihren laufenden Xcode-Editor. Wählen Sie im Dialogfeld „Optionen“ in Xcode bei Bedarf Elemente kopieren, Ordnerreferenzen erstellen und Zum Runner-Ziel hinzufügen aus.

Generierte PLIST-Datei zur iOS-App in Xcode hinzufügen

Schließen Sie Xcode und fügen Sie dann in der IDE Ihrer Wahl Folgendes zu Info.plist hinzu:

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>

Sie müssen den Wert so bearbeiten, dass er mit dem Eintrag in der generierten GoogleService-Info.plist-Datei übereinstimmt. Führen Sie Ihre App aus. Nach der Anmeldung sollten Ihre Playlists angezeigt werden.

Die laufende App auf iOS

google_sign_in für das Web konfigurieren

Gehen Sie zurück zur Seite „Anmeldedaten“ Ihres API-Projekts und erstellen Sie eine weitere OAuth-Client-ID. Wählen Sie dieses Mal aber Webanwendung: aus.

Webanwendungstyp auswählen

Geben Sie für den Rest des Formulars die autorisierten JavaScript-Quellen so an:

Client-ID der Webanwendung benennen

Dadurch wird eine Client-ID generiert. Fügen Sie web/index.html das folgende meta-Tag hinzu. Es muss die generierte Client-ID enthalten:

web/index.html

<meta name="google-signin-client_id" content="YOUR_GOOGLE_SIGN_IN_OAUTH_CLIENT_ID.apps.googleusercontent.com">

Zum Ausführen dieses Beispiels müssen Sie etwas in der Hand halten. Sie müssen den CORS-Proxy ausführen, den Sie im vorherigen Schritt erstellt haben, und die Flutter-Webanwendung über den Port ausführen, der im OAuth-Client-ID-Formular der Webanwendung angegeben ist. Folgen Sie dazu der folgenden Anleitung.

Führen Sie in einem Terminal den CORS-Proxyserver so aus:

$ dart run bin/server.dart
Server listening on port 8080

So führen Sie die Flutter-App in einem anderen Terminal aus:

$ 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".

Nach dem erneuten Anmelden solltest du deine Playlists sehen:

Die im Chrome-Browser ausgeführte App

8. Nächste Schritte

Glückwunsch!

Sie haben das Codelab abgeschlossen und eine adaptive Flutter-App erstellt, die auf allen sechs von Flutter unterstützten Plattformen ausgeführt werden kann. Sie haben den Code angepasst, um mit Unterschieden in der Anordnung von Bildschirmen, der Interaktion mit Text, dem Laden von Bildern und der Authentifizierung zu umgehen.

Es gibt noch viele weitere Dinge, die Sie in Ihren Anwendungen anpassen können. Weitere Möglichkeiten zum Anpassen des Codes an verschiedene Umgebungen, in denen er ausgeführt wird, finden Sie unter Adaptive Apps erstellen.