Адаптивные приложения во Flutter

1. Введение

Flutter — это набор инструментов пользовательского интерфейса Google для создания красивых, скомпилированных в собственном коде приложений для мобильных устройств, Интернета и настольных компьютеров из единой базы кода. В этой лаборатории вы узнаете, как создать приложение Flutter, которое адаптируется к платформе, на которой оно работает, будь то Android, iOS, Интернет, Windows, macOS или Linux.

Что вы узнаете

  • Как вырастить приложение Flutter, предназначенное для мобильных устройств, чтобы оно работало на всех шести платформах, поддерживаемых Flutter.
  • Различные API Flutter для обнаружения платформы и когда использовать каждый API.
  • Адаптация к ограничениям и ожиданиям запуска приложения в Интернете.
  • Как использовать разные пакеты рядом друг с другом для поддержки всего спектра платформ Flutter.

Что ты построишь

В этой лаборатории кода вы сначала создадите приложение Flutter для Android и iOS, которое исследует плейлисты Flutter на YouTube. Затем вы адаптируете это приложение для работы на трех настольных платформах (Windows, macOS и Linux), изменив способ отображения информации с учетом размера окна приложения. Затем вы адаптируете приложение для Интернета, сделав текст, отображаемый в приложении, доступным для выбора, как ожидают пользователи Интернета. Наконец, вы добавите аутентификацию в приложение, чтобы вы могли просматривать свои собственные плейлисты, в отличие от тех, которые созданы командой Flutter, которые требуют разных подходов к аутентификации для Android, iOS и в Интернете по сравнению с тремя настольными платформами Windows. macOS и Linux.

Вот скриншот приложения Flutter на Android и iOS:

Готовое приложение, работающее на эмуляторе Android.

Готовое приложение, работающее на симуляторе iOS.

Это приложение, работающее в широкоэкранном режиме на macOS, должно выглядеть так, как показано на следующем снимке экрана.

Готовое приложение, работающее на macOS

Эта лаборатория посвящена преобразованию мобильного приложения Flutter в адаптивное приложение, которое работает на всех шести платформах Flutter. Нерелевантные концепции и блоки кода замалчиваются и предоставляются для простого копирования и вставки.

Что бы вы хотели узнать из этой кодовой лаборатории?

Я новичок в этой теме, и мне нужен хороший обзор. Я кое-что знаю по этой теме, но хочу освежить знания. Я ищу пример кода для использования в моем проекте. Я ищу объяснение чего-то конкретного.

2. Настройте среду разработки Flutter.

Для выполнения этой лабораторной работы вам понадобятся два программного обеспечения — Flutter SDK и редактор .

Вы можете запустить кодовую лабораторию, используя любое из этих устройств:

  • Физическое устройство Android или iOS , подключенное к вашему компьютеру и переведенное в режим разработчика.
  • Симулятор iOS (требуется установка инструментов Xcode).
  • Эмулятор Android (требуется установка в Android Studio).
  • Браузер (для отладки необходим Chrome).
  • В качестве настольного приложения для Windows , Linux или macOS . Вы должны разрабатывать на платформе, на которой планируете развернуть. Итак, если вы хотите разработать настольное приложение для Windows, вам необходимо разработать его в Windows, чтобы получить доступ к соответствующей цепочке сборки. Существуют требования, специфичные для операционной системы, которые подробно описаны на docs.flutter.dev/desktop .

3. Начните работу

Подтверждение вашей среды разработки

Самый простой способ убедиться, что все готово к разработке, — выполнить следующую команду:

$ flutter doctor

Если что-то отображается без галочки, выполните следующую команду, чтобы получить дополнительную информацию о том, что не так:

$ flutter doctor -v

Возможно, вам потребуется установить инструменты разработчика для разработки мобильных или настольных компьютеров. Более подробную информацию о настройке инструментов в зависимости от операционной системы вашего хоста см. в документации по установке Flutter .

Создание проекта Flutter

Самый простой способ начать писать Flutter для настольных приложений — использовать инструмент командной строки Flutter для создания проекта Flutter. Альтернативно, ваша IDE может предоставить рабочий процесс для создания проекта 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.

Чтобы убедиться, что все работает, запустите стандартное приложение Flutter как мобильное приложение, как показано ниже. Альтернативно откройте этот проект в своей IDE и используйте его инструменты для запуска приложения. Благодаря предыдущему шагу запуск настольного приложения должен быть единственным доступным вариантом.

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

