Adaptive Apps in Flutter

1. Einführung

Flutter ist das UI-Toolkit von Google zum Erstellen ansprechender, nativ kompilierter Anwendungen für Mobilgeräte, das Web und Computer mit einer gemeinsamen Codebasis. In diesem Codelab erfahren Sie, wie Sie eine Flutter-App erstellen, die sich an die Plattform anpasst, auf der sie ausgeführt wird – ganz gleich ob Android, iOS, das Web, Windows, macOS oder Linux.

Lerninhalte

  • So entwickeln Sie eine für Mobilgeräte konzipierte Flutter-App für alle sechs von Flutter unterstützten Plattformen.
  • Die verschiedenen Flutter-APIs für die Plattform-Erkennung und wann die einzelnen APIs verwendet werden sollten.
  • Anpassung an die Einschränkungen und Erwartungen bei der Ausführung einer App im Web
  • Wie Sie verschiedene Pakete nebeneinander verwenden, um alle Flutter-Plattformen zu unterstützen.

Aufgaben

In diesem Codelab entwickeln Sie zuerst eine Flutter-App für Android und iOS, in der Sie YouTube-Playlists zu Flutter aufrufen können. Anschließend passen Sie diese Anwendung an die drei Desktop-Plattformen (Windows, macOS und Linux) an, indem Sie die Darstellung von Informationen entsprechend der Größe des Anwendungsfensters ändern. Anschließend passen Sie die Anwendung für das Web an, indem Sie Text, der in der App angezeigt wird, auswählbar machen, wie es Webnutzer erwarten. Schließlich fügen Sie der App die Authentifizierung hinzu, damit Sie Ihre eigenen Playlists ansehen können. Dazu sind für Android, iOS und das Web andere Authentifizierungsansätze erforderlich als für die drei Desktop-Plattformen Windows, macOS und Linux.

Hier ist ein Screenshot der Flutter-App unter Android und iOS:

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

Die fertige App im iOS-Simulator

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

Die fertige App unter macOS

In diesem Codelab geht es darum, eine mobile Flutter-App in eine adaptive App umzuwandeln, die auf allen sechs Flutter-Plattformen funktioniert. Auf irrelevante Konzepte und Codeblöcke wird nicht genauer eingegangen. Sie können die entsprechenden Codeblöcke einfach kopieren und einfügen.

Was möchten Sie in diesem Codelab lernen?

Ich bin neu auf diesem Gebiet und möchte einen guten Überblick erhalten. Ich weiß etwas über dieses Thema, möchte aber mein Wissen auffrischen. Ich suche Beispielcode für mein Projekt. Ich suche nach einer Erklärung für etwas Bestimmtes.

2. Flutter-Entwicklungsumgebung einrichten

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

Sie können das Codelab auf einem der folgenden Geräte ausführen:

  • Ein physisches Android- oder iOS-Gerät, das mit Ihrem Computer verbunden ist und auf den Entwicklermodus eingestellt ist.
  • Der iOS-Simulator (erfordert die Installation von Xcode-Tools).
  • Android Emulator (muss in Android Studio eingerichtet werden)
  • Ein Browser (für das Debugging ist Chrome erforderlich).
  • Als Windows-, Linux- oder macOS-Desktopanwendung. Sie müssen auf der Plattform entwickeln, auf der Sie die Bereitstellung planen. Wenn Sie also eine Windows-Desktop-App entwickeln möchten, müssen Sie unter Windows entwickeln, um auf die entsprechende Build-Kette zuzugreifen. Es gibt betriebssystemspezifische Anforderungen, die auf docs.flutter.dev/desktop ausführlich beschrieben werden.

3. Jetzt starten

Entwicklungsumgebung bestätigen

Am einfachsten prüfen Sie mit dem folgenden Befehl, ob alles für die Entwicklung bereit ist:

flutter doctor

Wenn etwas ohne Häkchen angezeigt wird, führen Sie Folgendes aus, um weitere Informationen zu erhalten:

flutter doctor -v

Möglicherweise müssen Sie Entwicklertools für die mobile oder Desktop-Entwicklung installieren. Weitere Informationen zum Konfigurieren der Tools in Abhängigkeit vom Hostbetriebssystem finden Sie in der Dokumentation zur Flutter-Installation.

Flutter-Projekt erstellen

Eine Möglichkeit, mit dem Schreiben von Flutter-Apps für den Desktop zu beginnen, ist die Verwendung des Flutter-Befehlszeilentools zum Erstellen eines Flutter-Projekts. Alternativ bietet Ihre IDE möglicherweise einen Workflow zum Erstellen eines Flutter-Projekts über die Benutzeroberfläche.

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

