Adaptive Apps in Flutter

Adaptive Apps in Flutter

Informationen zu diesem Codelab

subjectZuletzt aktualisiert: Juni 3, 2025
account_circleVerfasst von Brett Morgan

1. Einführung

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

Lerninhalte

  • Eine Flutter-App, die für Mobilgeräte entwickelt wurde, so erweitern, dass sie auf allen sechs von Flutter unterstützten Plattformen funktioniert
  • Die verschiedenen Flutter APIs für die Plattformerkennung und wann die jeweilige API verwendet werden sollte.
  • Sie müssen sich an die Einschränkungen und Erwartungen anpassen, die beim Ausführen einer App im Web gelten.
  • So verwenden Sie verschiedene Pakete nebeneinander, um die gesamte Palette der Flutter-Plattformen zu unterstützen.

Aufgaben

In diesem Codelab erstellen Sie zuerst eine Flutter-App für Android und iOS, in der die YouTube-Playlists von Flutter vorgestellt werden. Sie passen diese Anwendung dann so an, dass sie auf den drei Desktop-Plattformen (Windows, macOS und Linux) funktioniert, indem Sie die Darstellung der Informationen an die Größe des Anwendungsfensters anpassen. Anschließend passen Sie die Anwendung für das Web an, indem Sie den in der App angezeigten Text auswählbar machen, wie es Webnutzer erwarten. Zum Schluss fügen Sie der App eine Authentifizierung hinzu, damit Sie Ihre eigenen Playlists ansehen können, im Gegensatz zu denen, die vom Flutter-Team erstellt wurden. Für Android, iOS und das Web sind dabei andere Authentifizierungsmethoden erforderlich als für die drei Desktopplattformen Windows, macOS und Linux.

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

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

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

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

Die fertige App, die unter macOS ausgeführt wird

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 wird nicht genauer eingegangen und entsprechende Codeblöcke können Sie einfach kopieren und einfügen.

Was möchten Sie in diesem Codelab lernen?

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 einem der folgenden Geräte ausführen:

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

3. Jetzt starten

Entwicklungsumgebung bestätigen

Am einfachsten können Sie prüfen, ob alles für die Entwicklung bereit ist, indem Sie den folgenden Befehl ausführen:

flutter doctor

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

flutter doctor -v

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

Flutter-Projekt erstellen

Sie können mit dem Flutter-Befehlszeilentool ein Flutter-Projekt erstellen, um mit der Entwicklung von Flutter-Desktopanwendungen zu beginnen. 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.

Führen Sie die Flutter-Boilerplate-Anwendung als mobile App aus, um sicherzustellen, dass alles funktioniert. Alternativ können Sie dieses Projekt in Ihrer IDE öffnen und die Anwendung mit den 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.

Wenn Sie die Inhalte aktualisieren möchten, ersetzen Sie den Code in lib/main.dart durch den folgenden Code. Wenn Sie ändern möchten, was in Ihrer App angezeigt wird, führen Sie einen Hot Reload aus.

  • Wenn Sie die App über die Befehlszeile ausführen, geben Sie r in die Konsole ein, um sie per Hot Reload neu zu laden.
  • 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 ist die App, die nativ auf Android- und iOS-Geräten ausgeführt wird:

Fenstereigenschaften im Android-Emulator anzeigen

Fenstereigenschaften im iOS-Simulator anzeigen

Und hier ist derselbe Code, der nativ unter macOS und in Chrome ausgeführt wird, ebenfalls unter macOS.

Fenstereigenschaften unter macOS anzeigen

Fenstereigenschaften im Chrome-Browser anzeigen

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

Zweitens: Die beiden Optionen zum Prüfen, auf welcher Plattform der Code ausgeführt wird, führen zu unterschiedlichen Werten. 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.

Die beiden Methoden liefern unterschiedliche Ergebnisse, da sie unterschiedliche Zwecke haben. Das aus dart:io importierte Platform-Objekt soll für Entscheidungen verwendet werden, die unabhängig von den Rendering-Optionen sind. Ein gutes Beispiel hierfür ist die Entscheidung, welche Plug-ins verwendet werden sollen, die möglicherweise mit den nativen Implementierungen für eine bestimmte physische Plattform übereinstimmen oder nicht.