Теперь вы должны увидеть, что приложение работает. Контент требует обновления.

Чтобы обновить содержимое, обновите свой код в lib/main.dart используя следующий код. Чтобы изменить то, что отображает ваше приложение, выполните горячую перезагрузку.

  • Если вы запускаете приложение с помощью командной строки, введите r в консоли для горячей перезагрузки.
  • Если вы запускаете приложение с помощью IDE, приложение перезагружается при сохранении файла.

библиотека/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';
    }
  }
}

Вышеупомянутое приложение создано, чтобы дать вам представление о том, как можно обнаружить и адаптировать к различным платформам. Вот приложение, работающее на Android и iOS:

Отображение свойств окна в эмуляторе Android

Отображение свойств окна в симуляторе iOS

А вот тот же код, который работает как в macOS, так и внутри Chrome, и снова работает в macOS.

Отображение свойств окна в macOS

Отображение свойств окна в браузере Chrome

Здесь важно отметить, что, на первый взгляд, Flutter делает все возможное, чтобы адаптировать контент к дисплею, на котором он работает. Ноутбук, на котором были сделаны эти снимки экрана, оснащен дисплеем Mac с высоким разрешением, поэтому как macOS, так и веб-версия приложения отображаются с соотношением пикселей устройства, равным 2. Между тем, на iPhone 12 вы видите соотношение 3, и 2,63 на Pixel 2. Во всех случаях отображаемый текст примерно одинаков, что значительно упрощает нашу работу как разработчиков.

Второй момент, на который следует обратить внимание, заключается в том, что два варианта проверки того, на какой платформе выполняется код, дают разные значения. Первый вариант проверяет объект Platform , импортированный из dart:io , а второй вариант (доступный только внутри метода build виджета) извлекает объект Theme из аргумента BuildContext .

Причина, по которой эти два метода возвращают разные результаты, заключается в том, что их цель различна. Объект Platform , импортированный из dart:io предназначен для принятия решений, независимых от вариантов рендеринга. Ярким примером этого является принятие решения о том, какие плагины использовать, которые могут иметь или не иметь соответствующие собственные реализации для конкретной физической платформы.

Извлечение Theme из BuildContext предназначено для решений по реализации, ориентированных на тему. Ярким примером этого является решение, использовать ли ползунок «Материал» или ползунок «Купертино», как описано в Slider.adaptive .

В следующем разделе вы создадите базовое приложение для просмотра плейлистов YouTube, оптимизированное исключительно для Android и iOS. В следующих разделах вы добавите различные адаптации, чтобы приложение лучше работало на компьютере и в Интернете.

4. Создайте мобильное приложение

Добавить пакеты

В этом приложении вы будете использовать различные пакеты Flutter, чтобы получить доступ к API данных YouTube , управлению состоянием и небольшому количеству тем.

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

Эта команда добавляет в приложение несколько пакетов:

  • googleapis : созданная библиотека Dart, обеспечивающая доступ к API Google .
  • http : библиотека для создания HTTP-запросов, которая скрывает различия между собственным и веб-браузерами.
  • provider : обеспечивает управление состоянием.
  • url_launcher : предоставляет возможность перейти к видео из списка воспроизведения. Как видно из разрешенных зависимостей, url_launcher имеет реализации для Windows, macOS, Linux и Интернета, в дополнение к Android и iOS по умолчанию. Использование этого пакета означает, что вам не нужно будет создавать платформу, специфичную для этой функциональности.
  • flex_color_scheme : предоставляет приложению красивую цветовую схему по умолчанию. Чтобы узнать больше, ознакомьтесь с документацией по API flex_color_scheme .
  • go_router : реализует навигацию между различными экранами. Этот пакет предоставляет удобный API на основе URL-адресов для навигации с помощью Flutter Router.

Настройка мобильных приложений для url_launcher

Плагин url_launcher требует настройки приложений для запуска Android и iOS. В бегуне iOS Flutter добавьте следующие строки в словарь plist .

iOS/Runner/Info.plist

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

В бегуне Android Flutter добавьте следующие строки в Manifest.xml . Добавьте этот узел queries в качестве прямого дочернего узла узла manifest и однорангового узла узла application .

Android/приложение/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>

Более подробную информацию об этих необходимых изменениях конфигурации см. в документации url_launcher .

Доступ к API данных YouTube

