Ứng dụng thích ứng trong Flutter

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 những ứng dụng đẹp mắt, được biên dịch tự nhiên cho thiết bị di động, web và máy tính chỉ từ một cơ sở mã duy nhất. Trong lớp học lập trình này, bạn sẽ tìm hiểu cách tạo một ứng dụng Flutter có khả năng thích ứng với nền tảng mà ứng dụng này đ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 để có thể 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.
  • Thích ứng với các hạn chế và kỳ vọng khi chạy một ứ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, ban đầu bạn sẽ 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 thông tin hiển thị dựa trên kích thước của cửa sổ ứng dụng. Sau đó, bạn sẽ điều chỉnh ứng dụng cho phù hợp với web bằng cách làm cho văn bản hiển thị trong ứng dụng ở chế độ có thể chọn, đúng như người dùng web mong đợi. Cuối cùng, bạn sẽ thêm phương thức xác thực vào ứng dụng để có thể khám phá các Danh sách phát của riêng mình, khác với các danh sách phát do nhóm Flutter tạo. Quy trình này đòi hỏi nhiều phương pháp xác thực dành cho Android, iOS và web, so với 3 nền tảng dành cho máy tính là Windows, macOS và Linux.

Dưới đây là ảnh chụp màn hình của ứng dụng Flutter trên Android và iOS:

Ứng dụng đã hoàn thiện đang chạy trên trình mô phỏng Android

Ứng dụng đã hoàn thiện đang chạy trên trình mô phỏng iOS

Ứng dụng chạy ở màn hình rộng trên macOS sẽ giống như ảnh chụp màn hình sau đây.

Ứng dụng đã hoàn thiện chạy trên macOS

Lớp học lập trình này tập trung vào việc chuyển đổi ứ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 được 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 gì từ lớp học lập trình này?

Tôi mới biết đến chủ đề này và tôi muốn có thông tin tổng quan đầy đủ. Tôi đã biết đôi chút về chủ đề này, nhưng tôi muốn ôn lại kiến thức. Tôi đang tìm mã mẫu để sử dụng trong dự án của mình. Tôi muốn được giải thích cụ thể hơn.

2. Thiết lập môi trường phát triển Flutter

Bạn cần có 2 phần mềm để hoàn thành phòng thí nghiệm này – Flutter SDKtrình chỉnh sửa.