Das Extrahieren der Theme aus der BuildContext ist für entscheidungsorientierte Implementierungen gedacht. Ein gutes Beispiel hierfür ist die Entscheidung, ob der Material- oder der Cupertino-Schieberegler 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 fügen Sie verschiedene Anpassungen hinzu, damit die App auf dem Computer und im Web besser funktioniert.

4. Mobile App erstellen

Pakete hinzufügen

In dieser App verwenden Sie eine Vielzahl von Flutter-Paketen, um auf die YouTube Data API, die Zustandsverwaltung und ein wenig Design zuzugreifen.

$ flutter pub add googleapis http provider url_launcher flex_color_scheme go_router
Resolving dependencies...
Downloading packages...
+ _discoveryapis_commons 1.0.7
+ flex_color_scheme 8.2.0
+ flex_seed_scheme 3.5.1
+ flutter_web_plugins 0.0.0 from sdk flutter
+ go_router 15.1.2
+ googleapis 14.0.0
+ http 1.4.0
+ http_parser 4.1.2
  leak_tracker 10.0.9 (11.0.1 available)
  leak_tracker_flutter_testing 3.0.9 (3.0.10 available)
  leak_tracker_testing 3.0.1 (3.0.2 available)
  lints 5.1.1 (6.0.0 available)
