Aplikacje adaptacyjne w technologii Flutter

Aplikacje adaptacyjne w Flutter

Informacje o tym ćwiczeniu (w Codelabs)

subjectOstatnia aktualizacja: cze 3, 2025
account_circleAutorzy: Brett Morgan

1. Wprowadzenie

Flutter to udostępniane przez Google narzędzie do tworzenia interfejsu użytkownika, które umożliwia tworzenie atrakcyjnych aplikacji kompilowanych natywnie na urządzenia mobilne, komputery i internet na podstawie pojedynczej bazy kodu. W tym laboratorium programistycznym dowiesz się, jak tworzyć aplikacje Flutter, które dostosowują się do platformy, na której są uruchomione, np. Androida, iOS, przeglądarki, systemu Windows, macOS lub Linuxa.

  • Jak rozwinąć aplikację Flutter zaprojektowaną na urządzenia mobilne, aby działała na wszystkich 6 platformach obsługiwanych przez Flutter.
  • różne interfejsy Fluttera do wykrywania platformy i okoliczności, w których należy ich używać;
  • dostosowanie się do ograniczeń i oczekiwań związanych z uruchamianiem aplikacji w internecie;
  • Jak używać różnych pakietów obok siebie, aby obsługiwać pełny zakres platform Flutter.

Co utworzysz

W tym laboratorium kodu najpierw utworzysz aplikację Flutter na Androida i iOS, która pozwoli Ci zapoznać się z playlistami w YouTube. Następnie dostosujesz tę aplikację do pracy na 3 platformach komputerowych (Windows, macOS i Linux), modyfikując sposób wyświetlania informacji w zależności od rozmiaru okna aplikacji. Następnie dostosujesz aplikację do przeglądarki, sprawiając, że tekst wyświetlany w aplikacji będzie można wybrać, tak jak tego oczekują użytkownicy przeglądarek. Na koniec dodaj uwierzytelnianie do aplikacji, aby móc przeglądać własne playlisty, a nie te utworzone przez zespół Flutter, który wymaga różnych podejść do uwierzytelniania na Androidzie, iOS i w internecie w porównaniu z trzema platformami komputerowymi: Windows, macOS i Linux.

Oto zrzut ekranu aplikacji Flutter na Androida i iOS:

Gotowa aplikacja działająca w emulatorze Androida

Gotowa aplikacja działająca w symulatorze iOS

Aplikacja uruchomiona w trybie pełnoekranowym w systemie macOS powinna wyglądać jak na zrzucie ekranu poniżej.

Gotowa aplikacja działająca na macOS

Ten warsztat programistyczny dotyczy przekształcania aplikacji mobilnej Flutter w aplikację dostosowującą się do różnych urządzeń, która działa na wszystkich 6 platformach Flutter. Nieistotne koncepcje i bloki kodu zostały zamaskowane. Można je po prostu skopiować i wkleić.

Czego chcesz się nauczyć z tego Codelab?

2. Konfigurowanie środowiska programistycznego Flutter

Do wykonania tego ćwiczenia potrzebne są 2 programy: Flutter SDKedytor.

Możesz uruchomić laboratorium programistyczne na dowolnym z tych urządzeń:

  • fizyczne urządzenie Android lub iOS połączone z komputerem i ustawione w trybie programisty.
  • Symulator iOS (wymaga zainstalowania narzędzi Xcode).
  • Emulator Androida (wymaga skonfigurowania w Android Studio).
  • przeglądarka (do debugowania wymagana jest przeglądarka Chrome);
  • Jako aplikacja na komputer z systemem Windows, Linux lub macOS. Musisz tworzyć aplikację na platformie, na której planujesz ją wdrożyć. Jeśli więc chcesz tworzyć aplikacje na komputery z systemem Windows, musisz to robić w systemie Windows, aby mieć dostęp do odpowiedniego łańcucha kompilacji. Istnieją wymagania dotyczące poszczególnych systemów operacyjnych, które omówiono szczegółowo na stronie docs.flutter.dev/desktop.

3. Rozpocznij

Potwierdzanie środowiska programistycznego

Najprostszym sposobem sprawdzenia, czy wszystko jest gotowe do programowania, jest uruchomienie tego polecenia:

flutter doctor

Jeśli coś jest wyświetlane bez znacznika wyboru, wykonaj te czynności, aby uzyskać więcej informacji o problemie:

flutter doctor -v

W przypadku tworzenia aplikacji na urządzenia mobilne lub komputery może być konieczna instalacja narzędzi dla programistów. Więcej informacji o konfigurowaniu narzędzi w zależności od systemu operacyjnego hosta znajdziesz w dokumentacji Fluttera.

Tworzenie projektu Flutter

Aby zacząć pisać aplikacje na komputery za pomocą Fluttera, możesz użyć narzędzia wiersza poleceń Fluttera do utworzenia projektu Fluttera. Możesz też użyć interfejsu użytkownika w swoim środowisku IDE, aby utworzyć projekt Flutter.

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