Bạn có thể chạy lớp học lập trình bằng bất kỳ thiết bị nào sau đây:

  • Một thiết bị Android hoặc iOS thực kết nối với máy tính của bạn và được đặt ở Chế độ nhà phát triển.
  • Trình mô phỏng iOS (yêu cầu cài đặt 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 (cần có Chrome để gỡ lỗi).
  • Dưới dạng ứng dụng 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 một ứng dụng Windows dành cho máy tính, bạn phải phát triển trên Windows để truy cập vào chuỗi bản dựng phù hợp. Có các yêu cầu cụ thể theo hệ điều hành được đề cập chi tiết trên docs.flutter.dev/desktop.

3. Bắt đầu

Đang xác nhận môi trường phát triển

Cách dễ nhất để đảm bảo mọi thứ đều sẵn sàng cho việc phát triển, vui lòng chạy lệnh sau:

$ flutter doctor

Nếu có bất kỳ nội dung nào hiển thị nhưng không có dấu kiểm, vui lòng làm theo các bước sau để biết thêm chi tiết về vấn đề:

$ flutter doctor -v

Có thể bạn sẽ cần cài đặt các công cụ cho nhà phát triển để phát triển 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ỳ theo hệ điều hành của máy chủ lưu trữ, vui lòng xem tài liệu trong tài liệu về cách cài đặt Flutter.

Tạo một dự án Flutter

Một cách dễ dàng để bắt đầu viết ứng dụng Flutter dành cho máy tính là sử dụng công cụ dòng lệnh Flutter để tạo một 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 của dự án đó.

$ 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ứ 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ạ dưới đây. Ngoài ra, hãy mở dự án này trong IDE rồi sử dụng công cụ của dự án để chạy ứng dụng. Nhờ bước trước, việc chạy dưới dạng ứng dụng dành cho máy tính chỉ nên là lựa chọn duy nhất mà bạn có thể sử dụng.

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

Lúc này, 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ã sau trong lib/main.dart bằng đoạn mã sau. Để thay đổi nội dung mà ứng dụng của bạn hiển thị, hãy thực hiện 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 nhanh.
  • Nếu bạn chạy ứng dụng bằng IDE, thì ứ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 ở trên được thiết kế để giúp bạn dự đoán cách phát hiện và điều chỉnh các nền tảng khác nhau. Dưới đây là ứng dụng gốc chạy trên Android và iOS:

Đang hiển thị các thuộc tính cửa sổ trên trình mô phỏng Android

Đang hiển thị các thuộc tính cửa sổ trên trình mô phỏng iOS

Đây là cùng một mã đang chạy vốn có trên macOS và bên trong Chrome, nhưng lại chạy lại trên macOS.

Đang hiện các thuộc tính cửa sổ trên macOS

Đang hiển thị các thuộc tính của cửa sổ trong trình duyệt Chrome

Điểm quan trọng cần lưu ý ở đây là thoạt nhìn, Flutter sẽ làm những gì có thể để điều chỉnh nội dung cho phù hợp với màn hình mà nó đang chạy. Máy tính xách tay mà bạn chụp những ảnh chụp màn hình này có màn hình Mac với độ phân giải cao. Đó là lý do mà cả phiên bản macOS và web của ứng dụng này đều hiển thị ở Tỷ lệ Pixel của thiết bị là 2. Trong khi đó, trên iPhone 12, bạn 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 như tương tự nhau, giúp cho công việc của chúng tôi với tư cách 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ẽ trả về 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 khiến 2 phương thức này trả về kết quả khác nhau là vì ý định của chúng khác nhau. Đối tượng Platform được nhập từ dart:io dùng để đưa ra các quyết định độc lập với các lựa chọn hiển thị. Một ví dụ điển hình cho vấn đề này là việc quyết định sử dụng trình bổ trợ nào, trình bổ trợ nào có thể có hoặc không có cách triển khai gốc phù hợp cho một nền tảng thực cụ thể.

Việc trích xuất Theme từ BuildContext nhằm phục vụ các quyết định triển khai tập trung vào Giao diện. Một ví dụ chính cho trường hợp 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 cơ bản để khám phá danh sách phát trên YouTube. Ứng dụng này đượ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 cách điều chỉnh để giúp ứng dụng hoạt động tốt hơn trên máy tính và web.

4. Tạo ứng dụng dành cho thiết bị di động

Thêm gói

Trong ứng dụng này, bạn sẽ dùng nhiều gói Flutter để có quyền truy cập vào YouTube Data API, tính năng quản lý trạng thái và một số giao diện.

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

Lệnh này sẽ thêm một số gói vào ứng dụng:

  • googleapis: Một thư viện Dart được tạo, cung cấp quyền truy cập vào API của Google.
  • http: Một thư viện để tạo yêu cầu HTTP giúp ẩn những khác biệt giữa trình duyệt web và trình duyệt gốc.
  • 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ư minh hoạ trong các phần phụ thuộc đã phân giải, ngoài Android và iOS mặc định, url_launcher còn có các cách triển khai cho Windows, macOS, Linux và web. Khi 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 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ề API flex_color_scheme.
  • go_router: Triển khai tính năng điều hướng giữa các màn hình. Gói này cung cấp một API thuận tiện dựa trên URL để điều hướng bằng Bộ đị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 cấu hình cho các ứng dụng chạy Android và iOS. Trong trình chạy Flutter của 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 Flutter của Android, 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à một 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 về những thay đổi bắt buộc đối với cấu hình, vui lòng xem tài liệu về url_launcher.

Truy cập vào YouTube Data API

Để truy cập vào YouTube Data API nhằm liệt kê 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ó tài khoản.

Chuyển đến Bảng điều khiển dành cho nhà phát triển để tạo dự án API:

Hiển thị bảng điều khiển của GCP trong quy trình tạo dự án

Sau khi bạn đã có một 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.

Chọn YouTube Data API phiên bản 3 trong bảng điều khiển của GCP

Trên trang chi tiết của YouTube Data API phiên bản 3, hãy bật API này.

5a877ea82b83ae42.pngS

Sau khi bật API này, hãy chuyển đến trang Thông tin đăng nhập rồi tạo Khoá API.

Tạo thông tin đăng nhập trong bảng điều khiển GCP

Sau vài giây, bạn sẽ thấy một hộp thoại với Khoá API mới sáng bóng. Bạn sẽ sớm sử dụng khoá này.

Cửa sổ bật lên do khoá API tạo hiển thị khoá API đã tạo

Thêm mã

Trong phần còn lại của bước này, bạn sẽ cắt nhiều đoạn mã để tạo ứng dụng di động mà không cần bình luận về đoạn mã đó. Mục đích của lớp học lập trình này là lấy và điều chỉnh ứng dụng di động cho phù hợp với cả máy tính và web. Để được giới thiệu chi tiết hơn về cách xây dựng các ứng dụng Flutter dành cho thiết bị di động, vui lòng xem bài viết Viết ứng dụng Flutter đầu tiên, phần 1, phần 2Xây dựng giao diện người dùng đẹp mắt bằng Flutter.

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

Bạn sắp có thể chạy mã này trên Android và iOS. Chỉ còn một điều nữa cần thay đổi, đó là sửa đổi hằng số youTubeApiKey trên dòng 14 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 để thực hiện các yêu cầu HTTP như sau. Chỉnh sửa cả hai tệp DebugProfile.entitlementsRelease.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ó ứng dụng hoàn chỉnh, bạn có thể chạy thành công ứng dụng đó trên trình mô phỏng Android hoặc trình mô phỏng iPhone. Bạn sẽ thấy một danh sách các 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 giao diện YouTube để xem video.

Ứng dụng hiển thị danh sách phát cho tài khoản YouTube FlutterDev

Hiển thị video trong một danh sách phát cụ thể

Một video đã chọn đang phát trong trình phát YouTube

Tuy nhiên, nếu muốn chạy ứng dụng này trên máy tính để bàn, bạn sẽ thấy bố cục không chính xác khi được 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. Bạn sẽ tìm cách thích ứng với điều này trong bước tiếp theo.

5. Thích ứng với máy tính

Vấn đề về máy tính để bàn

Nếu chạy ứng dụng này trên một trong các nền tảng gốc dành cho máy tính, Windows, macOS hoặc Linux, bạn sẽ nhận thấy một vấn đề thú vị. Hoạt động được, nhưng trông có vẻ hơi kỳ lạ.

Ứng dụng chạy trên macOS cho thấy danh sách danh sách phát, trông có vẻ kỳ cục

Video trong một danh sách phát trên macOS

Giải pháp cho trường hợp 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 kích hoạt khi mã không chạy trên Android hoặc iOS và cửa sổ đủ rộng. Các hướng dẫn sau đây trình bày 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...
+ split_view 3.1.0
  test_api 0.4.3 (0.4.8 available)
Changed 1 dependency!

Giới thiệu về 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 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 điều chỉnh cách PlaylistsPlaylistDetails 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,
        useMaterial3: true,
      ).toTheme,
      darkTheme: FlexColorScheme.dark(
        scheme: FlexScheme.red,
        useMaterial3: true,
      ).toTheme,
      themeMode: ThemeMode.dark, // Or ThemeMode.System if you'd prefer
      debugShowCheckedModeBanner: false,
      routerConfig: _router,
    );
  }
}

