Aplikacje adaptacyjne w technologii Flutter

1. Wprowadzenie

Flutter to opracowany przez Google zestaw narzędzi interfejsu do tworzenia pięknych, natywnie skompilowanych aplikacji na urządzenia mobilne, komputery i komputery przy użyciu jednej bazy kodu. Dzięki temu ćwiczeniu w Codelabs dowiesz się, jak stworzyć aplikację Flutter, która dostosowuje się do platformy, na której działa – czy jest to internet, iOS, Android, Windows, macOS czy Linux.

Czego się nauczysz

  • Jak rozwinąć aplikację Flutter przeznaczoną na urządzenia mobilne, aby działała na wszystkich 6 platformach obsługiwanych przez Flutter.
  • Różne interfejsy Flutter API do wykrywania platform i kiedy używać każdego z nich.
  • Dostosowanie 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 ramach tego ćwiczenia w programie utworzysz aplikację Flutter na Androida i iOS, która będzie przeglądać playlisty Flutter w YouTube. Dostosuj tę aplikację, aby działała na 3 platformach stacjonarnych (Windows, macOS i Linux) przez zmianę sposobu wyświetlania informacji w zależności od rozmiaru okna aplikacji. Następnie dostosujesz aplikację do internetu, ustawiając możliwość wyboru tekstu wyświetlanego w aplikacji zgodnie z oczekiwaniami użytkowników internetu. Na koniec musisz dodać do aplikacji funkcję uwierzytelniania, aby umożliwić Ci przeglądanie własnych playlist w odróżnieniu od tych utworzonych przez zespół Flutter, które wymagają innego podejścia do uwierzytelniania na urządzeniach z Androidem, iOS i w internecie niż na 3 platformach komputerowych (Windows, macOS i Linux).

Oto zrzut ekranu aplikacji Flutter na Androida i iOS:

Gotowa aplikacja uruchomiona w emulatorze Androida

Gotowe działanie aplikacji uruchomionej w symulatorze iOS

Ta aplikacja działająca w trybie panoramicznym w systemie macOS powinna wyglądać mniej więcej tak, jak na zrzucie ekranu poniżej.

Gotowe aplikacje działające w systemie macOS

Skupia się on na przekształceniu aplikacji mobilnej Flutter w aplikację adaptacyjną, 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ę dowiedzieć z tego ćwiczenia z programowania?

Jestem w tym nowym temacie i chcę uzyskać ogólne informacje na ten temat. Wiem coś na ten temat, ale chcę odświeżyć informacje. Szukam przykładowego kodu do użycia w moim projekcie. Potrzebuję wyjaśnienia czegoś konkretnego.

2. Konfigurowanie środowiska programistycznego Flutter

Aby ukończyć ten moduł, potrzebujesz 2 oprogramowania: pakietu SDK Flutter i edytora.

Ćwiczenie z programowania możesz uruchomić na dowolnym z tych urządzeń:

  • Fizyczne urządzenie z Androidem lub iOS podłączone do komputera i ustawione w trybie programisty.
  • Symulator iOS (wymaga zainstalowania narzędzi Xcode).
  • Emulator Androida (wymaga skonfigurowania Android Studio).
  • Przeglądarka (do debugowania wymagany jest Chrome).
  • Aplikacja komputerowa w systemie Windows, Linux lub macOS Programowanie należy tworzyć na platformie, na której zamierzasz wdrożyć usługę. Jeśli więc chcesz opracować aplikację komputerową dla systemu Windows, musisz to zrobić w tym systemie, aby uzyskać dostęp do odpowiedniego łańcucha kompilacji. Istnieją wymagania związane z konkretnymi systemami operacyjnymi, które zostały szczegółowo omówione na stronie docs.flutter.dev/desktop.

3. Rozpocznij

Potwierdzanie środowiska programistycznego

Najłatwiejszym sposobem sprawdzenia, czy wszystko jest gotowe do programowania, jest uruchomienie tego polecenia:

