Thông tin về lớp học lập trình này
1. Giới thiệu
Flutter là bộ công cụ giao diện người dùng của Google để xây dựng các ứng dụng gốc đẹp mắt cho thiết bị di động, nền tảng web và máy tính để bàn chỉ từ một bộ mã cơ sở. Trong lớp học lập trình này, bạn sẽ tìm hiểu cách xây dựng một ứng dụng Flutter thích ứng với nền tảng mà ứng dụng đó đang chạy, có thể là Android, iOS, web, Windows, macOS hoặc Linux.
Kiến thức bạn sẽ học được
- Cách phát triển một ứng dụng Flutter được thiết kế cho thiết bị di động để hoạt động trên cả 6 nền tảng mà Flutter hỗ trợ.
- Các API Flutter khác nhau để phát hiện nền tảng và thời điểm sử dụng từng API.
- Điều chỉnh cho phù hợp với các quy định hạn chế và kỳ vọng khi chạy ứng dụng trên web.
- Cách sử dụng nhiều gói cùng lúc để hỗ trợ toàn bộ nền tảng của Flutter.
Sản phẩm bạn sẽ tạo ra
Trong lớp học lập trình này, bạn sẽ bắt đầu xây dựng một ứng dụng Flutter dành cho Android và iOS để khám phá các danh sách phát trên YouTube của Flutter. Sau đó, bạn sẽ điều chỉnh ứng dụng này để hoạt động trên 3 nền tảng máy tính (Windows, macOS và Linux) bằng cách sửa đổi cách hiển thị thông tin theo kích thước của cửa sổ ứng dụng. Sau đó, bạn sẽ điều chỉnh ứng dụng cho web bằng cách cho phép người dùng chọn văn bản hiển thị trong ứng dụng, như người dùng web mong đợi. Cuối cùng, bạn sẽ thêm tính năng xác thực vào ứng dụng để có thể khám phá Danh sách phát của riêng mình, thay vì danh sách phát do nhóm Flutter tạo ra. Tính năng này yêu cầu các phương pháp xác thực khác nhau cho Android, iOS và web, so với ba nền tảng máy tính Windows, macOS và Linux.
Dưới đây là ảnh chụp màn hình ứng dụng Flutter trên Android và iOS:
Ứng dụng này chạy ở chế độ màn hình rộng trên macOS sẽ giống như ảnh chụp màn hình sau.
Lớp học lập trình này tập trung vào việc chuyển đổi một ứng dụng Flutter dành cho thiết bị di động thành một ứng dụng thích ứng hoạt động trên cả 6 nền tảng Flutter. Các khái niệm và khối mã không liên quan được tinh chỉnh và cung cấp cho bạn, chỉ cần sao chép và dán.
Bạn muốn tìm hiểu điều gì qua lớp học lập trình này?
2. Thiết lập môi trường phát triển Flutter
Bạn cần có hai phần mềm để hoàn thành lớp học này – SDK Flutter và một trình chỉnh sửa.
Bạn có thể chạy lớp học lập trình này bằng bất kỳ thiết bị nào sau đây:
- Thiết bị Android hoặc iOS thực được kết nối với máy tính và đặt thành Chế độ nhà phát triển.
- Trình mô phỏng iOS (yêu cầu cài đặt các công cụ Xcode).
- Trình mô phỏng Android (yêu cầu thiết lập trong Android Studio).
- Trình duyệt (bạn cần có Chrome để gỡ lỗi).
- Dưới dạng ứng dụng máy tính Windows, Linux hoặc macOS. Bạn phải phát triển trên nền tảng mà bạn dự định triển khai. Vì vậy, nếu muốn phát triển ứng dụng dành cho máy tính Windows, bạn phải phát triển trên Windows để truy cập vào chuỗi xây dựng thích hợp. Có các yêu cầu dành riêng cho hệ điều hành được đề cập chi tiết trên docs.flutter.dev/desktop.
3. Bắt đầu
Xác nhận môi trường phát triển
Cách dễ nhất để đảm bảo mọi thứ đã sẵn sàng cho quá trình phát triển là chạy lệnh sau:
flutter doctor
Nếu có bất kỳ mục nào xuất hiện mà không có dấu kiểm, hãy chạy lệnh sau để biết thêm thông tin chi tiết về vấn đề:
flutter doctor -v
Bạn có thể cần cài đặt các công cụ dành cho nhà phát triển để phát triển ứng dụng cho thiết bị di động hoặc máy tính. Để biết thêm thông tin chi tiết về cách định cấu hình công cụ tuỳ thuộc vào hệ điều hành lưu trữ, hãy xem tài liệu trong tài liệu cài đặt Flutter.
Tạo dự án Flutter
Một cách để bắt đầu viết Flutter cho ứng dụng máy tính là sử dụng công cụ dòng lệnh Flutter để tạo dự án Flutter. Ngoài ra, IDE của bạn có thể cung cấp quy trình tạo dự án Flutter thông qua giao diện người dùng.
$ 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.
Để đảm bảo mọi thứ đều hoạt động, hãy chạy ứng dụng Flutter nguyên mẫu dưới dạng ứng dụng di động như minh hoạ bên dưới. Ngoài ra, bạn có thể mở dự án này trong IDE và chạy công cụ của dự án để chạy ứng dụng. Nhờ bước trước, bạn chỉ có thể chạy dưới dạng ứng dụng máy tính.
$ 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=/
Bây giờ, bạn sẽ thấy ứng dụng đang chạy. Nội dung cần được cập nhật.
Để cập nhật nội dung, hãy cập nhật mã của bạn trong lib/main.dart
bằng mã sau. Để thay đổi nội dung hiển thị trong ứng dụng, hãy tải lại nóng.
- Nếu bạn chạy ứng dụng bằng dòng lệnh, hãy nhập
r
vào bảng điều khiển để tải lại nóng. - Nếu bạn chạy ứng dụng bằng IDE, ứng dụng sẽ tải lại khi bạn lưu tệp.
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';
}
}
}
Ứng dụng này được thiết kế để giúp bạn hiểu cách phát hiện và điều chỉnh cho phù hợp với nhiều nền tảng. Dưới đây là ứng dụng chạy gốc trên Android và iOS:
Và đây là cùng một mã chạy gốc trên macOS và bên trong Chrome, lại chạy trên macOS.
Điểm quan trọng cần lưu ý ở đây là thoạt nhìn, Flutter đang làm mọi thứ có thể để điều chỉnh nội dung cho phù hợp với màn hình đang chạy. Máy tính xách tay dùng để chụp những ảnh chụp màn hình này có màn hình Mac có độ phân giải cao. Đó là lý do cả phiên bản macOS và phiên bản web của ứng dụng đều được kết xuất ở Tỷ lệ pixel của thiết bị là 2. Trong khi đó, trên iPhone 12, bạn sẽ thấy tỷ lệ 3 và 2, 63 trên Pixel 2. Trong mọi trường hợp, văn bản hiển thị gần giống nhau, giúp công việc của chúng ta (với tư cách là nhà phát triển) trở nên dễ dàng hơn rất nhiều.
Điểm thứ hai cần lưu ý là hai tuỳ chọn để kiểm tra xem mã đang chạy trên nền tảng nào sẽ cho ra các giá trị khác nhau. Tuỳ chọn đầu tiên kiểm tra đối tượng Platform
được nhập từ dart:io
, trong khi tuỳ chọn thứ hai (chỉ có trong phương thức build
của Tiện ích) truy xuất đối tượng Theme
từ đối số BuildContext
.
Lý do hai phương thức này trả về kết quả khác nhau là do ý định của chúng khác nhau. Đối tượng Platform
được nhập từ dart:io
dùng để đưa ra quyết định độc lập với các lựa chọn kết xuất. Ví dụ điển hình về điều này là quyết định sử dụng trình bổ trợ nào, có thể khớp hoặc không khớp với cách triển khai gốc cho một nền tảng thực tế cụ thể.
Việc trích xuất Theme
từ BuildContext
dành cho các quyết định triển khai tập trung vào Giao diện. Một ví dụ điển hình về điều này là quyết định sử dụng thanh trượt Material hay thanh trượt Cupertino, như đã thảo luận trong Slider.adaptive
.
Trong phần tiếp theo, bạn sẽ xây dựng một ứng dụng khám phá danh sách phát cơ bản trên YouTube, được tối ưu hoá hoàn toàn cho Android và iOS. Trong các phần sau, bạn sẽ thêm nhiều nội dung điều chỉnh để ứng dụng hoạt động tốt hơn trên máy tính và web.
4. Tạo ứng dụng di động
Thêm gói
Trong ứng dụng này, bạn sẽ sử dụng nhiều gói Flutter để có quyền truy cập vào YouTube Data API, quản lý trạng thái và một chút giao diện.
$ 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.
Lệnh này sẽ thêm một số gói vào ứng dụng:
googleapis
: Thư viện Dart được tạo cung cấp quyền truy cập vào API của Google.http
: Thư viện để tạo các yêu cầu HTTP giúp ẩn sự khác biệt giữa trình duyệt gốc và trình duyệt web.provider
: Cung cấp tính năng quản lý trạng thái.url_launcher
: Cung cấp phương tiện để chuyển đến một video trong danh sách phát. Như đã hiển thị trong các phần phụ thuộc đã giải quyết,url_launcher
có các phương thức triển khai cho Windows, macOS, Linux và web, ngoài Android và iOS mặc định. Khi sử dụng gói này, bạn sẽ không cần tạo nền tảng dành riêng cho chức năng này.flex_color_scheme
: Cung cấp cho ứng dụng một bảng phối màu mặc định đẹp mắt. Để tìm hiểu thêm, hãy xem tài liệu về APIflex_color_scheme
.go_router
: Triển khai thao tác điều hướng giữa các màn hình. Gói này cung cấp một API dựa trên URL thuận tiện để điều hướng bằng Trình định tuyến của Flutter.
Định cấu hình ứng dụng di động cho url_launcher
Trình bổ trợ url_launcher
yêu cầu phải định cấu hình các ứng dụng trình chạy Android và iOS. Trong trình chạy Flutter dành cho iOS, hãy thêm các dòng sau vào từ điển plist
.
ios/Runner/Info.plist
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
<string>http</string>
<string>tel</string>
<string>mailto</string>
</array>
Trong trình chạy Android Flutter, hãy thêm các dòng sau vào Manifest.xml
. Thêm nút queries
này làm nút con trực tiếp của nút manifest
và là nút ngang hàng của nút 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>
Để biết thêm thông tin chi tiết về những thay đổi cấu hình bắt buộc này, hãy xem tài liệu về url_launcher
.
Truy cập vào YouTube Data API
Để truy cập vào API Dữ liệu YouTube nhằm liệt kê danh sách danh sách phát, bạn cần tạo một dự án API để tạo các Khoá API bắt buộc. Các bước này giả định rằng bạn đã có Tài khoản Google. Vì vậy, hãy tạo một tài khoản nếu bạn chưa có.
Chuyển đến Developer Console (Bảng điều khiển dành cho nhà phát triển) để tạo dự án API:
Sau khi tạo dự án, hãy chuyển đến trang Thư viện API. Trong hộp tìm kiếm, hãy nhập "youtube" rồi chọn youtube data api v3.
Trên trang chi tiết về YouTube Data API phiên bản 3, hãy bật API.
Sau khi bật API, hãy chuyển đến Trang thông tin xác thực rồi tạo Khoá API.
Sau vài giây, bạn sẽ thấy một hộp thoại có Khoá API mới toanh. Bạn sẽ sớm sử dụng khoá này.
Thêm mã
Trong phần còn lại của bước này, bạn sẽ cắt và dán nhiều mã để tạo một ứng dụng di động mà không có bất kỳ chú thích nào về mã. Mục đích của lớp học lập trình này là lấy ứng dụng di động và điều chỉnh ứng dụng đó cho phù hợp với cả máy tính và web. Để biết thêm thông tin chi tiết về cách tạo ứng dụng Flutter cho thiết bị di động, hãy xem bài viết Ứng dụng Flutter đầu tiên của bạn.
Thêm các tệp sau, trước tiên là đối tượng trạng thái cho ứng dụng.
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));
}
}
Tiếp theo, hãy thêm trang chi tiết của từng danh sách phát.
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,
),
),
],
);
}
}
Tiếp theo, hãy thêm danh sách danh sách phát.
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(),
);
},
),
);
},
);
}
}
Và thay thế nội dung của tệp main.dart
như sau:
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,
);
}
}
Bạn sắp sẵn sàng chạy mã này trên Android và iOS. Bạn chỉ cần thay đổi một điều nữa, đó là sửa đổi hằng số youTubeApiKey
bằng Khoá API YouTube được tạo ở bước trước.
lib/main.dart
// TODO: Replace with your YouTube API Key
const youTubeApiKey = 'AIzaNotAnApiKey';
Để chạy ứng dụng này trên macOS, bạn cần bật ứng dụng để tạo các yêu cầu HTTP như sau. Chỉnh sửa cả tệp DebugProfile.entitlements
và Release.entitilements
như sau:
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>
Chạy ứng dụng
Giờ đây, khi đã có một ứng dụng hoàn chỉnh, bạn có thể chạy ứng dụng đó thành công trên trình mô phỏng Android hoặc trình mô phỏng iPhone. Bạn sẽ thấy danh sách danh sách phát của Flutter. Khi chọn một danh sách phát, bạn sẽ thấy các video trong danh sách phát đó. Cuối cùng, nếu nhấp vào nút Phát, bạn sẽ được chuyển đến YouTube để xem video.
Tuy nhiên, nếu cố gắng chạy ứng dụng này trên máy tính, bạn sẽ thấy bố cục bị sai khi mở rộng thành cửa sổ có kích thước thông thường trên máy tính. Bạn sẽ tìm hiểu các cách thích ứng với điều này trong bước tiếp theo.
5. Điều chỉnh cho phù hợp với máy tính để bàn
Vấn đề về máy tính để bàn
Nếu chạy ứng dụng trên một trong các nền tảng máy tính gốc, Windows, macOS hoặc Linux, bạn sẽ nhận thấy một vấn đề thú vị. Mã này hoạt động, nhưng trông có vẻ ... lạ.
Cách khắc phục vấn đề này là thêm chế độ xem phân tách, liệt kê danh sách phát ở bên trái và video ở bên phải. Tuy nhiên, bạn chỉ muốn bố cục này hoạt động khi mã không chạy trên Android hoặc iOS và cửa sổ đủ rộng. Hướng dẫn sau đây cho biết cách triển khai chức năng này.
Trước tiên, hãy thêm gói split_view
để hỗ trợ tạo bố cục.
$ 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.
Giới thiệu tiện ích thích ứng
Mẫu bạn sẽ sử dụng trong lớp học lập trình này là giới thiệu các tiện ích Thích ứng giúp đưa ra lựa chọn triển khai dựa trên các thuộc tính như chiều rộng màn hình, giao diện nền tảng, v.v. Trong trường hợp này, bạn sẽ giới thiệu một tiện ích AdaptivePlaylists
giúp hoạt động lại cách Playlists
và PlaylistDetails
tương tác. Chỉnh sửa tệp lib/main.dart
như sau:
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,
);
}
}
Tiếp theo, hãy tạo tệp cho tiện ích 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')),
},
],
),
);
}
}
Tệp này rất thú vị vì một số lý do. Trước tiên, ứng dụng này sử dụng cả chiều rộng của cửa sổ (bằng MediaQuery.of(context).size.width
) và bạn đang kiểm tra giao diện (bằng Theme.of(context).platform
) để quyết định hiển thị bố cục rộng với tiện ích SplitView
hay hiển thị hẹp mà không có tiện ích này.
Thứ hai, phần này đề cập đến việc xử lý điều hướng được mã hoá cứng. Phương thức này hiển thị một đối số gọi lại trong tiện ích Playlists
. Lệnh gọi lại đó sẽ thông báo cho mã xung quanh rằng người dùng đã chọn một danh sách phát. Sau đó, mã cần thực hiện công việc để hiển thị danh sách phát đó. Điều này thay đổi nhu cầu về Scaffold
trong tiện ích Playlists
và PlaylistDetails
. Giờ đây, các tiện ích này không còn ở cấp cao nhất nên bạn phải xoá Scaffold
khỏi các tiện ích đó.
Tiếp theo, hãy chỉnh sửa tệp src/lib/playlists.dart
sao cho khớp với mã sau:
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);
},
),
);
},
);
}
}
Có rất nhiều thay đổi trong tệp này. Ngoài việc giới thiệu lệnh gọi lại playlistSelected
và xoá tiện ích Scaffold
như đã đề cập, tiện ích _PlaylistsListView
được chuyển đổi từ không có trạng thái sang có trạng thái. Bạn phải thực hiện thay đổi này do việc giới thiệu một ScrollController
thuộc sở hữu phải được tạo và huỷ.
Việc giới thiệu ScrollController
rất thú vị vì bạn bắt buộc phải sử dụng nó trên bố cục rộng, trong đó có hai tiện ích ListView
cạnh nhau. Trên điện thoại di động, thông thường bạn sẽ có một ListView
duy nhất, do đó, có thể có một ScrollController
tồn tại lâu dài mà tất cả ListView
đều đính kèm và tách ra trong vòng đời riêng của chúng. Máy tính là một môi trường khác, nơi nhiều ListView
cạnh nhau là hợp lý.
Cuối cùng, hãy chỉnh sửa tệp lib/src/playlist_details.dart
như sau:
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,
),
),
],
);
}
}
Tương tự như tiện ích Playlists
ở trên, tệp này cũng có các thay đổi để loại bỏ tiện ích Scaffold
và giới thiệu ScrollController
thuộc sở hữu.
Chạy lại ứng dụng!
Chạy ứng dụng trên máy tính mà bạn chọn, có thể là Windows, macOS hoặc Linux. Giờ đây, ứng dụng sẽ hoạt động như mong đợi.
6. Thích ứng với web
Có chuyện gì với những hình ảnh đó vậy?
Việc cố gắng chạy ứng dụng này trên web hiện cần nhiều công sức hơn để thích ứng với trình duyệt web.
Nếu xem qua bảng điều khiển gỡ lỗi, bạn sẽ thấy một gợi ý nhẹ nhàng về việc bạn phải làm tiếp theo.
══╡ 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) ════════════════════════════════════════════════════════════════════════════════════════════════════
Tạo proxy CORS
Một cách để giải quyết các vấn đề về kết xuất hình ảnh là giới thiệu dịch vụ web proxy để thêm các tiêu đề Chia sẻ tài nguyên trên nhiều nguồn gốc bắt buộc. Mở một cửa sổ dòng lệnh và tạo máy chủ web Dart như sau:
$ 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
Thay đổi thư mục thành máy chủ yt_cors_proxy
và thêm một số phần phụ thuộc bắt buộc:
$ 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.
Một số phần phụ thuộc hiện tại không còn bắt buộc nữa. Cắt bớt các phần này như sau:
$ 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.
Tiếp theo, hãy sửa đổi nội dung của tệp server.dart để khớp với nội dung sau:
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}');
}
Bạn có thể chạy máy chủ này như sau:
$ dart run bin/server.dart Server listening on port 8080
Ngoài ra, bạn có thể tạo hình ảnh này dưới dạng hình ảnh Docker và chạy hình ảnh Docker thu được như sau:
$ 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
Tiếp theo, hãy sửa đổi mã Flutter để tận dụng proxy CORS này, nhưng chỉ khi chạy trong trình duyệt web.
Một cặp tiện ích có thể thích ứng
Cặp tiện ích đầu tiên là cách ứng dụng của bạn sẽ sử dụng proxy CORS.
lib/src/adaptive_image.dart
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class AdaptiveImage extends StatelessWidget {
AdaptiveImage.network(String url, {super.key}) {
if (kIsWeb) {
_url = Uri.parse(
url,
).replace(host: 'localhost', port: 8080, scheme: 'http').toString();
} else {
_url = url;
}
}
late final String _url;
@override
Widget build(BuildContext context) {
return Image.network(_url);
}
}
Ứng dụng này sử dụng hằng số kIsWeb
do sự khác biệt về nền tảng thời gian chạy. Tiện ích thích ứng khác sẽ thay đổi ứng dụng để hoạt động giống như các trang web khác. Người dùng trình duyệt mong muốn có thể chọn văn bản.
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),
};
}
}
Bây giờ, hãy áp dụng các nội dung điều chỉnh này trên toàn bộ cơ sở mã:
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,
),
),
],
);
}
}
Trong mã trên, bạn đã điều chỉnh cả tiện ích Image.network
và Text
. Tiếp theo, hãy điều chỉnh tiện ích 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);
},
),
);
},
);
}
}
Lần này, bạn chỉ điều chỉnh tiện ích Image.network
mà để nguyên hai tiện ích Text
. Điều này là có chủ ý vì nếu bạn điều chỉnh các tiện ích Văn bản, chức năng onTap
của ListTile
sẽ bị chặn khi người dùng nhấn vào văn bản.
Chạy ứng dụng trên web một cách chính xác
Khi proxy CORS đang chạy, bạn có thể chạy phiên bản web của ứng dụng và ứng dụng sẽ có giao diện như sau:
7. Xác thực thích ứng
Ở bước này, bạn sẽ mở rộng ứng dụng bằng cách cho phép ứng dụng xác thực người dùng, sau đó hiển thị danh sách phát của người dùng đó. Bạn sẽ phải sử dụng nhiều trình bổ trợ để hỗ trợ nhiều nền tảng mà ứng dụng có thể chạy trên đó, vì cách xử lý OAuth trên Android, iOS, web, Windows, macOS và Linux rất khác nhau.
Thêm trình bổ trợ để bật tính năng xác thực bằng Google
Bạn sẽ cài đặt 3 gói để xử lý tính năng xác thực của 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.
Để xác thực trên Windows, macOS và Linux, hãy sử dụng gói googleapis_auth
. Các nền tảng máy tính này xác thực bằng trình duyệt web. Để xác thực trên Android, iOS và web, hãy sử dụng các gói google_sign_in
và extension_google_sign_in_as_googleapis_auth
. Gói thứ hai đóng vai trò là một trình bổ trợ tương tác giữa hai gói.
Cập nhật mã
Bắt đầu cập nhật bằng cách tạo một thành phần trừu tượng mới có thể sử dụng lại, tiện ích AdaptiveLogin. Tiện ích này được thiết kế để bạn sử dụng lại, do đó, bạn cần phải định cấu hình một số thông tin:
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()));
}
}
Tệp này có nhiều chức năng. Phương thức build
của AdaptiveLogin
sẽ thực hiện phần việc nặng nhọc. Khi gọi cả kIsWeb
và Platform.isXXX
của dart:io
, phương thức này sẽ kiểm tra nền tảng thời gian chạy. Đối với Android, iOS và web, lớp này tạo bản sao cho tiện ích trạng thái _GoogleSignInLogin
. Đối với Windows, macOS và Linux, lớp này tạo bản sao của tiện ích trạng thái _GoogleApisAuthLogin
.
Bạn cần có thêm cấu hình để sử dụng các lớp này. Cấu hình này sẽ xuất hiện sau, sau khi bạn cập nhật phần còn lại của cơ sở mã để sử dụng tiện ích mới này. Bắt đầu bằng cách đổi tên FlutterDevPlaylists
thành AuthedUserPlaylists
để phản ánh rõ hơn mục đích mới của lớp này, đồng thời cập nhật mã để phản ánh rằng http.Client
hiện được truyền sau khi tạo. Cuối cùng, lớp _ApiKeyClient
không còn bắt buộc nữa:
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
Tiếp theo, hãy cập nhật tiện ích PlaylistDetails
bằng tên mới cho đối tượng trạng thái ứng dụng được cung cấp:
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);
},
);
}
}
Tương tự, hãy cập nhật tiện ích 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,
);
},
);
}
}
Cuối cùng, hãy cập nhật tệp main.dart
để sử dụng đúng tiện ích AdaptiveLogin
mới:
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,
);
}
}
Các thay đổi trong tệp này phản ánh sự thay đổi từ việc chỉ hiển thị danh sách phát YouTube của Flutter sang hiển thị danh sách phát của người dùng đã xác thực. Mặc dù mã hiện đã hoàn tất, nhưng vẫn cần thực hiện một loạt sửa đổi đối với tệp này và các tệp trong ứng dụng Runner tương ứng để định cấu hình đúng cách các gói google_sign_in
và googleapis_auth
cho việc xác thực.
Ứng dụng hiện hiển thị danh sách phát trên YouTube của người dùng đã được xác thực. Sau khi hoàn tất các tính năng, bạn cần bật tính năng xác thực. Để thực hiện việc này, hãy định cấu hình các gói google_sign_in
và googleapis_auth
. Để định cấu hình các gói, bạn cần thay đổi tệp main.dart
và các tệp cho ứng dụng Runner.
Định cấu hình googleapis_auth
Bước đầu tiên để định cấu hình xác thực là xoá Khoá API mà bạn đã định cấu hình và sử dụng trước đó. Chuyển đến trang thông tin xác thực của dự án API rồi xoá khoá API:
Thao tác này sẽ tạo một hộp thoại mà bạn xác nhận bằng cách nhấn vào nút Xoá:
Sau đó, hãy tạo mã ứng dụng khách OAuth:
Đối với Loại ứng dụng, hãy chọn Ứng dụng máy tính.
Chấp nhận tên rồi nhấp vào Tạo.
Thao tác này sẽ tạo Mã ứng dụng khách và Mật khẩu ứng dụng khách mà bạn phải thêm vào lib/main.dart
để định cấu hình luồng googleapis_auth
. Một chi tiết triển khai quan trọng là luồng googleapis_auth sử dụng một máy chủ web tạm thời chạy trên máy chủ cục bộ để thu thập mã thông báo OAuth đã tạo. Trên macOS, bạn cần sửa đổi tệp 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>
Bạn không cần chỉnh sửa tệp macos/Runner/DebugProfile.entitlements
vì tệp này đã có quyền cho com.apple.security.network.server
để bật tính năng Tải lại nhanh và công cụ gỡ lỗi máy ảo Dart.
Giờ đây, bạn có thể chạy ứng dụng trên Windows, macOS hoặc Linux (nếu ứng dụng được biên dịch trên các mục tiêu đó).
Định cấu hình google_sign_in
cho Android
Quay lại trang thông tin xác thực của dự án API và tạo một mã ứng dụng khách OAuth khác, ngoại trừ lần này, hãy chọn Android:
Đối với phần còn lại của biểu mẫu, hãy điền vào Package name (Tên gói) bằng gói được khai báo trong android/app/src/main/AndroidManifest.xml
. Nếu bạn đã làm theo hướng dẫn từng bước, thì giá trị này sẽ là com.example.adaptive_app
. Trích xuất dấu vân tay chứng chỉ SHA-1 bằng cách làm theo hướng dẫn trên trang trợ giúp của Google Cloud Console:
Điều này là đủ để ứng dụng hoạt động trên Android. Tuỳ thuộc vào lựa chọn API của Google mà bạn sử dụng, bạn có thể cần thêm tệp JSON đã tạo vào gói ứng dụng.
Định cấu hình google_sign_in
cho iOS
Quay lại trang thông tin xác thực của dự án API và tạo một mã ứng dụng khách OAuth khác, ngoại trừ lần này, hãy chọn iOS:
Đối với phần còn lại của biểu mẫu, hãy điền Mã nhận dạng gói bằng cách mở ios/Runner.xcworkspace
trong Xcode. Chuyển đến Project Navigator (Trình điều hướng dự án), chọn Runner (Trình chạy) trong trình điều hướng, sau đó chọn thẻ General (Chung) và sao chép Bundle Identifier (Mã nhận dạng gói). Nếu bạn đã làm theo từng bước trong lớp học lập trình này, thì giá trị này sẽ là com.example.adaptiveApp
.
Đối với các phần còn lại của biểu mẫu, hãy điền Mã nhận dạng gói. Mở ios/Runner.xcworkspace
trong Xcode. Chuyển đến Project Navigator (Trình điều hướng dự án). Chuyển đến Runner (Trình chạy) > thẻ General (Chung). Sao chép Mã nhận dạng gói. Nếu bạn đã làm theo từng bước trong lớp học lập trình này, thì giá trị của biến này sẽ là com.example.adaptiveApp
.
Bỏ qua Mã cửa hàng ứng dụng và Mã nhóm vì hiện tại, bạn không cần đến các mã này để phát triển cục bộ:
Tải tệp .plist
đã tạo xuống, tên của tệp này dựa trên mã ứng dụng mà bạn đã tạo. Đổi tên tệp đã tải xuống thành GoogleService-Info.plist
, sau đó kéo tệp đó vào trình soạn thảo Xcode đang chạy, cùng với tệp Info.plist
trong Runner/Runner
trong thanh điều hướng bên trái. Đối với hộp thoại tuỳ chọn trong Xcode, hãy chọn Sao chép mục nếu cần, Tạo tệp tham chiếu thư mục và mục tiêu Thêm vào Runner.
Sau đó, hãy thoát khỏi Xcode, trong IDE mà bạn chọn, hãy thêm nội dung sau vào 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>
Bạn cần chỉnh sửa giá trị này để khớp với mục nhập trong tệp GoogleService-Info.plist
đã tạo. Chạy ứng dụng và sau khi đăng nhập, bạn sẽ thấy danh sách phát của mình.
Định cấu hình google_sign_in
cho web
Quay lại trang thông tin xác thực của dự án API và tạo một mã ứng dụng khách OAuth khác, ngoại trừ lần này, hãy chọn Ứng dụng web:
Đối với phần còn lại của biểu mẫu, hãy điền vào phần Nguồn JavaScript được uỷ quyền như sau:
Thao tác này sẽ tạo một Mã ứng dụng khách. Thêm thẻ meta
sau vào web/index.html
, cập nhật để thêm Mã ứng dụng khách đã tạo:
web/index.html
<meta name="google-signin-client_id" content="YOUR_GOOGLE_SIGN_IN_OAUTH_CLIENT_ID.apps.googleusercontent.com">
Bạn cần được hướng dẫn một chút để chạy mẫu này. Bạn cần chạy proxy CORS mà bạn đã tạo ở bước trước và chạy ứng dụng web Flutter trên cổng được chỉ định trong biểu mẫu mã ứng dụng khách OAuth của ứng dụng web bằng cách làm theo hướng dẫn sau.
Trong một cửa sổ dòng lệnh, hãy chạy máy chủ proxy CORS như sau:
$ dart run bin/server.dart Server listening on port 8080
Trong một cửa sổ dòng lệnh khác, hãy chạy ứng dụng Flutter như sau:
$ 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".
Sau khi đăng nhập lại, bạn sẽ thấy các danh sách phát của mình:
8. Các bước tiếp theo
Xin chúc mừng!
Bạn đã hoàn tất lớp học lập trình và tạo một ứng dụng Flutter thích ứng chạy trên cả 6 nền tảng mà Flutter hỗ trợ. Bạn đã điều chỉnh mã để xử lý sự khác biệt về cách bố trí màn hình, cách tương tác với văn bản, cách tải hình ảnh và cách xác thực hoạt động.
Bạn có thể điều chỉnh nhiều thứ khác trong ứng dụng của mình. Để tìm hiểu thêm các cách điều chỉnh mã cho phù hợp với nhiều môi trường mà mã sẽ chạy, hãy xem bài viết Tạo ứng dụng thích ứng.