Aby sprawdzić, czy wszystko działa, uruchom szablon aplikacji Flutter jako aplikację mobilną, jak pokazano poniżej. Możesz też otworzyć ten projekt w swoim środowisku IDE i uruchomić aplikację za pomocą narzędzi tego środowiska. Dzięki poprzedniemu krokowi uruchamianie jako aplikacja na komputerze powinno być jedyną dostępną opcją.

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

Aplikacja powinna się uruchomić. Treść wymaga aktualizacji.

Aby zaktualizować zawartość, zaktualizuj kod w sekcji lib/main.dart, używając kodu poniżej. Aby zmienić wyświetlane treści w aplikacji, wykonaj jej gorące ponowne wczytanie.

  • Jeśli uruchamiasz aplikację za pomocą wiersza poleceń, wpisz r w konsoli, aby wykonać gorące załadowanie.
  • Jeśli uruchomisz aplikację w IDE, po zapisaniu pliku zostanie ona ponownie załadowana.

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

Aplikacja została zaprojektowana tak, aby pomóc Ci zrozumieć, jak można wykrywać różne platformy i jak je dostosowywać. Oto aplikacja działająca natywnie na Androidzie i iOS:

Wyświetlanie właściwości okna w emulatorze Androida

Wyświetlanie właściwości okna w symulatorze iOS

A tutaj ten sam kod działa natywnie w systemie macOS i w Chrome, również w systemie macOS.

Wyświetlanie właściwości okna w systemie macOS

Wyświetlanie właściwości okna w przeglądarce Chrome

Należy pamiętać, że na pierwszy rzut oka Flutter robi, co w sprawie dostosowania treści do wyświetlacza, na którym działa, co w ogóle jest możliwe. Laptop, na którym zostały zrobione te zrzuty ekranu, ma wyświetlacz Maca o wysokiej rozdzielczości, dlatego zarówno wersja na macOS, jak i wersja internetowa aplikacji są renderowane z użyciem współczynnika Device Pixel Ratio 2. W przypadku iPhone’a 12 współczynnik ten wynosi 3, a w przypadku Pixela 2 – 2,63. W każdym przypadku wyświetlany tekst jest mniej więcej taki sam, co znacznie ułatwia nam pracę.

Druga kwestia, na którą należy zwrócić uwagę, to fakt, że obie opcje sprawdzania, na której platformie działa kod, dają różne wyniki. Pierwsza opcja sprawdza obiekt Platform zaimportowany z dart:io, a druga (dostępna tylko w ramach metody build widgeta) pobiera obiekt Theme z argumentu BuildContext.

Te dwie metody zwracają różne wyniki, ponieważ mają inny cel. Obiekt Platform zaimportowany z dart:io służy do podejmowania decyzji niezależnych od wyborów dotyczących renderowania. Przykładem może być decyzja o tym, których wtyczek użyć. Mogą one, ale nie muszą, pasować do natywnych implementacji na danej platformie fizycznej.

Wyodrębnienie ThemeBuildContext jest przeznaczone do podejmowania decyzji dotyczących implementacji, które są skoncentrowane na temacie. Przykładem może być decyzja, czy użyć suwaka Material Design czy suwaka Cupertino, jak opisano w artykule Slider.adaptive.

W następnej sekcji utworzysz podstawową aplikację do przeglądania playlist w YouTube, która będzie zoptymalizowana tylko pod kątem Androida i iOS. W następnych sekcjach dodasz różne ustawienia, aby aplikacja działała lepiej na komputerach i w przeglądarce.

4. Tworzenie aplikacji mobilnej

Dodawanie pakietów

W tej aplikacji użyjesz różnych pakietów Fluttera, aby uzyskać dostęp do interfejsu YouTube Data API, zarządzania stanem i nieco tematyki.

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

To polecenie dodaje do aplikacji kilka pakietów:

  • googleapis: wygenerowana biblioteka Dart, która zapewnia dostęp do interfejsów API Google.
  • http: biblioteka do tworzenia żądań HTTP, która ukrywa różnice między przeglądarkami natywnymi i internetowymi.
  • provider: umożliwia zarządzanie stanem.
  • url_launcher: umożliwia przejście do filmu z playlisty. Jak widać na podstawie rozwiązanych zależności, url_launcher ma implementacje dla systemów Windows, macOS, Linux i WWW, a także domyślne implementacje dla systemów Android i iOS. Korzystanie z tego pakietu oznacza, że nie musisz tworzyć platformy dostosowanej do tej funkcji.
  • flex_color_scheme: nadaje aplikacji ładną domyślną kolorystykę. Więcej informacji znajdziesz w dokumentacji interfejsu API flex_color_scheme.
  • go_router: implementuje nawigację między różnymi ekranami. Ten pakiet udostępnia wygodne API oparte na adresie URL do nawigacji za pomocą modułu Router w Flutterze.