$ flutter doctor

Jeśli cokolwiek wyświetla się bez znacznika wyboru, uruchom te polecenia, by dowiedzieć się więcej o błędach:

$ flutter doctor -v

Może być konieczne zainstalowanie narzędzi dla programistów do tworzenia aplikacji na urządzenia mobilne lub komputery. Więcej informacji o konfigurowaniu narzędzi w zależności od systemu operacyjnego hosta znajdziesz w dokumentacji instalacji Flutter.

Tworzenie projektu Flutter

Prostym sposobem na rozpoczęcie pisania aplikacji Flutter na komputery jest utworzenie projektu Flutter w narzędziu wiersza poleceń Flutter. W Twoim IDE może też być dostępny przepływ pracy do tworzenia projektu Flutter za pomocą jego interfejsu.

$ 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 aplikację Flutter jako aplikację mobilną, jak pokazano poniżej. Możesz też otworzyć ten projekt w swoim IDE i użyć jego narzędzi do uruchomienia aplikacji. Zgodnie z poprzednim krokiem uruchamianie aplikacji 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 być teraz uruchomiona. Trzeba zaktualizować treść.

Aby zaktualizować treść, zaktualizuj kod w sekcji lib/main.dart za pomocą poniższego kodu. Aby zmienić to, co wyświetla się w aplikacji, odśwież stronę z pamięci.

  • Jeśli uruchamiasz aplikację z poziomu wiersza poleceń, wpisz w konsoli r, aby odświeżyć ją z pamięci.
  • Jeśli uruchomisz aplikację przy użyciu IDE, po zapisaniu pliku zostanie ona załadowana ponownie.

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

Powyższa aplikacja daje Ci poczucie, że można wykrywać i dostosowywać różne platformy. Oto aplikacja, która działa natywnie na Androida i iOS:

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

Pokazuję właściwości okna w symulatorze iOS

Ten sam kod działa natywnie w systemie macOS i w Chrome, a ponownie działa w systemie macOS.

Pokazuję właściwości okna w systemie macOS

Pokazuję właściwości okna w przeglądarce Chrome

Warto zauważyć, że na pierwszy rzut oka usługa Flutter robi wszystko, co w jej mocy, aby dostosować treści do wyświetlacza, na którym jest wyświetlana. Laptop, na którym wykonano zrzuty ekranu, ma wyświetlacz Mac o wysokiej rozdzielczości, dlatego zarówno wersja internetowa, jak i macOS, są renderowane ze współczynnikiem liczby pikseli urządzenia wynoszącym 2. Tymczasem na iPhonie 12 widoczny jest współczynnik proporcji 3 i 2,63 na Pixelu 2. We wszystkich przypadkach wyświetlany tekst jest podobny, co znacznie ułatwia pracę programistom.

Drugą istotną kwestią jest to, że 2 opcje sprawdzania, na której platformie działa kod, mają różne wartości. Pierwsza opcja sprawdza obiekt Platform zaimportowany z metody dart:io, a druga (dostępna tylko w metodzie build widżetu) pobiera obiekt Theme z argumentu BuildContext.

Te 2 metody zwracają różne wyniki, ponieważ ich intencje są odmienne. Obiekt Platform zaimportowany z narzędzia dart:io służy do podejmowania decyzji niezależnych od ustawień renderowania. Świetnym przykładem jest tutaj wybór wtyczek, które mogą (ale nie muszą) mieć natywne implementacje pasujące do konkretnej platformy fizycznej.

Wyodrębnienie Theme z BuildContext umożliwia podejmowanie decyzji na podstawie motywu. Świetnym przykładem jest tutaj podjęcie decyzji o użyciu suwaka Materiał lub suwaka Cupertino, jak omówiono w sekcji Slider.adaptive.

W następnej sekcji utworzysz podstawową aplikację Eksplorator playlist w YouTube zoptymalizowaną wyłącznie pod kątem Androida i iOS. W kolejnych sekcjach dodasz różne dostosowania, aby aplikacja działała lepiej na komputerach i w przeglądarce.