Damit alles funktioniert, führen Sie die Boilerplate-Flutter-Anwendung als mobile App aus, wie unten gezeigt. Alternativ können Sie dieses Projekt in Ihrer IDE öffnen und die Anwendung mit den entsprechenden Tools ausführen. Dank des vorherigen Schritts sollte die Ausführung als Desktopanwendung 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=/

Die App sollte jetzt ausgeführt werden. Die Inhalte müssen aktualisiert werden.

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

  • Wenn Sie die App über die Befehlszeile ausführen, geben Sie r in die Konsole ein, um ein Hot-Reload durchzuführen.
  • Wenn Sie die App mit einer IDE ausführen, wird sie neu geladen, wenn Sie die Datei speichern.

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 App soll Ihnen ein Gefühl dafür vermitteln, wie verschiedene Plattformen erkannt und angepasst werden können. Hier sehen Sie die App, die nativ auf Android und iOS ausgeführt wird:

Fenstereigenschaften im Android-Emulator anzeigen

Fenstereigenschaften im iOS-Simulator anzeigen

Hier sehen Sie denselben Code, der nativ unter macOS und in Chrome ausgeführt wird, das ebenfalls unter macOS ausgeführt wird.

Fenstereigenschaften unter macOS anzeigen

Fenstereigenschaften im Chrome-Browser anzeigen

Wichtig ist hier, dass Flutter auf den ersten Blick alles tut, um den Inhalt an das Display anzupassen, auf dem er ausgeführt wird. Der Laptop, auf dem diese Screenshots aufgenommen wurden, hat ein hochauflösendes Mac-Display. Daher werden sowohl die macOS- als auch die Webversion der App mit einem Geräte-Pixelverhältnis von 2 gerendert. Beim iPhone 12 liegt das Verhältnis bei 3 und beim Pixel 2 bei 2,63. In allen Fällen ist der angezeigte Text ungefähr gleich, was unsere Arbeit als Entwickler erheblich erleichtert.

Zweitens ist zu beachten, dass die beiden Optionen zum Prüfen, auf welcher Plattform der Code ausgeführt wird, zu unterschiedlichen Werten führen. Bei der ersten Option wird das aus dart:io importierte Platform-Objekt geprüft. Bei der zweiten Option (nur in der build-Methode des Widgets verfügbar) wird das Theme-Objekt aus dem BuildContext-Argument abgerufen.

Der Grund dafür, dass diese beiden Methoden unterschiedliche Ergebnisse zurückgeben, liegt in ihrer unterschiedlichen Intention. Das aus dart:io importierte Platform-Objekt ist für Entscheidungen gedacht, die unabhängig von Rendering-Optionen sind. Ein gutes Beispiel dafür ist die Entscheidung, welche Plug-ins verwendet werden sollen. Diese haben möglicherweise keine entsprechenden nativen Implementierungen für eine bestimmte physische Plattform.

Das Extrahieren von Theme aus BuildContext ist für Implementierungsentscheidungen vorgesehen, die themenbezogen sind. Ein gutes Beispiel dafür ist die Entscheidung, ob der Material- oder der Cupertino-Slider verwendet werden soll, wie in Slider.adaptive beschrieben.

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 fügen Sie verschiedene Anpassungen hinzu, damit die App auf dem Desktop und im Web besser funktioniert.

4. Mobile App erstellen

Pakete hinzufügen

In dieser App verwenden Sie verschiedene Flutter-Pakete, um auf die YouTube Data API, die Statusverwaltung und das Theming zuzugreifen.

$ flutter pub add googleapis http provider url_launcher flex_color_scheme go_router
Resolving dependencies...
Downloading packages...
+ _discoveryapis_commons 1.0.7
  characters 1.4.0 (1.4.1 available)
+ flex_color_scheme 8.3.0
+ flex_seed_scheme 3.5.1
> flutter_lints 6.0.0 (was 5.0.0)
+ flutter_web_plugins 0.0.0 from sdk flutter
+ go_router 16.2.0
+ googleapis 14.0.0
+ http 1.5.0
+ http_parser 4.1.2
> lints 6.0.0 (was 5.1.1)
+ logging 1.3.0
  material_color_utilities 0.11.1 (0.13.0 available)
  meta 1.16.0 (1.17.0 available)
+ nested 1.0.0
+ plugin_platform_interface 2.1.8
+ provider 6.1.5
  test_api 0.7.6 (0.7.7 available)
+ typed_data 1.4.0
+ url_launcher 6.3.2
+ url_launcher_android 6.3.17
+ url_launcher_ios 6.3.4
+ url_launcher_linux 3.2.1
+ url_launcher_macos 3.2.3
+ url_launcher_platform_interface 2.3.2
+ url_launcher_web 2.4.1
+ url_launcher_windows 3.1.4
+ web 1.1.1
Changed 24 dependencies!
4 packages have newer versions incompatible with dependency constraints.
Try `flutter pub outdated` for more information.