+ logging 1.3.0
  material_color_utilities 0.11.1 (0.12.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.4 (0.7.6 available)
+ typed_data 1.4.0
+ url_launcher 6.3.1
+ url_launcher_android 6.3.16
+ url_launcher_ios 6.3.3
+ url_launcher_linux 3.2.1
+ url_launcher_macos 3.2.2
+ url_launcher_platform_interface 2.3.2
+ url_launcher_web 2.4.1
+ url_launcher_windows 3.1.4
  vector_math 2.1.4 (2.1.5 available)
+ web 1.1.1
Changed 22 dependencies!
8 packages have newer versions incompatible with dependency constraints.
Try `flutter pub outdated` for more information.

Mit diesem Befehl werden der Anwendung mehrere Pakete 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 ausblendet.
  • provider: Bietet Statusverwaltung.
  • url_launcher: Hiermit kannst du in einer Playlist zu einem Video springen. Wie aus den aufgelösten Abhängigkeiten hervorgeht, gibt es für url_launcher neben den Standardimplementierungen für Android und iOS auch Implementierungen für Windows, macOS, Linux und das Web. Wenn Sie dieses Paket verwenden, müssen Sie keine Plattform-spezifischen Funktionen für diese Funktion erstellen.
  • flex_color_scheme: Sie verleiht der App ein schönes Standardfarbschema. 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 Flutter-Router.

Mobile Apps für url_launcher konfigurieren

Für das url_launcher-Plug-in müssen die Android- und iOS-Ausführungsanwendungen konfiguriert werden. Fügen Sie im iOS-Flutter-Runner dem plist-Wörterbuch 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 Peer 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 du auf die YouTube Data API zugreifen und Playlists auflisten möchtest, musst du 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. Falls Sie noch keins haben, erstellen Sie eines.

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

Anzeige der GCP Console während der Projekterstellung

Rufen Sie nach dem Erstellen eines Projekts 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

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

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

Code hinzufügen

Im Rest dieses Schritts schneiden Sie viel Code aus und fügen ihn ein, um eine mobile App zu erstellen, ohne den Code zu kommentieren. In diesem Codelab soll die mobile App sowohl für Computer als auch für das Web angepasst werden. Eine ausführlichere Einführung in die Entwicklung von Flutter-Apps für Mobilgeräte finden Sie unter 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üge als Nächstes die Detailseite der einzelnen 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ü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 auf Android- und iOS-Geräten auszuführen. Es gibt noch eine Änderung vorzunehmen: Ersetzen Sie die Konstante youTubeApiKey durch den im vorherigen Schritt generierten YouTube API-Schlüssel.

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 der App wie unten beschrieben erlauben, HTTP-Anfragen zu senden. 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

Da Sie jetzt eine vollständige Anwendung haben, sollten Sie sie in einem Android-Emulator oder einem iPhone-Simulator ausführen können. Sie sehen eine Liste der Playlists von Flutter. Wenn Sie eine Playlist auswählen, werden die Videos in dieser Playlist angezeigt. Wenn Sie auf die Wiedergabeschaltfläche klicken, werden Sie zu YouTube weitergeleitet, um sich das Video anzusehen.

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

Videos in einer bestimmten Playlist anzeigen

Ein ausgewähltes Video, das im YouTube-Player wiedergegeben wird

Wenn Sie diese App jedoch auf dem Computer ausführen, wirkt das Layout falsch, wenn es in ein normales Fenster in Desktopgröße maximiert wird. Im nächsten Schritt sehen Sie sich Möglichkeiten an, wie Sie sich darauf einstellen können.

5. An den Computer anpassen

Das Desktopproblem

Wenn Sie die App auf einer der nativen Desktopplattformen Windows, macOS oder Linux ausführen, fällt Ihnen ein interessantes Problem auf. Es funktioniert, sieht aber… seltsam aus.

Die App, die unter macOS ausgeführt wird, zeigt eine Liste von Playlists mit merkwürdigen Proportionen an

Videos in einer Playlist unter macOS

Eine Lösung dafür ist die Splitscreen-Ansicht, in der die Playlists links und die Videos rechts angezeigt werden. Dieses Layout soll jedoch nur dann aktiviert werden, wenn der Code nicht auf Android- oder iOS-Geräten 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...
  leak_tracker 10.0.9 (11.0.1 available)
  leak_tracker_flutter_testing 3.0.9 (3.0.10 available)
  leak_tracker_testing 3.0.1 (3.0.2 available)
  lints 5.1.1 (6.0.0 available)
  material_color_utilities 0.11.1 (0.12.0 available)
  meta 1.16.0 (1.17.0 available)
+ split_view 3.2.1
  test_api 0.7.4 (0.7.6 available)
  vector_math 2.1.4 (2.1.5 available)
Changed 1 dependency!
8 packages have newer versions incompatible with dependency constraints.
Try `flutter pub outdated` for more information.

Einführung von adaptiven Widgets

In diesem Codelab verwenden Sie das Muster für adaptive Widgets, bei dem die Implementierung anhand von Attributen wie Bildschirmbreite und Plattformthema ausgewählt wird. In diesem Fall führen Sie ein AdaptivePlaylists-Widget ein, mit dem die Interaktion zwischen Playlists und PlaylistDetails neu gestaltet wird. Bearbeiten Sie die Datei lib/main.dartso:

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

Erstelle 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. Erstens wird die Breite des Fensters (MediaQuery.of(context).size.width) verwendet und Sie prüfen das Design (Theme.of(context).platform), um zu entscheiden, ob ein breites Layout mit dem SplitView-Widget oder ein schmales Display ohne SplitView angezeigt werden soll.

Zweitens geht es in diesem Abschnitt um die hartcodierte Navigation. Es wird ein Callback-Argument im Playlists-Widget angezeigt. Über diesen Callback wird der umgebende Code darüber informiert, dass der Nutzer eine Playlist ausgewählt hat. Der Code muss dann die Arbeit ausführen, um diese Playlist anzuzeigen. Das ändert die Notwendigkeit für die Scaffold in den Playlists- und PlaylistDetails-Widgets. Da sie nicht mehr auf oberster Ebene sind, müssen Sie die Scaffold aus diesen Widgets entfernen.

Bearbeiten Sie als Nächstes die Datei src/lib/playlists.dart so, dass 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);
           
},
         
),
       
);
     
},
   
);
 
}
}

In dieser Datei gibt es viele Änderungen. Neben der Einführung des bereits erwähnten playlistSelected-Callbacks und der Entfernung des Scaffold-Widgets wird das _PlaylistsListView-Widget von einem zustandslosen zu einem zustandsabhängigen Widget konvertiert. Diese Änderung ist aufgrund der Einführung einer eigenen ScrollController erforderlich, die erstellt und gelöscht werden muss.