4. Tworzenie aplikacji mobilnej

Dodaj pakiety

W tej aplikacji będziesz korzystać z różnych pakietów Flutter, które pozwolą Ci uzyskać dostęp do interfejsu YouTube Data API, a także do zarządzania stanem i różnych motywów.

$ flutter pub add googleapis http provider url_launcher flex_color_scheme go_router
Resolving dependencies... 
Downloading packages... 
+ _discoveryapis_commons 1.0.6
+ flex_color_scheme 7.3.1
+ flex_seed_scheme 1.5.0
+ flutter_web_plugins 0.0.0 from sdk flutter
+ go_router 14.0.1
+ googleapis 13.1.0
+ http 1.2.1
+ http_parser 4.0.2
  leak_tracker 10.0.4 (10.0.5 available)
  leak_tracker_flutter_testing 3.0.3 (3.0.5 available)
+ logging 1.2.0
  material_color_utilities 0.8.0 (0.11.1 available)
  meta 1.12.0 (1.14.0 available)
+ nested 1.0.0
+ plugin_platform_interface 2.1.8
+ provider 6.1.2
  test_api 0.7.0 (0.7.1 available)
+ typed_data 1.3.2
+ url_launcher 6.2.6
+ url_launcher_android 6.3.1
+ url_launcher_ios 6.2.5
+ url_launcher_linux 3.1.1
+ url_launcher_macos 3.1.0
+ url_launcher_platform_interface 2.3.2
+ url_launcher_web 2.3.1
+ url_launcher_windows 3.1.1
+ web 0.5.1
Changed 22 dependencies!
5 packages have newer versions incompatible with dependency constraints.
Try `flutter pub outdated` for more information.

To polecenie dodaje do aplikacji szereg 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 a przeglądarkami.
  • provider: umożliwia zarządzanie stanem.
  • url_launcher: umożliwia przejście do filmu z playlisty. Z rozwiązanych zależności wynika, że oprócz domyślnych zależności dla Androida i iOS url_launcher ma implementacje w systemach Windows, macOS, Linux i w internecie. Dzięki temu pakietowi nie będzie trzeba tworzyć platformy dla tej funkcji.
  • flex_color_scheme: nadaje aplikacji ładny domyślny schemat kolorów. Więcej informacji znajdziesz w dokumentacji interfejsu flex_color_scheme API.
  • go_router: stosuje nawigację między różnymi ekranami. Ten pakiet udostępnia wygodny interfejs API oparty na adresie URL do nawigacji za pomocą routera Flutter.

Konfigurowanie aplikacji mobilnych dla użytkownika url_launcher

Wtyczka url_launcher wymaga skonfigurowania aplikacji uruchamiających na Androida i iOS. W narzędziu biegowym iOS Flutter dodaj następujące 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 narzędziu Android Flutter (Android) dodaj te wiersze do elementu Manifest.xml. Dodaj ten węzeł queries jako bezpośredni element podrzędny węzła manifest i instancję równorzędną 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 mieć dostęp do interfejsu YouTube Data API w celu wyświetlania playlist, musisz utworzyć projekt API, który wygeneruje wymagane klucze API. W poniższych instrukcjach przyjęliśmy, że masz już konto Google. Jeśli nie masz jeszcze konta, utwórz je.

Przejdź do Konsoli programisty, aby utworzyć projekt API:

Wyświetlanie konsoli GCP podczas tworzenia projektu

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

Wybieranie interfejsu YouTube Data API v3 w konsoli GCP

Włącz ten interfejs na stronie ze szczegółami interfejsu YouTube Data API v3.

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 się wyświetlić okno z nowym kluczem interfejsu API. Wkrótce będziesz go używać.

Wyskakujące okienko klucza interfejsu API pokazujące utworzony klucz interfejsu API

Dodaj kod