Konfigurowanie aplikacji mobilnych na potrzeby usługi url_launcher

Wtyczka url_launcher wymaga skonfigurowania aplikacji wykonawczych na Androida i iOS. W uruchamiaczu Fluttera na iOS dodaj te wiersze do słownika plist.

ios/Runner/Info.plist

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

W uruchomionym programie Flutter na Androida dodaj te wiersze do pliku Manifest.xml. Dodaj ten węzeł queries jako bezpośredni element podrzędny węzła manifest i równorzędny węzła application.

android/app/src/main/AndroidManifest.xml

<queries>
   
<intent>
       
<action android:name="android.intent.action.VIEW" />
       
<data android:scheme="https" />
   
</intent>
   
<intent>
       
<action android:name="android.intent.action.DIAL" />
       
<data android:scheme="tel" />
   
</intent>
   
<intent>
       
<action android:name="android.intent.action.SEND" />
       
<data android:mimeType="*/*" />
   
</intent>
</queries>

Więcej informacji o wymaganych zmianach w konfiguracji znajdziesz w dokumentacji url_launcher.

Dostęp do interfejsu YouTube Data API

Aby uzyskać dostęp do interfejsu YouTube Data API w celu wyświetlenia listy playlist, musisz utworzyć projekt interfejsu API, aby wygenerować wymagane klucze interfejsu API. Te czynności są oparte na założeniu, że masz już konto Google. Jeśli nie, utwórz je.

Otwórz Konsolę programistów, aby utworzyć projekt interfejsu API:

Wyświetlanie konsoli GCP podczas procesu tworzenia projektu

Gdy masz już projekt, otwórz stronę Biblioteka interfejsów API. W polu wyszukiwania wpisz „youtube” i wybierz YouTube Data API v3.

Wybieranie interfejsu YouTube Data API 3 w konsoli GCP

Na stronie z informacjami o interfejsie YouTube Data API w wersji 3 włącz interfejs API.

5a877ea82b83ae42.png

Po włączeniu interfejsu API otwórz stronę Dane logowania i utwórz klucz interfejsu API.

Tworzenie danych logowania w konsoli GCP

Po kilku sekundach powinno pojawić się okno z nowym kluczem API. Wkrótce użyjesz tego klucza.

Popup „Utworzono klucz interfejsu API” z wyświetlonym kluczem

Dodaj kod

W dalszej części tego kroku będziesz wklejać dużo kodu, aby zbudować aplikację mobilną, bez komentarzy na temat kodu. Celem tego ćwiczenia jest przekształcenie aplikacji mobilnej tak, aby działała na komputerach i w internecie. Więcej informacji o tworzeniu aplikacji mobilnych w Flutter znajdziesz w artykule Twoja pierwsza aplikacja Flutter.

Dodaj te pliki: najpierw obiekt stanu aplikacji.

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

Następnie dodaj stronę z informacjami o pojedynczej playliście.

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

Następnie dodaj listę playlist.

lib/src/playlists.dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:provider/provider.dart';

import 'app_state.dart';

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

 
@override
 