Die Einführung eines ScrollController ist interessant, weil es erforderlich ist, da in einem breiten Layout zwei ListView-Widgets nebeneinander angezeigt werden. Auf einem Smartphone gibt es normalerweise nur eine ListView. Daher kann es auch nur eine langlebige ScrollController geben, an die sich alle ListView während ihres individuellen Lebenszyklus anhängen und von der sie sich wieder lösen. Auf dem Computer ist das anders, in einer Welt, in der mehrere ListView nebeneinander sinnvoll sind.

Bearbeiten Sie abschließend 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 enthält auch diese Datei Änderungen, durch die das Scaffold-Widget entfernt und ein eigenes ScrollController eingeführt wird.

Starten Sie die App noch einmal.

Die App kann auf einem beliebigen Desktop-Betriebssystem ausgeführt werden, z. B. Windows, macOS oder Linux. Es sollte jetzt wie erwartet funktionieren.

Die App wird unter macOS im Splitscreen-Modus ausgeführt

6. An das Web anpassen

Was ist mit diesen Bildern los?

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 sind keine YouTube-Bild-Thumbnails zu sehen.

In der Debug-Konsole finden 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, die Probleme beim Bildrendering zu beheben, besteht darin, einen Proxy-Webdienst einzurichten, um die erforderlichen CORS-Header hinzuzufügen. Öffnen Sie ein Terminal und erstellen Sie einen Dart-Webserver so:

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

Einige aktuelle Abhängigkeiten sind nicht mehr erforderlich. Schneiden Sie sie so 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“ so, dass er Folgendes enthält:

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

So führen Sie diesen Server aus:

$ 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 so, dass dieser CORS-Proxy verwendet wird, aber nur, wenn er in einem Webbrowser ausgeführt wird.

Zwei anpassbare Widgets

Das erste Widget gibt an, 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 aufgrund von Unterschieden bei der Laufzeitplattform die Konstante kIsWeb verwendet. Das andere anpassbare 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),
   
};
 
}
}

Übertragen Sie diese Anpassungen jetzt 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 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 ist beabsichtigt, da die onTap-Funktion von ListTile blockiert wird, wenn der Nutzer auf den Text tippt.

App im Web korrekt ausführen

Wenn der CORS-Proxy ausgeführt wird, sollten Sie die Webansicht der App starten können. Sie sollte in etwa so aussehen:

Die App wird im Chrome-Browser ausgeführt und es sind YouTube-Bild-Thumbnails zu sehen.

7. Adaptive Authentifizierung

In diesem Schritt erweitern Sie die App, damit 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 OAuth-Verwaltung zwischen Android, iOS, Web, Windows, macOS und Linux sehr unterschiedlich ist.

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
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 das Paket googleapis_auth, um sich unter Windows, macOS und Linux zu authentifizieren. Auf diesen Desktop-Plattformen erfolgt die Authentifizierung über einen Webbrowser. Verwenden Sie die Pakete google_sign_in und extension_google_sign_in_as_googleapis_auth, um sich unter Android, iOS und im Web zu authentifizieren. Das zweite Paket dient als Interoperabilitäts-Shims zwischen den beiden Paketen.

Code aktualisieren