Mit diesem Befehl werden der Anwendung eine Reihe von Paketen hinzugefügt:

  • googleapis: Eine generierte Dart-Bibliothek, die Zugriff auf Google APIs bietet.
  • http: Eine Bibliothek zum Erstellen von HTTP-Anfragen, die die Unterschiede zwischen nativen und Webbrowsern verbirgt.
  • provider: Bietet Statusverwaltung.
  • url_launcher: Über diese Schaltfläche kannst du zu einem Video in einer Playlist springen. Wie aus den aufgelösten Abhängigkeiten hervorgeht, hat url_launcher Implementierungen für Windows, macOS, Linux und das Web zusätzlich zu den Standardimplementierungen für Android und iOS. Wenn Sie dieses Paket verwenden, müssen Sie keine plattformspezifischen Implementierungen für diese Funktion erstellen.
  • flex_color_scheme: Weist der App ein ansprechendes Standardfarbschema zu. Weitere Informationen finden Sie in der flex_color_scheme-API-Dokumentation.
  • go_router: Implementiert die Navigation zwischen den verschiedenen Bildschirmen. Dieses Paket bietet eine praktische, URL-basierte API für die Navigation mit dem Router von Flutter.

Mobile Apps für url_launcher konfigurieren

Für das url_launcher-Plug-in muss die Android- und iOS-Runner-Anwendung konfiguriert werden. Fügen Sie im iOS Flutter-Runner dem plist-Dictionary die folgenden Zeilen 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 zu Manifest.xml hinzu. Fügen Sie diesen queries-Knoten als direkt untergeordnetes Element des manifest-Knotens und als gleichgeordnetes Element des application-Knotens 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 url_launcher-Dokumentation.

Auf die YouTube Data API zugreifen

Wenn Sie über die YouTube Data API auf Playlists zugreifen möchten, um sie aufzulisten, müssen Sie ein API-Projekt erstellen, um die erforderlichen API-Schlüssel zu generieren. Bei diesen Schritten wird davon ausgegangen, dass Sie bereits ein Google-Konto haben. Erstellen Sie also eines, falls Sie noch keines haben.

Rufen Sie die Developer Console auf, um ein API-Projekt zu erstellen:

GCP Console während der Projekterstellung

Wenn Sie ein Projekt haben, rufen Sie die Seite „API-Bibliothek“ auf. Geben Sie im Suchfeld „youtube“ ein und wählen Sie die YouTube Data API v3 aus.

YouTube Data API v3 in der GCP Console auswählen

Aktivieren Sie die API auf der Detailseite der YouTube Data API v3.

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. Sie werden diesen Schlüssel gleich verwenden.

Das Pop-up-Fenster „API-Schlüssel erstellt“ mit dem erstellten API-Schlüssel

Code hinzufügen

Im weiteren Verlauf dieses Schritts werden Sie viel Code kopieren und einfügen, um eine mobile App zu erstellen. Der Code wird dabei nicht kommentiert. In diesem Codelab geht es darum, eine mobile App für Computer und das Web anzupassen. Eine ausführlichere Einführung in die Entwicklung von Flutter-Apps für Mobilgeräte finden Sie unter Ihre erste Flutter-App.

Fügen Sie die folgenden Dateien hinzu, zuerst das Statusobjekt 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ügen Sie als Nächstes die Seite mit den einzelnen Playlist-Details 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ügen Sie als Nächstes die Liste der Playlists 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(),
              );
            },
          ),
        );
      },
    );
  }
}

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).toTheme,
      darkTheme: FlexColorScheme.dark(scheme: FlexScheme.red).toTheme,
      themeMode: ThemeMode.dark, // Or ThemeMode.System if you'd prefer
      debugShowCheckedModeBanner: false,
      routerConfig: _router,
    );
  }
}

Sie sind fast bereit, diesen Code unter Android und iOS auszuführen. Sie müssen nur noch die Konstante youTubeApiKey durch den YouTube API-Schlüssel ersetzen, der im vorherigen Schritt generiert wurde.

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 ihr erlauben, HTTP-Anfragen zu senden. Gehen Sie dazu so vor: 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

Nachdem Sie eine vollständige Anwendung haben, sollte sie sich erfolgreich in einem Android-Emulator oder iPhone-Simulator ausführen lassen. Wenn du eine Playlist auswählst, wird eine Liste mit den Playlists von Flutter angezeigt. Wenn du auf eine Playlist klickst, werden die Videos in dieser Playlist angezeigt. Wenn du schließlich auf die Schaltfläche „Wiedergabe“ klickst, wirst du zu YouTube weitergeleitet, um das Video anzusehen.