Widget build(BuildContext context) {
   
return Scaffold(
     
appBar: AppBar(title: const Text('FlutterDev Playlists')),
     
body: Consumer<FlutterDevPlaylists>(
       
builder: (context, flutterDev, child) {
         
final playlists = flutterDev.playlists;
         
if (playlists.isEmpty) {
           
return const Center(child: CircularProgressIndicator());
         
}

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

class _PlaylistsListView extends StatelessWidget {
 
const _PlaylistsListView({required this.items});

 
final List<Playlist> items;

 
@override
 
Widget build(BuildContext context) {
   
return ListView.builder(
     
itemCount: items.length,
     
itemBuilder: (context, index) {
       
var playlist = items[index];
       
return Padding(
         
padding: const EdgeInsets.all(8.0),
         
child: ListTile(
           
leading: Image.network(
             
playlist.snippet!.thumbnails!.default_!.url!,
           
),
           
title: Text(playlist.snippet!.title!),
           
subtitle: Text(playlist.snippet!.description!),
           
onTap: () {
             
context.go(
               
Uri(
                 
path: '/playlist/${playlist.id}',
                 
queryParameters: <String, String>{
                   
'title': playlist.snippet!.title!,
                 
},
               
).toString(),
             
);
           
},
         
),
       
);
     
},
   
);
 
}
}

Zastąp zawartość pliku main.dart następującymi wartościami:

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

Prawie wszystko jest gotowe do uruchomienia tego kodu na Androidzie i iOS. Trzeba jeszcze zmienić stałą youTubeApiKey na klucz interfejsu API YouTube wygenerowany w poprzednim kroku.

lib/main.dart

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

Aby uruchomić tę aplikację w systemie macOS, musisz umożliwić jej wysyłanie żądań HTTP w ten sposób: Zmodyfikuj pliki DebugProfile.entitlements i Release.entitilements w ten sposób:

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>

Uruchamianie aplikacji

Teraz, gdy masz już gotową aplikację, powinna ona działać w emulatorze Androida lub symulatorze iPhone’a. Zobaczysz listę playlist Fluttera. Po wybraniu playlisty zobaczysz filmy z niej. Gdy klikniesz przycisk Odtwórz, otworzy się YouTube, gdzie będzie można obejrzeć film.

Aplikacja z playlistami na koncie YouTube FlutterDev

wyświetlanie filmów na konkretnej playliście.

Wybrany film odtwarzany w odtwarzaczu YouTube

Jeśli jednak spróbujesz uruchomić tę aplikację na komputerze, zobaczysz, że układ jest nieprawidłowy, gdy zostanie rozszerzony do normalnego rozmiaru okna na komputerze. W następnym kroku dowiesz się, jak to zrobić.

5. Dostosowywanie do komputera

Problem z komputerem

Jeśli uruchomisz aplikację na jednej z natywnych platform komputerowych (Windows, macOS lub Linux), zauważysz ciekawy problem. Działa, ale wygląda… dziwnie.

Aplikacja działająca na macOS z dziwnie wyglądającą listą playlist

filmy na playliście w systemie macOS.

Rozwiązaniem jest dodanie widoku podzielonego, w którym po lewej stronie znajdują się playlisty, a po prawej filmy. Chcesz jednak, aby ten układ był aktywny tylko wtedy, gdy kod nie jest uruchamiany na Androidzie lub iOS i okno jest wystarczająco szerokie. Z podanych niżej instrukcji dowiesz się, jak wdrożyć tę funkcję.

Najpierw dodaj pakiet split_view, aby ułatwić sobie tworzenie układu.

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

Wprowadzanie widżetów dostosowujących się do ekranu

W tym samouczku zapoznasz się z wzorcem, który polega na wprowadzeniu dostosowywanych widżetów, które dokonują wyborów implementacji na podstawie atrybutów takich jak szerokość ekranu, motyw platformy itp. W tym przypadku wprowadzisz widżet AdaptivePlaylists, który zmienia sposób interakcji elementów Playlists i PlaylistDetails. Zmień plik lib/main.dart w ten sposób:

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

Następnie utwórz plik dla widżetu AdaptivePlaylist:

lib/src/adaptive_playlists.dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:split_view/split_view.dart';

import 'playlist_details.dart';
import 'playlists.dart';

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

 
@override
 
Widget build(BuildContext context) {
   
final screenWidth = MediaQuery.of(context).size.width;
   
final targetPlatform = Theme.of(context).platform;

   
if (targetPlatform == TargetPlatform.android ||
       
targetPlatform == TargetPlatform.iOS ||
       
screenWidth <= 600) {
     
return const NarrowDisplayPlaylists();
   
} else {
     
return const WideDisplayPlaylists();
   
}
 
}
}

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

 
@override
 
Widget build(BuildContext context) {
   
return Scaffold(
     
appBar: AppBar(title: const Text('FlutterDev Playlists')),
     
body: Playlists(
       
playlistSelected: (playlist) {
         
context.go(
           
Uri(
             
path: '/playlist/${playlist.id}',
             
queryParameters: <String, String>{
               
'title': playlist.snippet!.title!,
             
},
           
).toString(),
         
);
       
},
     
),
   
);
 
}
}

class WideDisplayPlaylists extends StatefulWidget {
 
const WideDisplayPlaylists({super.key});

 
@override
 
State<WideDisplayPlaylists> createState() => _WideDisplayPlaylistsState();
}

class _WideDisplayPlaylistsState extends State<WideDisplayPlaylists> {
 
Playlist? selectedPlaylist;
 
@override
 
Widget build(BuildContext context) {
   
return Scaffold(
     
appBar: AppBar(
       
title: switch (selectedPlaylist?.snippet?.title) {
         
String title => Text('FlutterDev Playlist: $title'),
         
_ => const Text('FlutterDev Playlists'),
       
},
     
),
     
body: SplitView(
       
viewMode: SplitViewMode.Horizontal,
       
children: [
         
Playlists(
           
playlistSelected: (playlist) {
             
setState(() {
               
selectedPlaylist = playlist;
             
});
           
},
         
),
         
switch ((selectedPlaylist?.id, selectedPlaylist?.snippet?.title)) {
           
(String id, String title) => PlaylistDetails(
             
playlistId: id,
             
playlistName: title,
           
),
           
_ => const Center(child: Text('Select a playlist')),
         
},
       
],
     
),
   
);
 
}
}

Ten plik jest interesujący z kilku powodów. Po pierwsze, używa ona zarówno szerokości okna (za pomocą MediaQuery.of(context).size.width), jak i tego, co sprawdzasz w ramach motywu (za pomocą Theme.of(context).platform), aby zdecydować, czy wyświetlić szeroki układ z widżetem SplitView, czy wąski bez niego.

Po drugie, ta sekcja dotyczy zakodowanego na stałe sposobu obsługi nawigacji. Wyświetla argument wywołania zwrotnego w widżecie Playlists. Ten wywołanie zwrotne powiadamia kod, że użytkownik wybrał playlistę. Następnie kod musi wykonać zadanie wyświetlenia tej playlisty. W związku z tym nie będzie już potrzebny parametr Scaffold w widżetach PlaylistsPlaylistDetails. Ponieważ nie są już najwyższego poziomu, musisz usunąć z nich Scaffold.

Następnie zmień plik src/lib/playlists.dart, aby odpowiadał temu kodowi:

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

W tym pliku jest dużo zmian. Oprócz wspomnianego wprowadzenia funkcji playlistSelected callback i usunięcia widżetu Scaffold widżet _PlaylistsListView został przekształcony z bezstanowego na stanowy. Ta zmiana jest wymagana ze względu na wprowadzenie własności ScrollController, która musi być tworzona i niszczona.

Wprowadzenie ScrollController jest interesujące, ponieważ jest wymagane, ponieważ w szerokim układzie masz obok siebie 2 widżety ListView. Na telefonie komórkowym zwykle jest tylko 1 ListView, więc może być 1 długotrwały ScrollController, do którego wszystkie ListView się przyłączają i odłączają podczas swojego cyklu życia. Na komputerach jest inaczej, ponieważ w tym przypadku wiele ListView obok siebie ma sens.

Na koniec zmodyfikuj plik lib/src/playlist_details.dart w ten sposób:

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

Podobnie jak w przypadku widżetu Playlists, ten plik zawiera zmiany polegające na usunięciu widżetu Scaffold i wprowadzeniu widżetu ScrollController należącego do właściciela.

Uruchom aplikację ponownie.

Uruchomienie aplikacji na komputerze z systemem Windows, macOS lub Linux. Teraz powinno działać zgodnie z oczekiwaniami.

Aplikacja działająca w systemie macOS w widoku podzielonego ekranu

6. Dostosowywanie do internetu

Co się stało z tymi obrazami?

Próba uruchomienia tej aplikacji w internecie ujawnia, że potrzebne są dalsze prace nad dostosowaniem aplikacji do przeglądarek internetowych.

Aplikacja działająca w przeglądarce Chrome bez miniatur obrazów z YouTube

Jeśli zajrzysz do konsoli debugowania, zobaczysz delikatną podpowiedź, co należy zrobić dalej.

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

Tworzenie serwera proxy CORS

Jednym ze sposobów rozwiązania problemów z renderowaniem obrazów jest wprowadzenie usługi internetowej proxy, która doda wymagane nagłówki CORS. Otwórz terminal i utwórz serwer WWW Dart w ten sposób:

$ 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

Zmień katalog na serwer yt_cors_proxy i dodaj kilka wymaganych zależności:

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

Niektóre obecne zależności nie są już wymagane. Przytnij je w ten sposób:

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

Następnie zmień zawartość pliku server.dart, aby odpowiadała tym danym:

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

Ten serwer możesz uruchomić w ten sposób:

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

Możesz też skompilować go jako obraz Dockera i uruchomić uzyskany obraz Dockera w ten sposób:

$ 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

Następnie zmodyfikuj kod Fluttera, aby korzystać z serwera proxy CORS, ale tylko podczas uruchamiania w przeglądarce.

Widżety, które można dostosować

Pierwszy z tych widżetów określa, jak aplikacja będzie korzystać z serwera proxy CORS.

lib/src/adaptive_image.dart

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';

class AdaptiveImage extends StatelessWidget {
 
AdaptiveImage.network(String url, {super.key}) {
   
if (kIsWeb) {
     
_url = Uri.parse(
       
url,
     
).replace(host: 'localhost', port: 8080, scheme: 'http').toString();
   
} else {
     
_url = url;
   
}
 
}

 
late final String _url;

 
@override
 
Widget build(BuildContext context) {
   
return Image.network(_url);
 
}
}

Ta aplikacja używa stałej kIsWeb ze względu na różnice w środowisku uruchomieniowym. Drugi elastyczny widżet zmienia aplikację tak, aby działała jak inne strony internetowe. Użytkownicy przeglądarki oczekują, że tekst będzie można zaznaczyć.

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

Teraz rozpowszechnij te zmiany w całym kodzie:

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

W powyższym kodzie dostosowano widżety Image.networkText. Następnie dostosuj widżet Playlists.

lib/src/playlists.dart

import 'package:flutter/material.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:provider/provider.dart';

import 'adaptive_image.dart';                                 // Add this line
import 'app_state.dart';

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

 
final PlaylistsListSelected playlistSelected;

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

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

typedef PlaylistsListSelected = void Function(Playlist playlist);

class _PlaylistsListView extends StatefulWidget {
 
const _PlaylistsListView({
   
required this.items,
   
required this.playlistSelected,
 
});

 
final List<Playlist> items;
 
final PlaylistsListSelected playlistSelected;

 
@override
 
State<_PlaylistsListView> createState() => _PlaylistsListViewState();
}

class _PlaylistsListViewState extends State<_PlaylistsListView> {
 
late ScrollController _scrollController;

 
@override
 
void initState() {
   
super.initState();
   
_scrollController = ScrollController();
 
}

 
@override
 
void dispose() {
   
_scrollController.dispose();
   
super.dispose();
 
}

 
@override
 
Widget build(BuildContext context) {
   
return ListView.builder(
     
controller: _scrollController,
     
itemCount: widget.items.length,
     
itemBuilder: (context, index) {
       
var playlist = widget.items[index];
       
return Padding(
         
padding: const EdgeInsets.all(8.0),
         
child: ListTile(
           
leading: AdaptiveImage.network(                   // Change this one.
             
playlist.snippet!.thumbnails!.default_!.url!,
           
),
           
title: Text(playlist.snippet!.title!),
           
subtitle: Text(playlist.snippet!.description!),
           
onTap: () {
             
widget.playlistSelected(playlist);
           
},
         
),
       
);
     
},
   
);
 
}
}

Tym razem dostosowałaś tylko widżet Image.network, a widżety Text zostawiłaś bez zmian. Zrobiliśmy to celowo, ponieważ jeśli dostosujesz widżety tekstowe, funkcja ListTile zostanie zablokowana, gdy użytkownik kliknie tekst.onTap

prawidłowo uruchomić aplikację w przeglądarce;

Gdy serwer proxy CORS jest uruchomiony, powinna się uruchomić wersja internetowa aplikacji, która będzie wyglądać mniej więcej tak:

Aplikacja działająca w przeglądarce Chrome z miniaturami obrazów z YouTube

7. Uwierzytelnianie adaptacyjne

W tym kroku rozszerzysz aplikację, nadając jej możliwość uwierzytelniania użytkownika i wyświetlania jego playlist. Aby obsługiwać różne platformy, na których może działać aplikacja, musisz użyć wielu wtyczek, ponieważ obsługa protokołu OAuth różni się w przypadku Androida, iOS, przeglądarki, systemu Windows, macOS i Linuxa.

Dodawanie wtyczek w celu włączenia uwierzytelniania Google

Zainstaluj 3 pakiety, aby obsługiwać uwierzytelnianie Google.

$ flutter pub add googleapis_auth google_sign_in \
    extension_google_sign_in_as_googleapis_auth
Resolving dependencies...
+ args 2.4.2
+ crypto 3.0.3
+ extension_google_sign_in_as_googleapis_auth 2.0.12
+ google_identity_services_web 0.3.0+2
+ google_sign_in 6.2.1
+ google_sign_in_android 6.1.21
+ google_sign_in_ios 5.7.2
+ google_sign_in_platform_interface 2.4.4
+ google_sign_in_web 0.12.3+2
+ googleapis_auth 1.4.1
+ js 0.6.7 (0.7.0 available)
  matcher 0.12.16 (0.12.16+1 available)
  material_color_utilities 0.5.0 (0.8.0 available)
  meta 1.10.0 (1.11.0 available)
  path 1.8.3 (1.9.0 available)
  test_api 0.6.1 (0.7.0 available)
  web 0.3.0 (0.4.0 available)
Changed 11 dependencies!
7 packages have newer versions incompatible with dependency constraints.
Try `flutter pub outdated` for more information.

Aby uwierzytelniać się w systemach Windows, macOS i Linux, użyj pakietu googleapis_auth. Te platformy komputerowe uwierzytelniają się za pomocą przeglądarki internetowej. Aby uwierzytelniać użytkowników na Androidzie, iOS i w przeglądarce, użyj pakietów google_sign_in i extension_google_sign_in_as_googleapis_auth. Drugi pakiet pełni funkcję interoperacyjności między tymi dwoma pakietami.

Aktualizowanie kodu

Zacznij od utworzenia nowej abstrakcji do wielokrotnego użytku, czyli widżetu AdaptiveLogin. Ten widżet jest przeznaczony do wielokrotnego użytku, dlatego wymaga skonfigurowania:

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

Ten plik robi wiele rzeczy. Całą pracę wykonuje metoda buildAdaptiveLogin. Ta metoda sprawdza platformę czasu wykonywania, ponieważ wywołuje metodę Platform.isXXX zarówno w kIsWeb, jak i w dart:io. W przypadku Androida, iOS i internetu instancjuje widżet _GoogleSignInLogin z stanem. W systemach Windows, macOS i Linux instancjuje widżet _GoogleApisAuthLogin z stanem.

Aby używać tych klas, musisz przeprowadzić dodatkową konfigurację, która nastąpi później, po zaktualizowaniu reszty kodu źródłowego. Zacznij od zmiany nazwy FlutterDevPlaylists na AuthedUserPlaylists, aby lepiej odzwierciedlić nowy cel tego elementu, i zaktualizuj kod, aby uwzględnić, że element http.Client jest już skonstruowany. Na koniec: klasa _ApiKeyClient nie jest już wymagana:

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

Następnie zaktualizuj widżet PlaylistDetails, podając nową nazwę obiektu stanu aplikacji:

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

W podobny sposób zaktualizuj widżet Playlists:

lib/src/playlists.dart

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

  final PlaylistsListSelected playlistSelected;

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

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

Na koniec zaktualizuj plik main.dart, aby prawidłowo używać nowego widżetu AdaptiveLogin:

lib/main.dart

// Drop dart:io import

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

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

// Drop flutterDevAccountId and youTubeApiKey

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

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

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

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

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

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

Zmiany w tym pliku odzwierciedlają zmianę z wyświetlania tylko playlist Fluttera w YouTube na wyświetlanie playlist uwierzytelnionego użytkownika. Chociaż kod jest już kompletny, nadal trzeba wprowadzić w nim i w plikach w odpowiednich aplikacjach Runner kilka zmian, aby prawidłowo skonfigurować pakiety google_sign_ingoogleapis_auth na potrzeby uwierzytelniania.

Aplikacja wyświetla teraz playlisty YouTube uwierzytelnionego użytkownika. Po skonfigurowaniu funkcji musisz włączyć uwierzytelnianie. Aby to zrobić, skonfiguruj pakiety google_sign_ingoogleapis_auth. Aby skonfigurować pakiety, musisz zmienić plik main.dart i pliki aplikacji Runner.

Skonfiguruj googleapis_auth

Pierwszym krokiem w konfigurowaniu uwierzytelniania jest usunięcie klucza interfejsu API skonfigurowanego i używanego wcześniej. Otwórz stronę danych logowania projektu interfejsu API i usuń klucz interfejsu API:

strona danych logowania projektu interfejsu API w konsoli GCP.

Spowoduje to wyświetlenie okna, które potwierdzasz, klikając przycisk Usuń:

Wyskakujące okienko Usuń dane logowania

Następnie utwórz identyfikator klienta OAuth:

Tworzenie identyfikatora klienta OAuth

W polu Typ aplikacji wybierz Aplikacja na komputer.

Wybieranie typu aplikacji na komputer

Zaakceptuj nazwę i kliknij Utwórz.

Nazywanie identyfikatora klienta

Spowoduje to utworzenie identyfikatora klienta i tajnego klucza klienta, które musisz dodać do lib/main.dart, aby skonfigurować przepływ googleapis_auth. Ważnym szczegółem implementacji jest to, że przepływ googleapis_auth używa tymczasowego serwera internetowego działającego na localhost, aby przechwycić wygenerowany token OAuth. W przypadku systemu macOS wymaga to modyfikacji pliku macos/Runner/Release.entitlements:

macos/Runner/Release.entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
        <dict>
                <key>com.apple.security.app-sandbox</key>
                <true/>
                <!-- add the following two lines -->
                <key>com.apple.security.network.server</key>
                <true/>
                <key>com.apple.security.network.client</key>
                <true/>
        </dict>
</plist>

Nie musisz wprowadzać tej zmiany w pliku macos/Runner/DebugProfile.entitlements, ponieważ ma on już uprawnienia do com.apple.security.network.server, które umożliwiają korzystanie z funkcji Hot Reload i narzędzi do debugowania Dart VM.

Aplikacja powinna teraz działać w systemach Windows, macOS i Linux (jeśli została skompilowana na te platformy).

Aplikacja wyświetlająca playlisty dla zalogowanego użytkownika

Konfigurowanie google_sign_in na urządzeniu z Androidem

Wróć na stronę danych logowania projektu interfejsu API i utwórz kolejny identyfikator klienta OAuth, ale tym razem wybierz Android:.

Wybieranie typu aplikacji na Androida

W pozostałych polach formularza wpisz nazwę pakietu zgodnie z deklaracją w pliku android/app/src/main/AndroidManifest.xml. Jeśli postępujesz zgodnie z instrukcjami, powinno być to com.example.adaptive_app. Wyodrębnij odcisk cyfrowy certyfikatu SHA-1, korzystając z instrukcji na stronie Pomocy konsoli Google Cloud:

Nazwa identyfikatora klienta na Androida

To wystarczy, aby aplikacja działała na Androidzie. W zależności od wybranych interfejsów API Google może być konieczne dodanie wygenerowanego pliku JSON do pakietu aplikacji.

Uruchamianie aplikacji na Androidzie

Konfigurowanie google_sign_in na iOS

Wróć na stronę danych logowania projektu interfejsu API i utwórz kolejny identyfikator klienta OAuth, ale tym razem wybierz iOS:.

Wybieranie typu aplikacji na iOS

W pozostałych polach formularza wpisz identyfikator pakietu, otwierając ios/Runner.xcworkspace w Xcode. Otwórz Nawigatora projektu, wybierz w nim Runnera, a następnie kliknij kartę Ogólne i skopiuj identyfikator pakietu. Jeśli kroki w tym ćwiczeniu zostały wykonane prawidłowo, powinna to być wartość com.example.adaptiveApp.

W pozostałych polach formularza wpisz identyfikator pakietu. Otwórz ios/Runner.xcworkspace w Xcode. Otwórz nawigator projektów. Otwórz kartę Runner > Ogólne. Skopiuj identyfikator pakietu. Jeśli kroki w tym laboratorium kodu zostały wykonane prawidłowo, wartość powinna wynosić com.example.adaptiveApp.

Gdzie znaleźć identyfikator pakietu w Xcode

Na razie zignoruj identyfikator App Store i identyfikator zespołu, ponieważ nie są one wymagane do tworzenia aplikacji na komputerze:

Nazwa identyfikatora klienta na iOS

Pobierz wygenerowany plik .plist. Jego nazwa jest tworzona na podstawie wygenerowanego identyfikatora klienta. Zmień nazwę pobranego pliku na GoogleService-Info.plist, a potem przeciągnij go do działającego edytora Xcode, obok pliku Info.plist w sekcji Runner/Runner w panelu nawigacyjnym po lewej stronie. W oknie opcji w Xcode wybierz w razie potrzeby Kopiuj elementy, Utwórz odwołania do folderówDodaj do Runnera.

Dodawanie wygenerowanego pliku plist do aplikacji na iOS w Xcode

Zamknij Xcode, a potem w wybranym środowisku IDE dodaj do pliku Info.plist:

ios/Runner/Info.plist

<key>CFBundleURLTypes</key>
<array>
       
<dict>
               
<key>CFBundleTypeRole</key>
               
<string>Editor</string>
               
<key>CFBundleURLSchemes</key>
               
<array>
                       
<!-- TODO Replace this value: -->
                       
<!-- Copied from GoogleService-Info.plist key REVERSED_CLIENT_ID -->
                       
<string>com.googleusercontent.apps.TODO-REPLACE-ME</string>
               
</array>
       
</dict>
</array>

Musisz zmienić wartość, aby odpowiadała wpisowi w wygenerowanym pliku GoogleService-Info.plist. Uruchom aplikację i po zalogowaniu się sprawdź, czy widzisz swoje playlisty.

Uruchomiona aplikacja na iOS

Konfigurowanie google_sign_in w internecie

Wróć na stronę danych logowania projektu interfejsu API i utwórz kolejny identyfikator klienta OAuth, ale tym razem wybierz Aplikacja internetowa:

Wybieranie typu aplikacji internetowej

W pozostałych polach formularza wpisz autoryzowane źródła JavaScriptu w ten sposób:

Nazwa identyfikatora klienta aplikacji internetowej

Spowoduje to wygenerowanie identyfikatora klienta. Dodaj do tagu meta tag web/index.html, zaktualizowany tak, aby zawierał wygenerowany identyfikator klienta:

web/index.html

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

Uruchomienie tego przykładu wymaga trochę pomocy. Musisz uruchomić serwer pośredniczący CORS utworzony w poprzednim kroku i uruchomić aplikację internetową Flutter na porcie określonym w formularzu identyfikatora klienta usługi internetowej OAuth, korzystając z podanych niżej instrukcji.

W jednym terminalu uruchom serwer CORS Proxy w ten sposób:

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

W innym terminalu uruchom aplikację Flutter w ten sposób:

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

Po ponownym zalogowaniu się powinny się wyświetlić Twoje playlisty:

Aplikacja działająca w przeglądarce Chrome

8. Dalsze kroki

Gratulacje!

Ukończyłeś/ukończyłaś ćwiczenie i utworzyłeś/utworzyłaś dostosowaną do potrzeb użytkownika aplikację Flutter, która działa na wszystkich 6 platformach obsługiwanych przez Fluttera. Kod został dostosowany do obsługi różnic w rozmieszczeniu ekranów, sposobie interakcji z tekstem, sposobie wczytywania obrazów i działaniu uwierzytelniania.

W swoich aplikacjach możesz dostosować jeszcze wiele innych elementów. Aby dowiedzieć się więcej o tym, jak dostosować kod do różnych środowisk, w których będzie on uruchamiany, przeczytaj artykuł Tworzenie aplikacji dostosowujących się do różnych urządzeń.