Beginnen Sie mit dem Update, indem Sie eine neue wiederverwendbare Abstraktion erstellen: das AdaptiveLogin-Widget. Dieses Widget kann wiederverwendet werden 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) {
         
final context = this.context;
         
if (authClient != null && context.mounted) {
           
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) {
     
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 hat viele Funktionen. Die build-Methode von AdaptiveLogin übernimmt den Großteil der Arbeit. Durch das Aufrufen von Platform.isXXX von kIsWeb und dart:io wird die Laufzeitplattform geprüft. Für Android, iOS und das Web wird das _GoogleSignInLogin-Widget mit Status erstellt. Unter Windows, macOS und Linux wird ein _GoogleApisAuthLogin-Widget mit Status 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. Beginnen Sie damit, die FlutterDevPlaylists in AuthedUserPlaylists umzubenennen, um ihren neuen Zweck besser widerzuspiegeln. Aktualisieren Sie dann den Code, damit die http.Client nach dem Erstellen ü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 bereitgestellte 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 auch das Playlists-Widget:

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 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).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 Änderung wider, dass nicht mehr nur die YouTube-Playlists von Flutter angezeigt werden, sondern die Playlists des authentifizierten Nutzers. Der Code ist jetzt zwar fertig, aber an dieser Datei und an den Dateien in den jeweiligen Runner-Apps sind noch einige Änderungen erforderlich, um die google_sign_in- und googleapis_auth-Pakete für die Authentifizierung richtig zu konfigurieren.

Die App zeigt jetzt YouTube-Playlists des authentifizierten Nutzers an. Nachdem Sie die Funktionen eingerichtet haben, 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-Apps ändern.

googleapis_auth konfigurieren

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

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

Daraufhin wird ein Dialogfeld geöffnet, das Sie durch Klicken auf die 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.

Anwendungstyp „Desktop-App“ auswählen

Bestätigen 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-Vorgang zu konfigurieren. Ein wichtiges Implementierungsdetail ist, dass der googleapis_auth-Vorgang einen temporären Webserver verwendet, der auf localhost ausgeführt wird, um das generierte OAuth-Token zu erfassen. Unter macOS ist 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 Android aus.

Android-Anwendungstyp auswählen

Geben Sie im 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 es com.example.adaptive_app sein. Extrahieren Sie den SHA-1-Zertifikatsfingerabdruck gemäß der Anleitung in der Google Cloud Console-Hilfe:

Android-Client-ID benennen

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

App unter Android ausführen

google_sign_in für iOS konfigurieren

Kehren Sie zur Anmeldedatenseite Ihres API-Projekts zurück und erstellen Sie eine weitere OAuth-Client-ID. Wählen Sie diesmal iOS aus.

iOS-Anwendungstyp auswählen

Füllen Sie den Rest des Formulars aus. Öffnen Sie dazu ios/Runner.xcworkspace in Xcode und geben Sie die Bundle-ID ein. Rufen Sie den Projektnavigator auf, wählen Sie im Navigator den Runner und dann den Tab „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 im Rest des Formulars die Bundle-ID ein. Öffnen Sie ios/Runner.xcworkspace in Xcode. Rufen Sie den Projektnavigator auf. Gehen Sie zu „Läufer“ > Tab „Allgemein“. Kopieren Sie die Bundle-ID. Wenn Sie diesem Codelab Schritt für Schritt gefolgt sind, 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 .plist-Datei herunter. Der Name der Datei basiert auf Ihrer generierten Client-ID. Benennen Sie die heruntergeladene Datei in GoogleService-Info.plist um und ziehen Sie sie in den laufenden Xcode-Editor neben die Datei Info.plist unter Runner/Runner im linken Navigationsbereich. Wählen Sie im Optionsdialogfeld in Xcode bei Bedarf Copy items (Elemente kopieren), Create folder references (Ordnerverweise erstellen) und Add to the Runner (Ziel zum Runner hinzufügen) aus.

Die generierte plist-Datei der iOS-App in Xcode hinzufügen

Schließen Sie Xcode und fügen Sie in Ihrer bevorzugten IDE 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ühre die App aus und melde dich an. Deine Playlists sollten dann angezeigt werden.

Die laufende App unter 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 Webanwendung aus.

Webanwendungstyp auswählen

Füllen Sie den Rest des Formulars so aus:

Client-ID der Webanwendung benennen

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

web/index.html

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

Das Ausführen dieses Beispiels erfordert etwas Einarbeitung. Sie müssen den im vorherigen Schritt erstellten CORS-Proxy ausführen und die Flutter-Webanwendung gemäß der folgenden Anleitung auf dem im Formular für die OAuth-Client-ID der Webanwendung angegebenen Port ausführen.

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

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

Führen Sie in einem anderen Terminal die Flutter-App 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 du dich noch einmal angemeldet hast, 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 so angepasst, dass Unterschiede in der Bildschirmanordnung, der Interaktion mit Text, dem Laden von Bildern und der Authentifizierung berücksichtigt werden.

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