Pozostała część tego kroku wymaga wklejenia dużej ilości kodu i stworzenie aplikacji mobilnej bez komentarza. Ćwiczenie z programowania ma na celu dostosowanie aplikacji mobilnej zarówno na komputery, jak i na komputery. Bardziej szczegółowe informacje o tworzeniu aplikacji Flutter na urządzenia mobilne znajdziesz w artykułach Pisanie pierwszej aplikacji Flutter, część 1, część 2 oraz Tworzenie atrakcyjnych interfejsów użytkownika za pomocą Flutter.

Dodaj wymienione niżej 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 konkretnej 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 w taki 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/app_state.dart';
import 'src/playlist_details.dart';
import 'src/playlists.dart';

// From https://www.youtube.com/channel/UCwXdFgeE9KYzlDdR7TG9cMw
const flutterDevAccountId = 'UCwXdFgeE9KYzlDdR7TG9cMw';

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

final _router = GoRouter(
  routes: <RouteBase>[
    GoRoute(
      path: '/',
      builder: (context, state) {
        return const Playlists();
      },
      routes: <RouteBase>[
        GoRoute(
          path: 'playlist/:id',
          builder: (context, state) {
            final title = state.uri.queryParameters['title']!;
            final id = state.pathParameters['id']!;
            return PlaylistDetails(
              playlistId: id,
              playlistName: title,
            );
          },
        ),
      ],
    ),
  ],
);

void main() {
  if (youTubeApiKey == 'AIzaNotAnApiKey') {
    print('youTubeApiKey has not been configured.');
    exit(1);
  }

  runApp(ChangeNotifierProvider<FlutterDevPlaylists>(
    create: (context) => FlutterDevPlaylists(
      flutterDevAccountId: flutterDevAccountId,
      youTubeApiKey: youTubeApiKey,
    ),
    child: const PlaylistsApp(),
  ));
}

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

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

Jesteś prawie gotowy do uruchomienia tego kodu na Androidzie i iOS. Jeszcze jedno: zmodyfikuj stałą youTubeApiKey w wierszu 14 za pomocą klucza interfejsu API YouTube wygenerowanego w poprzednim kroku.

lib/main.dart

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

Jeśli chcesz uruchomić tę aplikację w systemie macOS, musisz ją włączyć w celu wysyłania żądań HTTP w podany niżej sposób. Edytuj 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>

Uruchom aplikację

Po ukończeniu aplikacji powinno być możliwe jej uruchomienie w emulatorze Androida lub symulatorze iPhone'a. Zobaczysz listę playlist Flutter. Po wybraniu playlisty zobaczysz znajdujące się na niej filmy. Gdy klikniesz przycisk Odtwórz, otworzy się YouTube, gdzie możesz obejrzeć film.

Aplikacja wyświetlająca playlisty na koncie YouTube FlutterDev

wyświetlanie filmów z konkretnej playlisty,

Wybrany film odtwarzany w odtwarzaczu YouTube

Jeśli jednak spróbujesz uruchomić ją na komputerze, po rozwinięciu do okna o normalnym rozmiarze komputera układ poczuje się niewłaściwie. W następnym kroku dowiesz się, jak dostosować się do tej zmiany.

5. Dostosowywanie do pulpitu

Problem z pulpitem

Jeśli uruchomisz aplikację na jednej z natywnych platform komputerowych, czyli w systemie Windows, macOS lub Linux, zauważysz interesujący problem. Działa, ale wygląda na ... dziwne.

Aplikacja działająca w systemie macOS wyświetla listę playlist, wyglądając dziwnie

Filmy z playlisty w systemie macOS

Rozwiązaniem tego problemu jest dodanie podzielonego widoku, w którym lista playlist znajduje się po lewej stronie, a filmy po prawej. Ten układ ma się jednak uruchamiać tylko wtedy, gdy kod nie jest uruchomiony na Androidzie lub iOS, a okno jest wystarczająco szerokie. Poniżej znajdziesz instrukcje, jak wdrożyć tę funkcję.

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

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

Przedstawiamy widżety adaptacyjne