Чтобы получить доступ к API данных YouTube для получения списка плейлистов, вам необходимо создать проект API для генерации необходимых ключей API. Эти шаги предполагают, что у вас уже есть учетная запись Google , поэтому создайте ее, если у вас ее еще нет.

Перейдите в консоль разработчика , чтобы создать проект API :

Отображение консоли GCP во время создания проекта

Если у вас есть проект, перейдите на страницу библиотеки API . В поле поиска введите «youtube» и выберите API данных YouTube v3 .

Выбор API данных YouTube v3 в консоли GCP

На странице сведений об API данных YouTube v3 включите API.

5a877ea82b83ae42.png

После включения API перейдите на страницу «Учетные данные» и создайте ключ API.

Создание учетных данных в консоли GCP

Через пару секунд вы должны увидеть диалоговое окно с вашим новым блестящим ключом API. Вскоре вы будете использовать этот ключ.

Всплывающее окно создания ключа API, показывающее созданный ключ API.

Добавить код

На оставшейся части этого шага вы будете вырезать и вставлять много кода для создания мобильного приложения без каких-либо комментариев к коду. Цель этой лаборатории кода — взять мобильное приложение и адаптировать его как для настольных компьютеров, так и для Интернета. Более подробное введение в создание приложений Flutter для мобильных устройств см. в разделах «Написание первого приложения Flutter», часть 1 , часть 2 и «Создание красивых пользовательских интерфейсов с помощью Flutter» .

Добавьте следующие файлы: сначала объект состояния приложения.

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

Затем добавьте страницу сведений об отдельном плейлисте.

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

Далее добавьте список плейлистов.

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

И замените содержимое файла main.dart следующим образом:

библиотека/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,
    );
  }
}

Вы почти готовы запустить этот код на Android и iOS. Осталось изменить еще одну вещь: измените константу youTubeApiKey в строке 14, указав ключ API YouTube, сгенерированный на предыдущем шаге.

библиотека/main.dart

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

Чтобы запустить это приложение на macOS, вам необходимо разрешить приложению выполнять HTTP-запросы следующим образом. Отредактируйте файлы DebugProfile.entitlements и Release.entitilements следующим образом:

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>

Запустите приложение

Теперь, когда у вас есть готовое приложение, вы сможете успешно запустить его на эмуляторе Android или симуляторе iPhone. Вы увидите список плейлистов Flutter, когда вы выберете плейлист, вы увидите видео в этом плейлисте, и, наконец, если вы нажмете кнопку «Воспроизвести», вы перейдете в YouTube для просмотра видео.

Приложение, показывающее плейлисты для аккаунта FlutterDev на YouTube.

Показ видео в определенном плейлисте

Выбранное видео воспроизводится в проигрывателе YouTube.

Однако если вы попытаетесь запустить это приложение на рабочем столе, вы увидите, что макет выглядит неправильным при расширении до обычного окна размера рабочего стола. На следующем этапе вы изучите способы адаптации к этому.

5. Адаптация к рабочему столу

Проблема с рабочим столом

Если вы запустите приложение на одной из родных настольных платформ: Windows, macOS или Linux, вы заметите интересную проблему. Это работает, но выглядит... странно.

Приложение, работающее на macOS, показывает список плейлистов, который выглядит странно пропорциональным.

Видео в плейлисте на macOS

Чтобы исправить это, добавьте разделенное представление, в котором плейлисты будут отображаться слева, а видео — справа. Однако вам нужно, чтобы этот макет срабатывал только тогда, когда код не работает на Android или iOS и окно достаточно широкое. Следующие инструкции показывают, как реализовать эту возможность.

Сначала добавьте пакет split_view , который поможет в построении макета.

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

Представляем адаптивные виджеты

Шаблон, который вы собираетесь использовать в этой кодовой лаборатории, заключается в представлении адаптивных виджетов, которые выбирают реализацию на основе таких атрибутов, как ширина экрана, тема платформы и т. д. В этом случае вы собираетесь представить виджет AdaptivePlaylists , который изменяет взаимодействие Playlists и PlaylistDetails . Отредактируйте файл lib/main.dart следующим образом:

библиотека/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,
    );
  }
}

Далее создайте файл для виджета 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')),
          },
        ],
      ),
    );
  }
}

Этот файл интересен по нескольким причинам. Во-первых, он использует как ширину окна (используя MediaQuery.of(context).size.width ), так и вы проверяете тему (используя Theme.of(context).platform ), чтобы решить, отображать ли широкий макет с помощью виджет SplitView или узкий дисплей без него.