Die App mit den Playlists für den YouTube-Kanal „FlutterDev“

Videos in einer bestimmten Playlist anzeigen

Ein ausgewähltes Video wird im YouTube-Player wiedergegeben.

Wenn Sie diese App jedoch auf dem Desktop ausführen, wird das Layout in einem normalen Fenster in Desktopgröße nicht richtig dargestellt. Im nächsten Schritt sehen Sie sich an, wie Sie sich daran anpassen können.

5. An den Desktop anpassen

Das Desktop-Problem

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

Die App unter macOS zeigt eine Liste von Playlists an, die seltsam proportioniert ist.

Videos in einer Playlist unter macOS

Eine Lösung für dieses Problem ist, eine geteilte Ansicht hinzuzufügen, in der die Playlists links und die Videos rechts aufgeführt sind. Dieses Layout soll jedoch nur verwendet werden, wenn der Code nicht unter Android oder iOS ausgeführt wird und das Fenster breit genug ist. In der folgenden Anleitung wird beschrieben, wie Sie diese Funktion implementieren.

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

$ flutter pub add split_view
Resolving dependencies...
Downloading packages...
  characters 1.4.0 (1.4.1 available)
  material_color_utilities 0.11.1 (0.13.0 available)
  meta 1.16.0 (1.17.0 available)
+ split_view 3.2.1
  test_api 0.7.6 (0.7.7 available)
Changed 1 dependency!
4 packages have newer versions incompatible with dependency constraints.
Try `flutter pub outdated` for more information.

Adaptive Widgets

In diesem Codelab verwenden Sie das Muster, adaptive Widgets einzuführen, die Implementierungsentscheidungen basierend auf Attributen wie Bildschirmbreite und Plattformdesign treffen. In diesem Fall führen Sie ein AdaptivePlaylists-Widget ein, das die Interaktion zwischen Playlists und PlaylistDetails neu gestaltet. 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).toTheme,
      darkTheme: FlexColorScheme.dark(scheme: FlexScheme.red).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 (mit MediaQuery.of(context).size.width) als auch das Theme (mit Theme.of(context).platform) verwendet, um zu entscheiden, ob ein breites Layout mit dem SplitView-Widget oder ein schmales Layout ohne das Widget angezeigt werden soll.

Zweitens geht es in diesem Abschnitt um die fest codierte Verarbeitung der Navigation. Es wird ein Callback-Argument im Playlists-Widget angezeigt. Dieser Callback benachrichtigt den umgebenden Code, dass der Nutzer eine Playlist ausgewählt hat. Der Code muss dann die Arbeit ausführen, um diese Playlist anzuzeigen. Dadurch ändert sich die Notwendigkeit der Scaffold in den Widgets Playlists und PlaylistDetails. Da sie nicht mehr auf der obersten Ebene sind, müssen Sie das Scaffold aus diesen Widgets entfernen.

Bearbeiten Sie als Nächstes die Datei src/lib/playlists.dart, sodass sie dem folgenden Code entspricht:

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

Diese Datei enthält viele Änderungen. Abgesehen von der oben genannten Einführung eines playlistSelected-Callbacks und der Entfernung des Scaffold-Widgets wird das _PlaylistsListView-Widget von zustandslos in zustandsbehaftet konvertiert. Diese Änderung ist aufgrund der Einführung eines eigenen ScrollController erforderlich, das erstellt und gelöscht werden muss.

Die Einführung von ScrollController ist interessant, da sie erforderlich ist, weil in einem breiten Layout zwei ListView-Widgets nebeneinander platziert werden. Auf einem Mobiltelefon ist es üblich, nur ein ListView zu haben. Daher kann es ein einzelnes langlebiges ScrollController geben, an das alle ListViews während ihres individuellen Lebenszyklus angehängt und von dem sie getrennt werden. Auf dem Desktop ist das anders, da hier mehrere nebeneinander angeordnete ListViews sinnvoll sind.

Bearbeiten Sie schließlich die Datei lib/src/playlist_details.dart 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 beim Playlists-Widget oben wurden in dieser Datei auch Änderungen vorgenommen, um das Scaffold-Widget zu entfernen und ein eigenes ScrollController einzuführen.

Führen Sie die App noch einmal aus.

Sie können die App auf einem beliebigen Desktop ausführen, egal ob Windows, macOS oder Linux. Es sollte jetzt wie erwartet funktionieren.

Die App wird unter macOS in einer geteilten Ansicht ausgeführt.

6. An das Web anpassen

Was hat es mit diesen Bildern auf sich?

Wenn Sie versuchen, diese App im Web auszuführen, wird jetzt angezeigt, dass mehr Arbeit erforderlich ist, um sie an Webbrowser anzupassen.