Wzórem, którego użyjesz w tym ćwiczeniu w programie, będzie wprowadzenie widżetów adaptacyjnych, które dokonują wyboru implementacji na podstawie takich atrybutów jak szerokość ekranu, motyw platformy itp. W tym przypadku zaprezentujesz widżet AdaptivePlaylists, który zmienia sposób interakcji usług Playlists i PlaylistDetails. Zmodyfikuj 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,
        useMaterial3: true,
      ).toTheme,
      darkTheme: FlexColorScheme.dark(
        scheme: FlexScheme.red,
        useMaterial3: true,
      ).toTheme,
      themeMode: ThemeMode.dark, // Or ThemeMode.System if you'd prefer
      debugShowCheckedModeBanner: false,
      routerConfig: _router,
    );
  }
}

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, uwzględnia on zarówno szerokość okna (za pomocą MediaQuery.of(context).size.width), a Ty sprawdzasz motyw (za pomocą Theme.of(context).platform), aby zdecydować, czy wyświetlić szeroki układ z widżetem SplitView czy wąski wyświetlacz bez niego.

Druga sekcja dotyczy zakodowanej na stałe obsługi nawigacji. Wyświetla argument wywołania zwrotnego w widżecie Playlists. To wywołanie zwrotne powiadamia otaczający kod, że użytkownik wybrał playlistę. Kod musi wykonać tę czynność, aby wyświetlić playlistę. Spowoduje to zmianę wymagań dotyczących funkcji Scaffold w widżetach Playlists i PlaylistDetails. Teraz gdy nie są to widżety najwyższego poziomu, musisz usunąć z nich Scaffold.

Następnie zmodyfikuj plik src/lib/playlists.dart w ten sposób:

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 wprowadzono wiele zmian. Oprócz wspomnianego wcześniej wprowadzenia wywołania zwrotnego wyboru playlisty i wyeliminowania widżetu Scaffold widżet _PlaylistsListView zmienia się z bezstanowego na stanowy. Ta zmiana jest wymagana w związku z wprowadzeniem należącego do Ciebie obiektu ScrollController, które musi zostać skonstruowane i zniszczone.

Wprowadzenie elementu ScrollController jest interesujące, ponieważ w przypadku szerokiego układu dwa widżety ListView znajdują się obok siebie. W telefonach komórkowych tradycyjnie używa się jednego elementu ListView, więc może istnieć jeden trwały element przewijany, do którego wszystkie urządzenia ListView podłączają i odłączają w trakcie cyklu życia. W świecie, w którym wiele elementów ListView obok siebie ma sens, warto to zrobić na komputerze.

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 powyższego widżetu Playlists, w tym pliku wprowadzono również zmiany związane z usunięciem widżetu Scaffold i wprowadzeniem należącego do Ciebie elementu ScrollController.

Uruchom aplikację ponownie.

Możesz uruchamiać aplikację na wybranym komputerze (Windows, macOS lub Linux). Teraz wszystko powinno działać zgodnie z oczekiwaniami.

Aplikacja działająca w systemie macOS z widokiem podzielonym

6. Dostosowanie do sieci

O co chodzi z tymi obrazami?

Uruchomienie aplikacji w internecie wiąże się teraz z koniecznością przystosowania się do działania przeglądarek.

aplikacja działająca w przeglądarce Chrome, bez miniatur obrazów z YouTube;

W konsoli debugowania znajdziesz subtelną wskazówkę, co musisz 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 na rozwiązanie problemów z renderowaniem obrazów jest wprowadzenie usługi sieciowej proxy do dodania wymaganych nagłówków udostępniania zasobów na różnych platformach. Wyświetl terminal i utwórz serwer WWW Dart w następujący 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.

Istnieją obecnie zależności, które 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 zmodyfikuj zawartość pliku server.dart tak, aby pasowała do tej:

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

Możesz uruchomić ten serwer w następujący sposób:

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