Tiếp theo, hãy tạo tệp cho tiện ích Danh sách phát thích ứng:

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 thú vị vì một vài lý do. Trước tiên, trang này sử dụng cả chiều rộng của cửa sổ (sử dụ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 xem sẽ hiển thị bố cục rộng với tiện ích SplitView hay màn hình hẹp không có bố cục 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 này 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 sẽ thay đổi nhu cầu sử dụng Scaffold trong các tiện ích PlaylistsPlaylistDetails. Vì chúng không phải 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 như 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 phần giới thiệu nêu trên về lệnh gọi lại playlistSelected và việc loại bỏ tiện ích Scaffold, tiện ích _PlaylistsListView được chuyển đổi từ không có trạng thái sang có trạng thái. Thay đổi này là bắt buộc do có một ScrollController được sở hữu phải được tạo và huỷ bỏ.

Việc giới thiệu ScrollController rất thú vị vì điều này là bắt buộc vì trên một bố cục rộng, bạn có 2 tiện ích ListView cạnh nhau. Trên điện thoại di động, thường có một ListView duy nhất. Do đó, có thể có một ScrollController lâu dài mà tất cả ListView đều có thể đính kèm và tách ra trong suốt vòng đời của riêng chúng. Máy tính thì khác, trong một thế giới nơi có nhiều ListView cạnh nhau là điều 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,
          ),
        ),
      ],
    );
  }
}

Giống như tiện ích Playlists ở trên, tệp này cũng có các thay đổi về việc loại bỏ tiện ích Scaffold và giới thiệu một ScrollController đượ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. Lúc này, chế độ này sẽ hoạt động như dự kiến.

Ứng dụng chạy trên macOS với chế độ xem phân tách

6. Thích ứng với web

Chuyện gì xảy ra với những hình ảnh đó nhỉ?

Việc cố gắng chạy ứng dụng này trên web giờ đây sẽ cho thấy việc cần làm thêm để thích ứng với trình duyệt web.