Die App wird im Chrome-Browser ausgeführt und es werden keine YouTube-Vorschaubilder angezeigt.

Wenn Sie einen Blick in die Debug-Konsole werfen, sehen Sie einen dezenten Hinweis darauf, 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 beheben, besteht darin, einen Proxy-Webdienst einzuführen, um die erforderlichen CORS-Header (Cross-Origin Resource Sharing) hinzuzufügen. Öffnen Sie ein Terminal 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 in das 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...
Downloading packages...
  http 1.5.0 (from dev dependency to direct dependency)
+ shelf_cors_headers 0.1.5
Changed 2 dependencies!

Es gibt eine aktuelle Abhängigkeit, die nicht mehr erforderlich ist. Schneiden Sie das Video so zu:

$ dart pub remove shelf_router
Resolving dependencies...
Downloading packages...
These packages are no longer being depended on:
- http_methods 1.1.1
- shelf_router 1.1.4
Changed 2 dependencies!

Ändern Sie als Nächstes den Inhalt der Datei „server.dart“ so, dass er dem Folgenden entspricht:

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

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

Ein Paar anpassbare Widgets

Das erste der beiden Widgets zeigt, 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);
  }
}

In dieser App wird die Konstante kIsWeb verwendet, da sich die Laufzeitplattformen unterscheiden. Das andere anpassungsfähige Widget ändert die App so, dass sie wie andere Webseiten funktioniert. 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 in der gesamten 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 obigen Code 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 haben Sie nur das Image.network-Widget angepasst, die beiden Text-Widgets aber unverändert gelassen. Das war so beabsichtigt, da die onTap-Funktion von ListTile blockiert wird, wenn der Nutzer auf den Text tippt.

App im Web richtig ausführen

Wenn der CORS-Proxy ausgeführt wird, sollte die Webversion der App in etwa so aussehen:

Die App wird im Chrome-Browser ausgeführt und es werden YouTube-Bildthumbnails angezeigt.

7. Adaptive Authentifizierung

In diesem Schritt erweitern Sie die App, sodass sie den Nutzer authentifizieren und dann die Playlists dieses Nutzers anzeigen kann. 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 auf Android, iOS, im Web, unter Windows, macOS und Linux sehr unterschiedlich erfolgt.

Plug-ins hinzufügen, um die Google-Authentifizierung zu aktivieren

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

$ flutter pub add googleapis_auth google_sign_in \
    extension_google_sign_in_as_googleapis_auth logging
Resolving dependencies...
Downloading packages...
+ args 2.7.0
  characters 1.4.0 (1.4.1 available)
+ crypto 3.0.6
+ extension_google_sign_in_as_googleapis_auth 3.0.0
+ google_identity_services_web 0.3.3+1
+ google_sign_in 7.1.1
+ google_sign_in_android 7.0.3
+ google_sign_in_ios 6.1.0
+ google_sign_in_platform_interface 3.0.0
+ google_sign_in_web 1.0.0
+ googleapis_auth 2.0.0
  logging 1.3.0 (from transitive dependency to direct dependency)
  material_color_utilities 0.11.1 (0.13.0 available)
  meta 1.16.0 (1.17.0 available)
  test_api 0.7.6 (0.7.7 available)
Changed 11 dependencies!
4 packages have newer versions incompatible with dependency constraints.
Try `flutter pub outdated` for more information.

Verwenden Sie zur Authentifizierung unter Windows, macOS und Linux das Paket googleapis_auth. Auf diesen Desktop-Plattformen erfolgt die Authentifizierung über einen Webbrowser. Verwenden Sie für die 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 Interop-Shim zwischen den beiden Paketen.

Code aktualisieren

Beginnen Sie mit dem Update, indem Sie eine neue wiederverwendbare Abstraktion erstellen: das AdaptiveLogin-Widget. Dieses Widget ist für die Wiederverwendung konzipiert und erfordert daher eine Konfiguration:

lib/src/adaptive_login.dart

import 'dart:async';
import 'dart:io' show Platform;

import 'package:extension_google_sign_in_as_googleapis_auth/extension_google_sign_in_as_googleapis_auth.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:googleapis_auth/auth_io.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/link.dart';

import 'app_state.dart';

final _log = Logger('AdaptiveLogin');

typedef _AdaptiveLoginButtonWidget =
    Widget Function({required VoidCallback? onPressed});

class AdaptiveLogin extends StatelessWidget {
  const AdaptiveLogin({
    super.key,
    required this.clientId,
    required this.scopes,
    required this.loginButtonChild,
  });

  final ClientId clientId;
  final List<String> scopes;
  final Widget loginButtonChild;