Możesz też skompilować go jako obraz Dockera, a potem uruchomić utworzony 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 Flutter, by korzystać z serwera proxy CORS, ale tylko wtedy, gdy działasz w przeglądarce.

Para widżetów z możliwością dostosowania

Pierwsza z 2 widżetów dotyczy sposobu, w jaki aplikacja będzie używać 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 z powodu różnic między platformami środowiska wykonawczego. Drugi widżet z możliwością dostosowania zmienia działanie aplikacji w taki sam sposób jak inne strony internetowe. Użytkownicy przeglądarki oczekują możliwości wyboru tekstu.

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 adaptacje w całej bazie kodu:

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 zarówno widżety Image.network, jak i widżety Text. 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łeś tylko widżet Image.network. 2 widżety Text zostały bez zmian. Jest to celowe, ponieważ jeśli dostosujesz widżety tekstowe, funkcja onTap w ListTile zostanie zablokowana po kliknięciu tekstu przez użytkownika.

Prawidłowe uruchomienie aplikacji w sieci

Po uruchomieniu serwera proxy CORS powinno być możliwe uruchomienie aplikacji w internetowej wersji, która powinna wyglądać mniej więcej tak:

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

7. Uwierzytelnianie adaptacyjne

W tym kroku powiększysz aplikację o możliwość uwierzytelnienia użytkownika, a potem wyświetlisz jego playlisty. Konieczne będzie użycie wielu wtyczek, aby obsługiwać różne platformy, na których aplikacja może działać, ponieważ obsługa protokołu OAuth przebiega zupełnie inaczej w przypadku Androida, iOS, internetu, systemu Windows, macOS i Linux.

Dodawanie wtyczek w celu włączenia uwierzytelniania Google

Zainstalujesz trzy pakiety do obsługi uwierzytelniania 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 uwierzytelnić się w systemach Windows, macOS i Linux, użyj pakietu googleapis_auth. Te platformy komputerowe uwierzytelniają się przez przeglądarkę. Aby przeprowadzić uwierzytelnianie na Androidzie, iOS i w internecie, użyj pakietów google_sign_in i extension_google_sign_in_as_googleapis_auth. Drugi pakiet działa jako podkładka interoperacyjna między dwoma pakietami.

Zaktualizuj kod

Zacznij aktualizację od utworzenia nowej abstrakcji wielokrotnego użytku – widżetu AdaptiveLogin. Ten widżet można wykorzystywać wielokrotnie, dlatego wymaga odpowiedniej konfiguracji:

lib/src/adaptive_login.dart

import 'dart:io' show Platform;

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

import 'app_state.dart';

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

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

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

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

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

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

  final _AdaptiveLoginButtonWidget button;
  final List<String> scopes;

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

class _GoogleSignInLoginState extends State<_GoogleSignInLogin> {
  @override
  initState() {
    super.initState();
    _googleSignIn = GoogleSignIn(
      scopes: widget.scopes,
    );
    _googleSignIn.onCurrentUserChanged.listen((account) {
      if (account != null) {
        _googleSignIn.authenticatedClient().then((authClient) {
          if (authClient != null) {
            context.read<AuthedUserPlaylists>().authClient = authClient;
            context.go('/');
          }
        });
      }
    });
  }

  late final GoogleSignIn _googleSignIn;

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

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

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

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

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

  Uri? _authUrl;

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

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

Ten plik wiele robi. Metoda build na platformie AdaptiveLogin wykonuje ciężką pracę. Ta metoda sprawdza platformę środowiska wykonawczego, wywołując zarówno metodę kIsWeb, jak i Platform.isXXX (dart:io). W przypadku aplikacji na Androida, iOS i internetu tworzy instancję widżetu stanowego _GoogleSignInLogin. W systemach Windows, macOS i Linux tworzy wystąpienie widżetu stanowego _GoogleApisAuthLogin.

Korzystanie z tych klas wymaga dodatkowej konfiguracji, która pojawi się później, po zaktualizowaniu pozostałej części kodu pod kątem używania nowego widżetu. Zacznij od zmiany nazwy elementu FlutterDevPlaylists na AuthedUserPlaylists, aby lepiej odzwierciedlić jej nowe przeznaczenie i zaktualizowanie kodu, tak by odzwierciedlał fakt, że http.Client został już przekazany po zakończeniu budowy. 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 o nową nazwę podanego 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);
      },
    );
  }
}

