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

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

О практической работе

subjectПоследнее обновление: июн. 3, 2025
account_circleАвторы: Brett Morgan

1. Введение

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

Чему вы научитесь

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

Что вы построите

В этой кодовой лаборатории вы сначала создадите приложение Flutter для Android и iOS, которое исследует плейлисты YouTube Flutter. Затем вы адаптируете это приложение для работы на трех настольных платформах (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, приложение перезагружается при сохранении файла.

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

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

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

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

А вот тот же код, работающий изначально на macOS и внутри Chrome, снова работающий на macOS.

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

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

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

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

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

Извлечение Theme из BuildContext предназначено для решений по реализации, ориентированных на тему. Ярким примером этого является решение использовать слайдер Material или слайдер Cupertino, как обсуждалось в 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.7
+ flex_color_scheme 8.2.0
+ flex_seed_scheme 3.5.1
+ flutter_web_plugins 0.0.0 from sdk flutter
+ go_router 15.1.2
+ googleapis 14.0.0
+ http 1.4.0
+ http_parser 4.1.2
  leak_tracker 10.0.9 (11.0.1 available)
  leak_tracker_flutter_testing 3.0.9 (3.0.10 available)
  leak_tracker_testing 3.0.1 (3.0.2 available)
  lints 5.1.1 (6.0.0 available)
+ logging 1.3.0
  material_color_utilities 0.11.1 (0.12.0 available)
  meta 1.16.0 (1.17.0 available)
+ nested 1.0.0
+ plugin_platform_interface 2.1.8
+ provider 6.1.5
  test_api 0.7.4 (0.7.6 available)
+ typed_data 1.4.0
+ url_launcher 6.3.1
+ url_launcher_android 6.3.16
+ url_launcher_ios 6.3.3
+ url_launcher_linux 3.2.1
+ url_launcher_macos 3.2.2
+ url_launcher_platform_interface 2.3.2
+ url_launcher_web 2.4.1
+ url_launcher_windows 3.1.4
  vector_math 2.1.4 (2.1.5 available)
+ web 1.1.1
Changed 22 dependencies!
8 packages have newer versions incompatible with dependency constraints.
Try `flutter pub outdated` for more information.

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

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

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

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

ios/Runner/Info.plist

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

В Android Flutter runner добавьте следующие строки в Manifest.xml . Добавьте этот узел queries как прямой дочерний элемент узла manifest и одноранговый элемент узла 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>

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

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

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

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

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

После того, как у вас есть проект, перейдите на страницу API Library . В поле поиска введите "youtube" и выберите youtube data api v3 .

Выбор YouTube Data API v3 в консоли GCP

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

5a877ea82b83ae42.png

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

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

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

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

Добавить код

В оставшейся части этого шага вы будете копировать и вставлять много кода для создания мобильного приложения, без каких-либо комментариев к коду. Цель этой кодовой лаборатории — взять мобильное приложение и адаптировать его как для настольных компьютеров, так и для веба. Для более подробного введения в создание приложений Flutter для мобильных устройств см. Ваше первое приложение 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 следующим образом:

lib/main.dart

import 'dart:io';

import 'package:flex_color_scheme/flex_color_scheme.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';

import 'src/app_state.dart';
import 'src/playlist_details.dart';
import 'src/playlists.dart';

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

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

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

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

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

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

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

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

lib/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.права

<?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, когда вы выберете плейлист, вы увидите видео в этом плейлисте, и, наконец, если вы нажмете кнопку Play, вы будете запущены в YouTube для просмотра видео.

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

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

Выбранное видео, воспроизводимое в плеере YouTube

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

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

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

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

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

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

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

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

$ flutter pub add split_view
Resolving dependencies...
Downloading packages...
  leak_tracker 10.0.9 (11.0.1 available)
  leak_tracker_flutter_testing 3.0.9 (3.0.10 available)
  leak_tracker_testing 3.0.1 (3.0.2 available)
  lints 5.1.1 (6.0.0 available)
  material_color_utilities 0.11.1 (0.12.0 available)
  meta 1.16.0 (1.17.0 available)
+ split_view 3.2.1
  test_api 0.7.4 (0.7.6 available)
  vector_math 2.1.4 (2.1.5 available)
Changed 1 dependency!
8 packages have newer versions incompatible with dependency constraints.
Try `flutter pub outdated` for more information.

Внедрение адаптивных виджетов

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

lib/main.dart

import 'dart:io';

import 'package:flex_color_scheme/flex_color_scheme.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';

import 'src/adaptive_playlists.dart';                          // Add this import
import 'src/app_state.dart';
import 'src/playlist_details.dart';
// Remove the src/playlists.dart import

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

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

final _router = GoRouter(
 
routes: <RouteBase>[
   
GoRoute(
     
path: '/',
     
builder: (context, state) {
       
return const AdaptivePlaylists();                      // Modify this line
     
},
     
routes: <RouteBase>[
       
GoRoute(
         
path: 'playlist/:id',
         
builder: (context, state) {
           
final title = state.uri.queryParameters['title']!;
           
final id = state.pathParameters['id']!;
           
return Scaffold(                                   // Modify from here
             
appBar: AppBar(title: Text(title)),
             
body: PlaylistDetails(playlistId: id, playlistName: title),
           
);                                                 // To here.
         
},
       
),
     
],
   
),
 
],
);

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

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

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

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

Далее создайте файл для виджета 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 Resource Sharing. Откройте терминал и создайте веб-сервер 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 такими, какие они были. Это было сделано намеренно, поскольку, если вы адаптируете виджеты Text, функциональность ListTile onTap блокируется, когда пользователь нажимает на текст.

Запустите приложение в Интернете, правильно

При работающем прокси-сервере 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) {
         
final context = this.context;
         
if (authClient != null && context.mounted) {
           
context.read<AuthedUserPlaylists>().authClient = authClient;
           
context.go('/');
         
}
       
});
     
}
   
});
 
}

 
late final GoogleSignIn _googleSignIn;

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

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

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

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

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

 
Uri? _authUrl;

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

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