Ứng dụng chạy trong trình duyệt Chrome, không có hình thu nhỏ của ảnh trên YouTube

Nếu xem nhanh bảng điều khiển gỡ lỗi, bạn sẽ thấy một gợi ý nhẹ nhàng cho biết những việc cần 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 để xử lý vấn đề hiển thị hình ảnh là dùng dịch vụ web proxy để thêm tiêu đề bắt buộc của tính năng Chia sẻ tài nguyên trên nhiều nguồn gốc. Hiển thị 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 vào 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.

Có một số phần phụ thuộc hiện tại không cần thiết nữa. Hãy cắt bớt những 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 cho phù hợp với thông tin 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 Docker 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ể điều chỉnh

Cặp tiện ích đầu tiên là cách ứng dụng của bạn 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 dùng hằng số kIsWeb do những khác biệt về nền tảng thời gian chạy. Tiện ích có thể điều chỉnh khác sẽ thay đổi ứng dụng để hoạt độ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 mở rộng các đ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.networkText. 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, nhưng giữ nguyên 2 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 đúng cách

Khi proxy CORS đang chạy, bạn có thể chạy phiên bản web của ứng dụng và có dạng như sau:

Ứng dụng chạy trong trình duyệt Chrome, với hình thu nhỏ hình ảnh trên YouTube được điền sẵn

7. Xác thực thích ứng

Trong bước này, bạn sẽ mở rộng ứng dụng bằng cách cấp cho ứng dụng khả nă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ợ để chạy trên nhiều nền tảng mà ứng dụng có thể chạy, vì việc xử lý OAuth được thực hiện rất khác nhau giữa Android, iOS, web, Windows, macOS và Linux.

Thêm trình bổ trợ để bật tính năng xác thực của Google

Bạn sẽ cài đặt 3 gói để xử lý phương thức 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 qua 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_inextension_google_sign_in_as_googleapis_auth. Gói thứ hai hoạt động như một miếng đệm khả năng tương tác giữa 2 gói.

Cập nhật đoạn mã

Bắt đầu quá trình cập nhật bằng cách tạo một đối tượng trừu tượng mới có thể sử dụng lại, đó là tiện ích AdaptiveLogin. Tiện ích này được thiết kế để bạn sử dụng lại và do đó đòi hỏi một số cấu hình:

lib/src/adaptive_login.dart

import 'dart:io' show Platform;

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

import 'app_state.dart';

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

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

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

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

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

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

  final _AdaptiveLoginButtonWidget button;
  final List<String> scopes;

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

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

  late final GoogleSignIn _googleSignIn;

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

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

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

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

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

  Uri? _authUrl;

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

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

Tệp này có rất nhiều chức năng. Phương thức build của AdaptiveLogin sẽ thực hiện phần việc khó khăn. Khi gọi cả kIsWebdart:io của Platform.isXXX, 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, hệ thống sẽ tạo thực thể cho tiện ích có trạng thái _GoogleSignInLogin. Đối với Windows, macOS và Linux, hệ thống sẽ tạo thực thể cho một tiện ích có trạng thái _GoogleApisAuthLogin.

Bạn cần phải định cấu hình bổ sung để sử dụng các lớp này. Bạn sẽ phải định cấu hình sau này sau khi 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 việc đổi tên FlutterDevPlaylists thành AuthedUserPlaylists để phản ánh chính xác hơn mục đích mới của nó trong cuộc sống, đồ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 đã 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({required this.playlistSelected, super.key});

  final PlaylistsListSelected playlistSelected;

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

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

Cuối cùng, hãy cập nhật tệp main.dart để sử dụng chính xác 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,
        useMaterial3: true,
      ).toTheme,
      darkTheme: FlexColorScheme.dark(
        scheme: FlexScheme.red,
        useMaterial3: true,
      ).toTheme,
      themeMode: ThemeMode.dark,                       // Or ThemeMode.System
      debugShowCheckedModeBanner: false,
      routerConfig: _router,
    );
  }
}

Nội dung thay đổi trong tệp này phản ánh sự thay đổi từ chỉ hiển thị danh sách phát trên 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 bạn vẫn cần sửa đổ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 gói google_sign_ingoogleapis_auth nhằm xác thực.

Giờ đây, ứng dụng sẽ hiển thị danh sách phát trên YouTube từ người dùng đã xác thực. Khi các tính năng đã hoàn tất, bạn cần phải 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_ingoogleapis_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.

Đang cấu hình googleapis_auth

Bước đầu tiên để định cấu hình tính năng 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 và xoá khoá API:

Trang thông tin đăng nhập của dự án API trong bảng điều khiển GCP

Thao tác này sẽ tạo ra một cửa sổ bật lên mà bạn xác nhận bằng cách nhấn vào nút Xoá:

Cửa sổ bật lên Xoá thông tin xác thực

Sau đó, hãy tạo một mã ứng dụng khách OAuth:

Tạo mã ứng dụng OAuth

Đối với Loại ứng dụng, hãy chọn Ứng dụng dành cho máy tính.

Chọn loại ứng dụng Ứng dụng dành cho máy tính

Chấp nhận tên rồi nhấp vào Tạo.

Đặt tên mã ứng dụng khách

Thao tác này sẽ tạo Mã ứng dụng khách và Mật khẩu ứng dụng mà bạn phải thêm vào lib/main.dart để định cấu hình quy trình googleapis_auth. Một chi tiết triển khai quan trọng là quy trình googleapis_auth sử dụng một máy chủ web tạm thời chạy trên localhost để thu thập mã thông báo OAuth đã tạo mà trên macOS yêu cầu phải 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 thực hiện nội dung chỉnh sửa này đối với 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 nóng và công cụ gỡ lỗi máy ảo Dart.

Giờ đây, bạn có thể chạy ứng dụng của mình trên Windows, macOS hoặc Linux (nếu ứng dụng được biên dịch trên những đích đó).

Ứng dụng hiển thị danh sách phát của người dùng đã đăng nhập

Định cấu hình google_sign_in dành 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:

Chọn loại ứng dụng Android

Trong phần còn lại của biểu mẫu, hãy điền 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 chỉ dẫn đến thư, tên đó phải là com.example.adaptive_app. Trích xuất dấu vân tay chứng chỉ SHA-1 theo hướng dẫn trên trang trợ giúp của Bảng điều khiển Google Cloud Platform:

Đặt tên mã ứng dụng khách Android

Đ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 của mình.

Chạy ứng dụng trên Android

Định cấu hình google_sign_in cho iOS

Quay lại trang thông tin đăng nhập 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:

của Google. Chọn loại ứng dụng 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) rồi 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ì bạn phải làm theo hướng dẫn com.example.adaptiveApp.

Đố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. Mở ios/Runner.xcworkspace trong Xcode. Chuyển đến Project Navigator (Trình điều hướng dự án). Chuyển đến Trình chạy > Chung. Sao chép giá trị 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 lớp học phải là com.example.adaptiveApp.

Nơi tìm giá trị nhận dạng gói trong Xcode

Hiện tại, hãy bỏ qua Mã cửa hàng ứng dụng và Mã nhóm vì những mã này không bắt buộc đối với hoạt động phát triển cục bộ:

Đặt tên mã ứng dụng khách iOS

Tải tệp .plist đã tạo xuống. Tên của tệp này được dựa trên mã ứng dụng khách đã tạo của bạn. Đổi tên tệp đã tải xuống thành GoogleService-Info.plist, sau đó kéo tệp đó vào trình chỉnh sửa Xcode đang chạy, cùng với tệp Info.plist trong Runner/Runner trong trình đ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 các mục nếu cần, Tạo tệp tham chiếu thư mụcThêm vào trình chạy mục tiêu.

Thêm tệp plist đã tạo vào ứng dụng iOS trong Xcode

Hãy thoát khỏi Xcode, sau đó, trong IDE mà bạn chọn, hãy thêm đoạn mã sau vào Info.plist của bạn:

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 các danh sách phát của mình.

Ứng dụng đang chạy trên iOS

Đang định cấu hình google_sign_in dành cho web

Quay lại trang thông tin đăng nhập 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:

Chọn loại ứ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 các nguồn gốc JavaScript được uỷ quyền như sau:

Đặt tên mã ứng dụng khách của ứng dụng web

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 để bao gồ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ầm một chút tay để chạy mẫu này. Bạn cần chạy proxy CORS mà bạn đã tạo ở bước trước, đồng thời cần 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 thiết bị đầu cuối, hãy chạy máy chủ Proxy CORS như sau:

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

Trong một thiết bị đầu cuối 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 thêm lần nữa, bạn sẽ thấy danh sách phát của mình:

Ứng dụng chạy trong trình duyệt Chrome

8. Các bước tiếp theo

Xin chúc mừng!

Bạn đã hoàn thành lớp học lập trình và xây dựng 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ý những 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 hoạt động của quá trình xác thực.

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ã sao cho phù hợp với nhiều môi trường mà mã sẽ chạy, hãy xem nội dung Tạo ứng dụng thích ứng.