Podobnie zaktualizuj widżet Playlists:

lib/src/playlists.dart

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

  final PlaylistsListSelected playlistSelected;

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

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

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

Zmiany w tym pliku odzwierciedlają zmianę od wyświetlania playlist z YouTube Flutter na wyświetlanie playlist uwierzytelnionego użytkownika. Chociaż kod jest już gotowy, musisz wprowadzić serię zmian w tym pliku oraz w plikach w odpowiednich aplikacjach uruchamiających, aby prawidłowo skonfigurować pakiety google_sign_in i googleapis_auth pod kątem uwierzytelniania.

Aplikacja wyświetla teraz playlisty w YouTube utworzone przez uwierzytelnionego użytkownika. Gdy funkcje są już gotowe, musisz włączyć uwierzytelnianie. Aby to zrobić, skonfiguruj pakiety google_sign_in i googleapis_auth. Aby skonfigurować pakiety, musisz zmienić plik main.dart i pliki aplikacji Runner.

Konfiguruję zasób googleapis_auth

Pierwszym krokiem do skonfigurowania uwierzytelniania jest wyeliminowanie skonfigurowanego i używanego wcześniej klucza interfejsu API. W projekcie API otwórz stronę danych logowania i usuń klucz interfejsu API:

Strona danych logowania projektu API w konsoli GCP

Pojawi się wyskakujące okienko, w którym potwierdzasz, że klikasz przycisk Usuń:

Wyskakujące okienko Usuń dane logowania

Następnie utwórz identyfikator klienta OAuth:

Tworzenie identyfikatora klienta OAuth

Jako Typ aplikacji wybierz Aplikacja komputerowa.

Wybieranie typu aplikacji komputerowej

Zaakceptuj nazwę i kliknij Utwórz.

nadanie nazwy identyfikatorowi klienta;

Spowoduje to utworzenie identyfikatora klienta i klucza klienta, które musisz dodać do usługi 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 WWW działającego na lokalnym hoście do przechwycenia wygenerowanego tokena OAuth, co w systemie macOS wymaga 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ż uprawnienie com.apple.security.network.server do włączania funkcji Hot Załaduj ponownie i narzędzi debugowania maszyny wirtualnej Dart.

Teraz możesz uruchomić aplikację w systemach Windows, macOS lub Linux (jeśli aplikacja została skompilowana na te środowiska docelowe).

Aplikacja wyświetlająca playlisty zalogowanego użytkownika

Konfigurowanie aplikacji google_sign_in na Androida

Wróć na stronę danych logowania w projekcie API i utwórz kolejny identyfikator klienta OAuth. Tym razem wybierz Android:

Wybieranie typu aplikacji na Androida

W pozostałej części formularza wpisz nazwę pakietu za pomocą pakietu zadeklarowanego w polu android/app/src/main/AndroidManifest.xml. Jeśli zostało przez Ciebie wykonane zgodnie ze wskazówkami dotyczącymi litery, to com.example.adaptive_app. Wyodrębnij odcisk cyfrowy certyfikatu SHA-1, postępując zgodnie z instrukcjami ze strony pomocy konsoli Google Cloud Platform:

Nadanie nazwy identyfikatorowi klienta Androida

To wystarczająco dużo, aby aplikacja działała na Androidzie. W zależności od tego, których interfejsów API Google używasz, konieczne może być dodanie wygenerowanego pliku JSON do pakietu aplikacji.

Uruchamianie aplikacji na Androidzie

Konfiguruję google_sign_in na iOS

