1. Введение
Flutter — это набор инструментов 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:
Это приложение, работающее в широкоэкранном режиме на 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:
А вот тот же код, работающий изначально на macOS и внутри Chrome, снова работающий на macOS.
Важно отметить, что, на первый взгляд, Flutter делает всё возможное, чтобы адаптировать контент к дисплею, на котором он работает. Ноутбук, на котором были сделаны эти скриншоты, оснащён дисплеем Mac с высоким разрешением, поэтому и версия для macOS, и веб-версия приложения отображаются с соотношением пикселей устройства 2. В то же время на iPhone 12 оно равно 3, а на Pixel 2 — 2,63. Во всех случаях отображаемый текст примерно одинаков, что значительно упрощает работу разработчиков.
Во-вторых, следует отметить, что два варианта проверки платформы, на которой выполняется код, возвращают разные значения. Первый вариант проверяет объект Platform
, импортированный из dart:io
, а второй (доступный только внутри метода build
виджета) извлекает объект 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 characters 1.4.0 (1.4.1 available) + flex_color_scheme 8.3.0 + flex_seed_scheme 3.5.1 > flutter_lints 6.0.0 (was 5.0.0) + flutter_web_plugins 0.0.0 from sdk flutter + go_router 16.2.0 + googleapis 14.0.0 + http 1.5.0 + http_parser 4.1.2 > lints 6.0.0 (was 5.1.1) + logging 1.3.0 material_color_utilities 0.11.1 (0.13.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.6 (0.7.7 available) + typed_data 1.4.0 + url_launcher 6.3.2 + url_launcher_android 6.3.17 + url_launcher_ios 6.3.4 + url_launcher_linux 3.2.1 + url_launcher_macos 3.2.3 + url_launcher_platform_interface 2.3.2 + url_launcher_web 2.4.1 + url_launcher_windows 3.1.4 + web 1.1.1 Changed 24 dependencies! 4 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
: задаёт приложению приятную цветовую схему по умолчанию. Подробнее см. в документации APIflex_color_scheme
. -
go_router
: реализует навигацию между различными экранами. Этот пакет предоставляет удобный API на основе URL для навигации с использованием маршрутизатора Flutter.
Настройка мобильных приложений для url_launcher
Плагин url_launcher
требует настройки приложений-бегунков для Android и iOS. В приложении Flutter для iOS добавьте следующие строки в словарь plist
.
ios/Runner/Info.plist
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
<string>http</string>
<string>tel</string>
<string>mailto</string>
</array>
В средстве выполнения Flutter для Android добавьте следующие строки в файл 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 :
После создания проекта перейдите на страницу библиотеки API . В поле поиска введите «youtube» и выберите « youtube data api v3» .
На странице сведений о YouTube Data API v3 включите API.
После включения 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.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 для просмотра видео.
Однако, если вы попытаетесь запустить это приложение на десктопе, вы увидите, что при развёртывании в стандартное окно рабочего стола его макет будет выглядеть некорректно. На следующем этапе мы рассмотрим, как это исправить.
5. Адаптация к рабочему столу
Проблема с рабочим столом
Если вы запустите приложение на одной из настольных платформ — Windows, macOS или Linux — вы заметите интересную проблему. Оно работает, но выглядит... странно.
Чтобы решить эту проблему, можно добавить разделённый вид, отображающий плейлисты слева, а видео — справа. Однако этот макет должен активироваться только тогда, когда код не запущен на Android или iOS, а окно достаточно широкое. Ниже приведены инструкции по реализации этой возможности.
Во-первых, добавьте пакет split_view
, который поможет в построении макета.
$ flutter pub add split_view Resolving dependencies... Downloading packages... characters 1.4.0 (1.4.1 available) material_color_utilities 0.11.1 (0.13.0 available) meta 1.16.0 (1.17.0 available) + split_view 3.2.1 test_api 0.7.6 (0.7.7 available) Changed 1 dependency! 4 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. Теперь оно должно работать так, как вы ожидаете.
6. Адаптируйтесь к Интернету
Что случилось с этими изображениями, а?
Попытка запустить это приложение в Интернете теперь показывает, что требуется больше работы по адаптации к веб-браузерам.
Если вы заглянете в консоль отладки, то увидите ненавязчивую подсказку о том, что вам следует делать дальше.
══╡ 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... Downloading packages... http 1.5.0 (from dev dependency to direct dependency) + shelf_cors_headers 0.1.5 Changed 2 dependencies!
Текущая зависимость больше не требуется. Обрежьте её следующим образом:
$ dart pub remove shelf_router Resolving dependencies... Downloading packages... These packages are no longer being depended on: - http_methods 1.1.1 - shelf_router 1.1.4 Changed 2 dependencies!
Затем измените содержимое файла 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 вы сможете запустить веб-версию приложения, и она будет выглядеть примерно так:
7. Адаптивная аутентификация
На этом этапе вы расширите возможности приложения, добавив ему возможность аутентификации пользователя и отображения его плейлистов. Вам потребуется использовать несколько плагинов для поддержки различных платформ, на которых может работать приложение, поскольку обработка OAuth сильно различается в Android, iOS, веб-версиях, Windows, macOS и Linux.
Добавьте плагины для включения аутентификации Google
Вам предстоит установить три пакета для обработки аутентификации Google.
$ flutter pub add googleapis_auth google_sign_in \ extension_google_sign_in_as_googleapis_auth logging Resolving dependencies... Downloading packages... + args 2.7.0 characters 1.4.0 (1.4.1 available) + crypto 3.0.6 + extension_google_sign_in_as_googleapis_auth 3.0.0 + google_identity_services_web 0.3.3+1 + google_sign_in 7.1.1 + google_sign_in_android 7.0.3 + google_sign_in_ios 6.1.0 + google_sign_in_platform_interface 3.0.0 + google_sign_in_web 1.0.0 + googleapis_auth 2.0.0 logging 1.3.0 (from transitive dependency to direct dependency) material_color_utilities 0.11.1 (0.13.0 available) meta 1.16.0 (1.17.0 available) test_api 0.7.6 (0.7.7 available) Changed 11 dependencies! 4 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:async';
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:logging/logging.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/link.dart';
import 'app_state.dart';
final _log = Logger('AdaptiveLogin');
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.instance;
_googleSignIn.initialize();
_authEventsSubscription = _googleSignIn.authenticationEvents.listen((
event,
) async {
_log.fine('Google Sign-In authentication event: $event');
if (event is GoogleSignInAuthenticationEventSignIn) {
final googleSignInClientAuthorization = await event
.user
.authorizationClient
.authorizationForScopes(widget.scopes);
if (googleSignInClientAuthorization == null) {
_log.warning('Google Sign-In authenticated client creation failed');
return;
}
_log.fine('Google Sign-In authenticated client created');
final context = this.context;
if (context.mounted) {
context.read<AuthedUserPlaylists>().authClient =
googleSignInClientAuthorization.authClient(scopes: widget.scopes);
context.go('/');
}
}
});
// Check if user is already authenticated
_log.fine('Attempting lightweight authentication');
_googleSignIn.attemptLightweightAuthentication();
}
@override
dispose() {
_authEventsSubscription.cancel();
super.dispose();
}
late final GoogleSignIn _googleSignIn;
late final StreamSubscription _authEventsSubscription;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: widget.button(
onPressed: () {
_googleSignIn.authenticate();
},
),
),
);
}
}
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
. Вызывая метод Platform.isXXX
kIsWeb
и 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-ключа, который вы ранее настроили и использовали. Перейдите на страницу учётных данных вашего проекта API и удалите API-ключ:
Появится диалоговое окно, которое вы подтвердите, нажав кнопку «Удалить»:
Затем создайте идентификатор клиента 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.
Теперь вы сможете запустить свое приложение на Windows, macOS или Linux (если приложение было скомпилировано для этих целевых платформ).
Настройте google_sign_in
для Android
Вернитесь на страницу учетных данных вашего проекта API и создайте еще один идентификатор клиента OAuth, но на этот раз выберите Android:
В оставшейся части формы заполните поле «Имя пакета» именем пакета, объявленным в файле android/app/src/main/AndroidManifest.xml
. Если вы следовали инструкциям в точности, имя должно быть com.example.adaptive_app
. Извлеките отпечаток сертификата SHA-1, следуя инструкциям со страницы справки Google Cloud Console :
Этого достаточно для работы приложения на Android. В зависимости от выбранных вами API Google вам может потребоваться добавить сгенерированный JSON-файл в комплект приложения.
Настройте google_sign_in
для iOS
Вернитесь на страницу учетных данных вашего проекта API и создайте еще один идентификатор клиента OAuth, но на этот раз выберите iOS:
Для оставшейся части формы заполните Bundle ID, открыв ios/Runner.xcworkspace
в Xcode. Перейдите в Project Navigator, выберите Runner в навигаторе, затем перейдите на вкладку General и скопируйте Bundle Identifier. Если вы следовали пошаговому руководству по этой практической работе, он должен быть com.example.adaptiveApp
.
В оставшейся части формы заполните идентификатор пакета. Откройте файл ios/Runner.xcworkspace
в Xcode. Перейдите в Project Navigator. Перейдите в Runner > вкладка General. Скопируйте идентификатор пакета. Если вы выполнили все шаги этого руководства, его значение должно быть com.example.adaptiveApp
.
Пока не обращайте внимания на App Store ID и Team ID, так как они не требуются для локальной разработки:
Загрузите сгенерированный файл .plist
, его имя основано на сгенерированном идентификаторе клиента. Переименуйте загруженный файл в GoogleService-Info.plist
и перетащите его в работающий редактор Xcode рядом с файлом Info.plist
в разделе Runner/Runner
в левой панели навигации. В диалоговом окне параметров Xcode выберите «Копировать элементы (при необходимости)», «Создать ссылки на папки» и «Добавить в целевой объект Runner» .
Выйдите из 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
. Запустите приложение, и после входа в систему вы увидите свои плейлисты.
Настройте google_sign_in
для веба
Вернитесь на страницу учетных данных вашего проекта API и создайте еще один идентификатор клиента OAuth, но на этот раз выберите Веб-приложение:
В оставшейся части формы заполните поля «Авторизованные источники JavaScript» следующим образом:
Это сгенерирует идентификатор клиента. Добавьте следующий meta
в web/index.html
, обновив его, чтобы включить сгенерированный идентификатор клиента:
web/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".
После повторного входа вы должны увидеть свои плейлисты:
8. Дальнейшие шаги
Поздравляю!
Вы выполнили практическую работу и создали адаптивное приложение Flutter, работающее на всех шести платформах, поддерживаемых Flutter. Вы адаптировали код с учётом различий в компоновке экранов, взаимодействии с текстом, загрузке изображений и работе аутентификации.
Вы можете адаптировать множество других функций в своих приложениях. Чтобы узнать о дополнительных способах адаптации кода к различным средам, в которых он будет работать, см. статью Создание адаптивных приложений .