  @override
  Widget build(BuildContext context) {
    if (kIsWeb || Platform.isAndroid || Platform.isIOS) {
      return _GoogleSignInLogin(button: _loginButton, scopes: scopes);
    } else {
      return _GoogleApisAuthLogin(
        button: _loginButton,
        scopes: scopes,
        clientId: clientId,
      );
    }
  }

  Widget _loginButton({required VoidCallback? onPressed}) =>
      ElevatedButton(onPressed: onPressed, child: loginButtonChild);
}

class _GoogleSignInLogin extends StatefulWidget {
  const _GoogleSignInLogin({required this.button, required this.scopes});

  final _AdaptiveLoginButtonWidget button;
  final List<String> scopes;

  @override
  State<_GoogleSignInLogin> createState() => _GoogleSignInLoginState();
}

class _GoogleSignInLoginState extends State<_GoogleSignInLogin> {
  @override
  initState() {
    super.initState();
    _googleSignIn = GoogleSignIn.instance;
    _googleSignIn.initialize();
    _authEventsSubscription = _googleSignIn.authenticationEvents.listen((
      event,
    ) async {
      _log.fine('Google Sign-In authentication event: $event');
      if (event is GoogleSignInAuthenticationEventSignIn) {
        final googleSignInClientAuthorization = await event
            .user
            .authorizationClient
            .authorizationForScopes(widget.scopes);
        if (googleSignInClientAuthorization == null) {
          _log.warning('Google Sign-In authenticated client creation failed');
          return;
        }
        _log.fine('Google Sign-In authenticated client created');
        final context = this.context;
        if (context.mounted) {
          context.read<AuthedUserPlaylists>().authClient =
              googleSignInClientAuthorization.authClient(scopes: widget.scopes);
          context.go('/');
        }
      }
    });

    // Check if user is already authenticated
    _log.fine('Attempting lightweight authentication');
    _googleSignIn.attemptLightweightAuthentication();
  }

  @override
  dispose() {
    _authEventsSubscription.cancel();
    super.dispose();
  }

  late final GoogleSignIn _googleSignIn;
  late final StreamSubscription _authEventsSubscription;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: widget.button(
          onPressed: () {
            _googleSignIn.authenticate();
          },
        ),
      ),
    );
  }
}

class _GoogleApisAuthLogin extends StatefulWidget {
  const _GoogleApisAuthLogin({
    required this.button,
    required this.scopes,
    required this.clientId,
  });

  final _AdaptiveLoginButtonWidget button;
  final List<String> scopes;
  final ClientId clientId;

  @override
  State<_GoogleApisAuthLogin> createState() => _GoogleApisAuthLoginState();
}

class _GoogleApisAuthLoginState extends State<_GoogleApisAuthLogin> {
  @override
  initState() {
    super.initState();
    clientViaUserConsent(widget.clientId, widget.scopes, (url) {
      setState(() {
        _authUrl = Uri.parse(url);
      });
    }).then((authClient) {
      final context = this.context;
      if (context.mounted) {
        context.read<AuthedUserPlaylists>().authClient = authClient;
        context.go('/');
      }
    });
  }

  Uri? _authUrl;

  @override
  Widget build(BuildContext context) {
    final authUrl = _authUrl;
    if (authUrl != null) {
      return Scaffold(
        body: Center(
          child: Link(
            uri: authUrl,
            builder: (context, followLink) =>
                widget.button(onPressed: followLink),
          ),
        ),
      );
    }

    return const Scaffold(body: Center(child: CircularProgressIndicator()));
  }
}

Diese Datei ist sehr umfangreich. Die Methode build von AdaptiveLogin erledigt den Großteil der Arbeit. Diese Methode ruft die Platform.isXXX von kIsWeb und dart:io auf und prüft die Laufzeitplattform. Für Android, iOS und das Web wird das zustandsbehaftete Widget _GoogleSignInLogin instanziiert. Unter Windows, macOS und Linux wird ein _GoogleApisAuthLogin-Stateful-Widget instanziiert.

Für die Verwendung dieser Klassen ist eine zusätzliche Konfiguration erforderlich, die später erfolgt, nachdem der Rest der Codebasis für die Verwendung dieses neuen Widgets aktualisiert wurde. Benennen Sie zuerst FlutterDevPlaylists in AuthedUserPlaylists um, um den neuen Zweck besser widerzuspiegeln, und aktualisieren Sie den Code so, dass http.Client jetzt nach der Konstruktion übergeben wird. Schließlich 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 bereitgestellte Objekt für den Anwendungsstatus:

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 das Playlists-Widget so:

lib/src/playlists.dart

class Playlists extends StatelessWidget {
  const Playlists({super.key, required this.playlistSelected});

  final PlaylistsListSelected playlistSelected;