Wróć na stronę danych logowania w projekcie API i utwórz inny identyfikator klienta OAuth. Tym razem wybierz iOS:

, Wybieranie typu aplikacji na iOS

W pozostałej części formularza wypełnij identyfikator pakietu, otwierając plik ios/Runner.xcworkspace w Xcode. Przejdź do Nawigatora projektów, wybierz Runner w nawigatorze, kliknij kartę Ogólne i skopiuj identyfikator pakietu. Jeśli krok po kroku został przez Ciebie wykonane ćwiczenie w Codelabs, powinno to być com.example.adaptiveApp.

Podaj identyfikator pakietu w pozostałej części formularza. Otwórz plik ios/Runner.xcworkspace w Xcode. Otwórz Projekt Navigator. Wybierz Runner > Karta Ogólne. Skopiuj identyfikator pakietu. Jeśli krok po kroku został przez Ciebie wykonane ćwiczenie w Codelabs, jego wartość powinna wynosić com.example.adaptiveApp.

Gdzie w Xcode można znaleźć identyfikator pakietu

Na razie zignoruj identyfikator App Store i identyfikator zespołu, ponieważ nie są one wymagane do lokalnego programowania:

Nadanie nazwy identyfikatorowi klienta iOS

Pobierz wygenerowany plik .plist. Jego nazwa bazuje na wygenerowanym identyfikatorze klienta. Zmień nazwę pobranego pliku na GoogleService-Info.plist, a następnie przeciągnij go do uruchomionego edytora Xcode obok pliku Info.plist w sekcji Runner/Runner w lewym nawigatorze. W oknie opcji w Xcode wybierz w razie potrzeby Kopiuj elementy, Utwórz odwołania do folderów i Dodaj do środowiska docelowego.

Dodawanie wygenerowanego pliku plist do aplikacji na iOS w Xcode

Wyjdź z Xcode, a następnie w dowolnym IDE dodaj do 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 pasowała do wpisu w wygenerowanym pliku GoogleService-Info.plist. Uruchom aplikację. Twoje playlisty powinny pojawić się po zalogowaniu.

Uruchomiona aplikacja na iOS

Konfiguruję google_sign_in na potrzeby sieci

Wróć na stronę z danymi logowania w projekcie API i utwórz inny identyfikator klienta OAuth (tym razem nie ograniczaj się do opcji Aplikacja internetowa):

Wybieranie typu aplikacji internetowej

W pozostałej części formularza wypełnij autoryzowane źródła JavaScriptu w ten sposób:

Nadawanie nazwy identyfikatorowi klienta aplikacji internetowej

Spowoduje to wygenerowanie identyfikatora klienta. Dodaj do pliku web/index.html ten tag meta zaktualizowany o wygenerowany identyfikator klienta:

web/index.html

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

Aby uruchomić tę próbkę, musisz trzymać rękę na pulsie. Musisz uruchomić serwer proxy CORS utworzony w poprzednim kroku i uruchomić aplikację internetową Flutter na porcie określonym w formularzu identyfikatora klienta OAuth aplikacji internetowej, postępując zgodnie z poniższymi instrukcjami.

W jednym z terminali uruchom serwer proxy CORS 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".

Gdy zalogujesz się jeszcze raz, Twoje playlisty powinny być widoczne:

Aplikacja działająca w przeglądarce Chrome

8. Dalsze kroki

Gratulacje!

Udało Ci się ukończyć ćwiczenia i stworzyć adaptacyjną aplikację Flutter, która będzie działać na wszystkich 6 platformach obsługiwanych przez Flutter. Kod został dostosowany, aby uwzględnić różnice w układzie ekranów, interakcji z tekstem, sposobie ładowania obrazów oraz działaniu uwierzytelniania.

Istnieje wiele innych elementów, które możesz dostosować w swoich aplikacjach. Aby poznać dodatkowe sposoby dostosowywania kodu do różnych środowisk, w których będzie uruchamiany, zapoznaj się z sekcją Tworzenie aplikacji adaptacyjnych.