Во-вторых, в этом разделе рассматривается жестко запрограммированная обработка навигации. Он отображает аргумент обратного вызова в виджете Playlists . Этот обратный вызов уведомляет окружающий код о том, что пользователь выбрал список воспроизведения. Затем код должен выполнить работу по отображению этого списка воспроизведения. Это меняет необходимость в Scaffold в виджетах Playlists и PlaylistDetails . Теперь, когда они не являются верхним уровнем, вам необходимо удалить Scaffold из этих виджетов.

Затем отредактируйте файл src/lib/playlists.dart следующим образом:

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

В этом файле много изменений. Помимо вышеупомянутого введения обратного вызова playlistSelected и исключения виджета Scaffold , виджет _PlaylistsListView преобразуется из состояния без сохранения состояния в состояние с сохранением состояния. Это изменение необходимо из-за введения собственного ScrollController , который необходимо создать и уничтожить.

Введение ScrollController интересно, поскольку оно необходимо, поскольку в широком макете у вас есть два виджета ListView рядом. На мобильном телефоне традиционно используется один ListView , и, таким образом, может существовать один долгоживущий ScrollController, к которому все ListView прикрепляются и отсоединяются в течение их индивидуального жизненного цикла. Рабочий стол отличается от других: в мире, где несколько ListView рядом друг с другом имеют смысл.

И, наконец, отредактируйте файл lib/src/playlist_details.dart следующим образом:

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

Подобно виджету Playlists , указанному выше, этот файл также содержит изменения, связанные с устранением виджета Scaffold и введением собственного ScrollController .

Запустите приложение еще раз!

Запуск приложения на рабочем столе по вашему выбору, будь то Windows, macOS или Linux. Теперь все должно работать так, как вы ожидаете.

Приложение, работающее на macOS, с разделенным представлением

6. Адаптация к Интернету

Что там с этими изображениями, а?

Попытка запустить это приложение в Интернете теперь требует дополнительной работы по адаптации к веб-браузерам.

Приложение работает в браузере Chrome без миниатюр изображений YouTube.

Если вы заглянете в консоль отладки, вы увидите легкую подсказку о том, что вам следует делать дальше.

══╡ EXCEPTION CAUGHT BY IMAGE RESOURCE SERVICE ╞════════════════════════════════════════════════════
The following ProgressEvent$ object was thrown resolving an image codec:
  [object ProgressEvent]

When the exception was thrown, this was the stack

Image provider: NetworkImage("https://i.ytimg.com/vi/4AoFA19gbLo/default.jpg", scale: 1.0)
Image key: NetworkImage("https://i.ytimg.com/vi/4AoFA19gbLo/default.jpg", scale: 1.0)
════════════════════════════════════════════════════════════════════════════════════════════════════

Создание прокси-сервера CORS

Одним из способов решения проблем рендеринга изображений является внедрение прокси-веб-службы для добавления необходимых заголовков совместного использования ресурсов Cross Origin. Откройте терминал и создайте веб-сервер Dart следующим образом:

$ 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

Перейдите в каталог сервера yt_cors_proxy и добавьте пару необходимых зависимостей:

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

Есть некоторые текущие зависимости, которые больше не требуются. Обрежьте их следующим образом:

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

Затем измените содержимое файла server.dart так, чтобы оно соответствовало следующему:

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

Вы можете запустить этот сервер следующим образом:

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

Альтернативно вы можете создать его как образ Docker и запустить полученный образ Docker следующим образом:

$ 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

Затем измените код Flutter, чтобы использовать преимущества этого прокси-сервера CORS, но только при работе внутри веб-браузера.

Пара адаптируемых виджетов

Первый из пары виджетов — это то, как ваше приложение будет использовать прокси-сервер 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);
  }
}

Это приложение использует константу kIsWeb из-за различий в платформах времени выполнения. Другой адаптируемый виджет позволяет приложению работать так же, как и другим веб-страницам. Пользователи браузера ожидают, что текст можно будет выбирать.

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

Теперь распространите эти адаптации по всей кодовой базе:

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

В приведенном выше коде вы адаптировали виджеты Image.network и Text . Далее адаптируйте виджет 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);
            },
          ),
        );
      },
    );
  }
}

На этот раз вы адаптировали только виджет Image.network , но оставили два виджета Text такими, какие они есть. Это было сделано намеренно, поскольку если вы адаптируете виджеты «Текст», функциональность onTap ListTile блокируется, когда пользователь нажимает на текст.