  @override
  Widget build(BuildContext context) {
    return Consumer<AuthedUserPlaylists>(               // Update this line
      builder: (context, flutterDev, child) {
        final playlists = flutterDev.playlists;
        if (playlists.isEmpty) {
          return const Center(child: CircularProgressIndicator());
        }

        return _PlaylistsListView(
          items: playlists,
          playlistSelected: playlistSelected,
        );
      },
    );
  }
}

Aktualisieren Sie schließlich die Datei main.dart, um das neue AdaptiveLogin-Widget richtig zu verwenden:

lib/main.dart

// Drop dart:io import

import 'package:flex_color_scheme/flex_color_scheme.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:googleapis_auth/googleapis_auth.dart'; // Add this line
import 'package:provider/provider.dart';

import 'src/adaptive_login.dart';                      // Add this line
import 'src/adaptive_playlists.dart';
import 'src/app_state.dart';
import 'src/playlist_details.dart';

// Drop flutterDevAccountId and youTubeApiKey

// Add from this line
// From https://developers.google.com/youtube/v3/guides/auth/installed-apps#identify-access-scopes
final scopes = ['https://www.googleapis.com/auth/youtube.readonly'];

// TODO: Replace with your Client ID and Client Secret for Desktop configuration
final clientId = ClientId(
  'TODO-Client-ID.apps.googleusercontent.com',
  'TODO-Client-secret',
);
// To this line

final _router = GoRouter(
  routes: <RouteBase>[
    GoRoute(
      path: '/',
      builder: (context, state) {
        return const AdaptivePlaylists();
      },
      // Add redirect configuration
      redirect: (context, state) {
        if (!context.read<AuthedUserPlaylists>().isLoggedIn) {
          return '/login';
        } else {
          return null;
        }
      },
      // To this line
      routes: <RouteBase>[
        // Add new login Route
        GoRoute(
          path: 'login',
          builder: (context, state) {
            return AdaptiveLogin(
              clientId: clientId,
              scopes: scopes,
              loginButtonChild: const Text('Login to YouTube'),
            );
          },
        ),
        // To this line
        GoRoute(
          path: 'playlist/:id',
          builder: (context, state) {
            final title = state.uri.queryParameters['title']!;
            final id = state.pathParameters['id']!;
            return Scaffold(
              appBar: AppBar(title: Text(title)),
              body: PlaylistDetails(playlistId: id, playlistName: title),
            );
          },
        ),
      ],
    ),
  ],
);

void main() {
  runApp(
    ChangeNotifierProvider<AuthedUserPlaylists>(       // Modify this line
      create: (context) => AuthedUserPlaylists(),      // Modify this line
      child: const PlaylistsApp(),
    ),
  );
}

class PlaylistsApp extends StatelessWidget {
  const PlaylistsApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'Your Playlists',                         // Change FlutterDev to Your
      theme: FlexColorScheme.light(scheme: FlexScheme.red).toTheme,
      darkTheme: FlexColorScheme.dark(scheme: FlexScheme.red).toTheme,
      themeMode: ThemeMode.dark,                       // Or ThemeMode.System
      debugShowCheckedModeBanner: false,
      routerConfig: _router,
    );
  }
}

Die Änderungen in dieser Datei spiegeln die Umstellung von der Anzeige der YouTube-Wiedergabelisten von Flutter auf die Anzeige der Wiedergabelisten des authentifizierten Nutzers wider. Der Code ist jetzt zwar vollständig, aber es sind noch einige Änderungen an dieser Datei und den Dateien in den jeweiligen Runner-Apps erforderlich, um die Pakete google_sign_in und googleapis_auth für die Authentifizierung richtig zu konfigurieren.

In der App werden jetzt YouTube-Playlists des authentifizierten Nutzers angezeigt. Nachdem Sie die Funktionen fertiggestellt haben, müssen Sie die Authentifizierung aktivieren. Konfigurieren Sie dazu die Pakete google_sign_in und googleapis_auth. Wenn Sie die Pakete konfigurieren möchten, müssen Sie die Datei main.dart und die Dateien für die Runner-Apps ändern.

googleapis_auth konfigurieren

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

Die Seite „Anmeldedaten“ des API-Projekts in der GCP Console

Dadurch wird ein Dialogfeld geöffnet, das Sie mit der Schaltfläche „Löschen“ bestätigen:

Das Pop-up „Anmeldedaten löschen“

Erstellen Sie dann eine OAuth-Client-ID:

OAuth-Client-ID erstellen

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

Auswahl des Anwendungstyps „Desktop-App“

Übernehmen Sie den Namen und klicken Sie auf Erstellen.

Client-ID benennen