Этот файл делает много. Метод build AdaptiveLogin выполняет тяжелую работу. Вызывая как kIsWeb , так и Platform.isXXX dart:io , этот метод проверяет платформу выполнения. Для 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({super.key, required this.playlistSelected});

  final PlaylistsListSelected playlistSelected;

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

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

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

lib/main.dart

// Drop dart:io import

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

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

// Drop flutterDevAccountId and youTubeApiKey

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

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

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

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

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

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

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

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

Настроить googleapis_auth

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

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

Это создаст диалоговое окно, которое вы подтвердите, нажав кнопку «Удалить»:

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

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

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

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

Выбор типа приложения «Десктопное приложение»

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

Присвоение имени клиентскому идентификатору

Это создает Client ID и Client Secret, которые необходимо добавить в lib/main.dart для настройки потока googleapis_auth . Важной деталью реализации является то, что поток googleapis_auth использует временный веб-сервер, работающий на localhost, для захвата сгенерированного токена OAuth, который в macOS требует изменения файла macos/Runner/Release.entitlements :

macos/Runner/Release.права

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

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

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

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

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

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

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

Присвоение имени идентификатору клиента Android

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

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

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

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

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

Для остальной части формы заполните Bundle ID, открыв ios/Runner.xcworkspace в Xcode. Перейдите в Project Navigator, выберите Runner в навигаторе, затем выберите вкладку General и скопируйте Bundle Identifier. Если вы следовали этой codelab шаг за шагом, это должно быть com.example.adaptiveApp .

Для оставшейся части формы заполните Bundle ID. Откройте ios/Runner.xcworkspace в Xcode. Перейдите в Project Navigator. Перейдите в Runner > вкладка General. Скопируйте Bundle Identifier. Если вы следовали этой codelab шаг за шагом, его значение должно быть com.example.adaptiveApp .

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

Пока не обращайте внимания на App Store ID и Team ID, так как они не требуются для локальной разработки:

Присвоение имени идентификатору клиента iOS

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

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

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

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

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

веб/индекс.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. Следующие шаги

Поздравляю!

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

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