Правильно запустите приложение в Интернете

При работающем прокси-сервере CORS вы сможете запустить веб-версию приложения, и она будет выглядеть примерно так:

Приложение, работающее в браузере Chrome, с заполненными миниатюрами изображений YouTube.

7. Адаптивная аутентификация

На этом этапе вы собираетесь расширить приложение, предоставив ему возможность аутентифицировать пользователя, а затем показывать плейлисты этого пользователя. Вам придется использовать несколько плагинов для разных платформ, на которых может работать приложение, поскольку обработка OAuth выполняется по-разному в Android, iOS, Интернете, Windows, macOS и Linux.

Добавление плагинов для включения аутентификации Google

Вы собираетесь установить три пакета для аутентификации 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.

Для аутентификации в Windows, macOS и Linux используйте пакет googleapis_auth . Эти настольные платформы аутентифицируются через веб-браузер. Для аутентификации на Android, iOS и в Интернете используйте пакеты google_sign_in и extension_google_sign_in_as_googleapis_auth . Второй пакет действует как прокладка взаимодействия между двумя пакетами.

Обновите код

Начните обновление с создания новой многоразовой абстракции — виджета AdaptiveLogin. Этот виджет предназначен для повторного использования и поэтому требует некоторой настройки:

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

Этот файл делает многое. Метод build AdaptiveLogin выполняет тяжелую работу. Вызывая как kIsWeb так и dart:io Platform.isXXX , этот метод проверяет платформу времени выполнения. Для Android, iOS и Интернета он создает экземпляр виджета с состоянием _GoogleSignInLogin . Для Windows, macOS и Linux он создает экземпляр виджета _GoogleApisAuthLogin с отслеживанием состояния.

Для использования этих классов требуется дополнительная настройка, которая появится позже, после обновления остальной части кода для использования этого нового виджета. Начните с переименования FlutterDevPlaylists в AuthedUserPlaylists , чтобы лучше отразить его новую цель в жизни, и обновите код, чтобы отразить, что http.Client теперь передается после создания. Наконец, класс _ApiKeyClient больше не требуется:

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

Затем обновите виджет PlaylistDetails , указав новое имя для предоставленного объекта состояния приложения:

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

Аналогичным образом обновите виджет 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,
        );
      },
    );
  }
}

Наконец, обновите файл main.dart , чтобы правильно использовать новый виджет AdaptiveLogin :

библиотека/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,
    );
  }
}

Изменения в этом файле отражают переход от простого отображения плейлистов Flutter на YouTube к отображению плейлистов аутентифицированного пользователя. Хотя код теперь завершен, в этот файл и файлы соответствующих приложений Runner еще необходимо внести ряд изменений, чтобы правильно настроить пакеты google_sign_in и googleapis_auth для аутентификации.

Приложение теперь отображает плейлисты YouTube от авторизованного пользователя. Когда функции завершены, вам необходимо включить аутентификацию. Для этого настройте пакеты google_sign_in и googleapis_auth . Чтобы настроить пакеты, вам необходимо изменить файл main.dart и файлы приложений Runner.

Настройка googleapis_auth

Первым шагом к настройке аутентификации является удаление ключа API, который вы ранее настроили и использовали. Перейдите на страницу учетных данных вашего проекта API и удалите ключ API:

Страница учетных данных проекта API в консоли GCP

При этом появится всплывающее окно, которое вы подтвердите, нажав кнопку «Удалить»:

Всплывающее окно «Удалить учетные данные»

Затем создайте идентификатор клиента OAuth:

Создание идентификатора клиента OAuth

В качестве типа приложения выберите Приложение для ПК.

Выбор типа приложения для настольного компьютера

Примите имя и нажмите «Создать» .

Именование идентификатора клиента

При этом будут созданы идентификатор клиента и секрет клиента, которые необходимо добавить в lib/main.dart для настройки потока googleapis_auth . Важная деталь реализации заключается в том, что поток googleapis_auth использует временный веб-сервер, работающий на локальном хосте, для захвата сгенерированного токена OAuth, который в macOS требует изменения файла 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>

Вам не нужно вносить это изменение в файл macos/Runner/DebugProfile.entitlements поскольку у него уже есть права на com.apple.security.network.server для включения горячей перезагрузки и инструментов отладки Dart VM.