Dadurch werden die Client-ID und der Clientschlüssel erstellt, die Sie lib/main.dart hinzufügen müssen, um den googleapis_auth-Ablauf zu konfigurieren. Ein wichtiges Implementierungsdetail ist, dass der googleapis_auth-Ablauf einen temporären Webserver verwendet, der auf localhost ausgeführt wird, um das generierte OAuth-Token zu erfassen. Unter macOS ist dazu 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 diese Änderung nicht an der Datei macos/Runner/DebugProfile.entitlements vornehmen, da sie bereits eine Berechtigung für com.apple.security.network.server hat, um Hot Reload und die Dart VM-Debugging-Tools zu aktivieren.

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

Die App, in der die Playlists für den angemeldeten Nutzer angezeigt werden

google_sign_in für Android konfigurieren

Kehren Sie zur Seite „Anmeldedaten“ Ihres API-Projekts zurück 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 Anleitung genau befolgt haben, sollte com.example.adaptive_app angezeigt werden. Extrahieren Sie den SHA-1-Zertifikatsfingerabdruck anhand der Anleitung auf der Hilfeseite zur Google Cloud Console:

Android-Client-ID benennen

Das reicht aus, damit die App auf Android funktioniert. Je nachdem, welche Google-APIs Sie verwenden, müssen Sie möglicherweise die generierte JSON-Datei Ihrem Anwendungsbundle hinzufügen.

App unter Android ausführen

google_sign_in für iOS konfigurieren

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

iOS-Anwendungstyp auswählen

Füllen Sie den Rest des Formulars aus. Öffnen Sie dazu ios/Runner.xcworkspace in Xcode. Rufen Sie den Project Navigator auf, wählen Sie den Runner im Navigator aus, wählen Sie dann den Tab „General“ (Allgemein) aus und kopieren Sie die Bundle-ID. Wenn Sie dieses Codelab Schritt für Schritt durchgearbeitet haben, sollte es com.example.adaptiveApp sein.

Geben Sie für den Rest des Formulars die Bundle-ID ein. Öffnen Sie ios/Runner.xcworkspace in Xcode. Rufen Sie den Projektnavigator auf. Gehe zu „Runner“ > Tab „Allgemein“. Kopieren Sie die Bundle-ID. Wenn Sie dieses Codelab Schritt für Schritt durchgearbeitet haben, sollte der Wert com.example.adaptiveApp sein.

Wo finde ich die Bundle-ID in Xcode?

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

iOS-Client-ID benennen

Laden Sie die generierte Datei .plist herunter. Der Name basiert auf der generierten Client-ID. Benennen Sie die heruntergeladene Datei in GoogleService-Info.plist um und ziehen Sie sie dann in den laufenden Xcode-Editor, neben die Datei Info.plist unter Runner/Runner im Navigator auf der linken Seite. Wählen Sie im Optionsdialogfeld in Xcode bei Bedarf Copy items (Elemente kopieren), Create folder references (Ordnerreferenzen erstellen) und Add to the Runner target (Zum Runner-Ziel hinzufügen) aus.

Die generierte PLIST-Datei in Xcode zur iOS-App hinzufügen

Beenden Sie Xcode und fügen Sie 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 Running App für iOS

google_sign_in für das Web konfigurieren

Kehren Sie zur Seite „Anmeldedaten“ Ihres API-Projekts zurück und erstellen Sie eine weitere OAuth-Client-ID. Wählen Sie diesmal jedoch Webanwendung aus:

Webanwendungstyp auswählen

Füllen Sie den Rest des Formulars so aus:

Client-ID für Webanwendung benennen

Dadurch wird eine Client-ID generiert. Fügen Sie web/index.html das folgende meta-Tag hinzu, das die generierte Client-ID enthält:

web/index.html

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

Für die Ausführung dieses Beispiels ist etwas Unterstützung erforderlich. Sie müssen den CORS-Proxy, den Sie im vorherigen Schritt erstellt haben, und die Flutter-Webanwendung auf dem Port ausführen, der im Formular für die OAuth-Client-ID der Webanwendung angegeben ist. Folgen Sie dazu der Anleitung unten.

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

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

Führen Sie die Flutter-App in einem anderen Terminal so 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".

Nachdem Sie sich noch einmal angemeldet haben, sollten Ihre Playlists angezeigt werden:

Die App, die im Chrome-Browser ausgeführt wird

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 Unterschiede bei der Darstellung von Bildschirmen, der Interaktion mit Text, dem Laden von Bildern und der Authentifizierung zu berücksichtigen.

Es gibt noch viele weitere Dinge, die Sie in Ihren Anwendungen anpassen können. Weitere Informationen dazu, wie Sie Ihren Code an verschiedene Umgebungen anpassen können, in denen er ausgeführt wird, finden Sie unter Adaptive Apps entwickeln.