Теперь вы сможете запускать свое приложение в Windows, macOS или Linux (если приложение было скомпилировано для этих целей).

Приложение, показывающее плейлисты для вошедшего в систему пользователя.

Настройка google_sign_in для Android

Вернитесь на страницу учетных данных вашего проекта API и создайте еще один идентификатор клиента OAuth, но на этот раз выберите Android:

Выбор типа приложения Android

В оставшейся части формы заполните имя пакета, указав пакет, объявленный в android/app/src/main/AndroidManifest.xml . Если вы следовали инструкциям в письме, это должно быть com.example.adaptive_app . Извлеките отпечаток сертификата SHA-1, следуя инструкциям на странице справки консоли Google Cloud Platform :

Именование идентификатора клиента Android

Этого достаточно, чтобы приложение заработало на Android. В зависимости от выбора API Google, который вы используете, вам может потребоваться добавить сгенерированный файл JSON в пакет вашего приложения.

Запуск приложения на Android

Настройка google_sign_in для iOS

Вернитесь на страницу учетных данных вашего проекта API и создайте еще один идентификатор клиента OAuth, но на этот раз выберите iOS:

. Выбор типа приложения iOS

В оставшейся части формы заполните идентификатор пакета, открыв ios/Runner.xcworkspace в Xcode. Перейдите в навигатор проекта, выберите Runner в навигаторе, затем выберите вкладку «Общие» и скопируйте идентификатор пакета. Если вы шаг за шагом следовали этой кодовой лаборатории, это должен быть com.example.adaptiveApp .

В оставшейся части формы заполните идентификатор пакета. Откройте ios/Runner.xcworkspace в Xcode. Перейдите в Навигатор проекта. Перейдите в Runner > вкладка «Общие». Скопируйте идентификатор пакета. Если вы шаг за шагом следовали этому коду, его значение должно быть com.example.adaptiveApp .

Где найти идентификатор пакета в Xcode

Пока игнорируйте идентификатор App Store и идентификатор команды, поскольку они не требуются для локальной разработки:

Именование идентификатора клиента iOS

Загрузите сгенерированный файл .plist , его имя основано на сгенерированном вами идентификаторе клиента. Переименуйте загруженный файл в GoogleService-Info.plist , а затем перетащите его в работающий редактор Xcode вместе с файлом Info.plist в раздел Runner/Runner в левом навигаторе. В диалоговом окне параметров в Xcode выберите «Копировать элементы, если необходимо», «Создать ссылки на папки» и «Добавить в цель Runner» .

Добавление сгенерированного файла plist в приложение iOS в Xcode

Выйдите из Xcode, затем в выбранной вами IDE добавьте следующее в свой 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>

Вам необходимо отредактировать значение, чтобы оно соответствовало записи в созданном вами файле GoogleService-Info.plist . Запустите приложение, и после входа в систему вы должны увидеть свои плейлисты.

Бегущее приложение на iOS

Настройка google_sign_in для Интернета

Вернитесь на страницу учетных данных вашего проекта API и создайте еще один идентификатор клиента OAuth, но на этот раз выберите «Веб-приложение»:

Выбор типа веб-приложения

В оставшейся части формы заполните авторизованное происхождение JavaScript следующим образом:

Именование идентификатора клиента веб-приложения

Это генерирует идентификатор клиента. Добавьте следующий meta в web/index.html , обновленный для включения сгенерированного идентификатора клиента:

веб/index.html

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

Запуск этого примера требует некоторой подготовки. Вам необходимо запустить прокси-сервер CORS, созданный на предыдущем шаге, и запустить веб-приложение Flutter на порту, указанном в форме идентификатора клиента OAuth веб-приложения, используя следующие инструкции.

В одном терминале запустите прокси-сервер CORS следующим образом:

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

В другом терминале запустите приложение Flutter следующим образом:

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

После повторного входа в систему вы должны увидеть свои плейлисты:

Приложение работает в браузере Chrome

8. Следующие шаги

Поздравляем!

Вы завершили работу над кодом и создали адаптивное приложение Flutter, которое работает на всех шести платформах, которые поддерживает Flutter. Вы адаптировали код для обработки различий в расположении экранов, взаимодействии с текстом, загрузке изображений и работе аутентификации.

Есть еще много вещей, которые вы можете адаптировать в своих приложениях. Дополнительные способы адаптации кода к различным средам, в которых он будет выполняться, см. в разделе Создание адаптивных приложений .