برنامه های تطبیقی ​​در Flutter

1. مقدمه

Flutter جعبه ابزار UI گوگل برای ساخت برنامه های زیبا و بومی کامپایل شده برای موبایل، وب و دسکتاپ از یک پایگاه کد واحد است. در این لبه کد، یاد خواهید گرفت که چگونه یک برنامه Flutter بسازید که با پلتفرمی که روی آن اجرا می شود، اعم از اندروید، iOS، وب، ویندوز، macOS یا لینوکس، سازگار شود.

چیزی که یاد خواهید گرفت

  • چگونه یک برنامه Flutter را که برای موبایل طراحی شده است رشد دهیم تا روی هر شش پلتفرم پشتیبانی شده توسط Flutter کار کند.
  • API های مختلف Flutter برای تشخیص پلت فرم و زمان استفاده از هر API.
  • انطباق با محدودیت ها و انتظارات اجرای یک برنامه در وب.
  • نحوه استفاده از بسته های مختلف در کنار یکدیگر برای پشتیبانی از طیف کامل پلتفرم های فلاتر.

چیزی که خواهی ساخت

در این نرم افزار کد، ابتدا یک برنامه Flutter برای اندروید و iOS خواهید ساخت که لیست های پخش Flutter در یوتیوب را بررسی می کند. سپس با تغییر نحوه نمایش اطلاعات با توجه به اندازه پنجره برنامه، این برنامه را برای کار بر روی سه پلتفرم دسکتاپ (ویندوز، macOS و لینوکس) تطبیق خواهید داد. سپس با ایجاد متن نمایش داده شده در برنامه، همانطور که کاربران وب انتظار دارند، برنامه را برای وب تطبیق دهید. در نهایت، شما احراز هویت را به برنامه اضافه می‌کنید تا بتوانید لیست‌های پخش خود را کاوش کنید، برخلاف مواردی که توسط تیم Flutter ایجاد شده است، که به رویکردهای متفاوتی برای احراز هویت برای Android، iOS و وب نیاز دارد، در مقابل سه پلتفرم دسکتاپ Windows. macOS و Linux.

در اینجا یک اسکرین شات از برنامه Flutter در اندروید و iOS آمده است:

برنامه تمام شده در حال اجرا بر روی شبیه ساز اندروید

برنامه تمام شده در حال اجرا در شبیه ساز iOS

این برنامه در حال اجرا در صفحه گسترده در macOS باید شبیه تصویر زیر باشد.

برنامه تمام شده در حال اجرا در macOS

این آزمایشگاه کد روی تبدیل یک برنامه فلاتر موبایل به یک برنامه تطبیقی ​​که در هر شش پلتفرم فلاتر کار می کند تمرکز دارد. مفاهیم و بلوک‌های کد غیرمرتبط محو شده‌اند و برای شما ارائه می‌شوند تا به سادگی کپی و جای‌گذاری کنید.

دوست دارید از این کد لبه چه چیزی یاد بگیرید؟

من با موضوع جدید هستم و می خواهم یک مرور کلی خوب داشته باشم. من چیزی در مورد این موضوع می دانم، اما می خواهم یک تجدید نظر کنم. من به دنبال کدی برای استفاده در پروژه خود هستم. من به دنبال توضیح یک چیز خاص هستم.

2. محیط توسعه Flutter خود را تنظیم کنید

برای تکمیل این آزمایشگاه به دو نرم افزار نیاز دارید - Flutter SDK و یک ویرایشگر .

شما می توانید کدلب را با استفاده از هر یک از این دستگاه ها اجرا کنید:

  • یک دستگاه فیزیکی Android یا iOS که به رایانه شما متصل شده و روی حالت Developer تنظیم شده است.
  • شبیه ساز iOS (نیاز به نصب ابزار Xcode دارد).
  • شبیه ساز اندروید (نیاز به نصب در Android Studio دارد).
  • یک مرورگر (Chrome برای اشکال زدایی لازم است).
  • به عنوان یک برنامه دسکتاپ Windows ، Linux ، یا macOS . شما باید روی پلتفرمی که قصد استقرار در آن را دارید توسعه دهید. بنابراین، اگر می خواهید یک برنامه دسکتاپ ویندوز توسعه دهید، باید در ویندوز توسعه دهید تا به زنجیره ساخت مناسب دسترسی داشته باشید. الزامات خاص سیستم عامل وجود دارد که به طور مفصل در docs.flutter.dev/desktop پوشش داده شده است.

3. شروع کنید

تایید محیط توسعه شما

ساده ترین راه برای اطمینان از اینکه همه چیز برای توسعه آماده است، لطفا دستور زیر را اجرا کنید:

$ flutter doctor

اگر چیزی بدون علامت تیک نشان داده می شود، لطفاً موارد زیر را اجرا کنید تا جزئیات بیشتر در مورد اشتباه را دریافت کنید:

$ flutter doctor -v

ممکن است نیاز به نصب ابزارهای توسعه دهنده برای توسعه موبایل یا دسکتاپ داشته باشید. برای جزئیات بیشتر در مورد پیکربندی ابزار خود بسته به سیستم عامل میزبان خود، لطفاً به مستندات در مستندات نصب Flutter مراجعه کنید.

ایجاد پروژه فلاتر

یک راه آسان برای شروع نوشتن Flutter برای برنامه های دسکتاپ، استفاده از ابزار خط فرمان Flutter برای ایجاد یک پروژه Flutter است. از طرف دیگر، IDE شما ممکن است یک گردش کار برای ایجاد یک پروژه Flutter از طریق UI آن فراهم کند.

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

برای اطمینان از اینکه همه چیز کار می کند، برنامه boilerplate Flutter را به عنوان یک برنامه تلفن همراه مانند تصویر زیر اجرا کنید. از طرف دیگر، این پروژه را در IDE خود باز کنید و از ابزار آن برای اجرای برنامه استفاده کنید. با تشکر از مرحله قبل، اجرای به عنوان یک برنامه دسکتاپ باید تنها گزینه موجود باشد.

$ flutter run
Launching lib/main.dart on iPhone 15 in debug mode...
Running Xcode build...
 └─Compiling, linking and signing...                         6.5s
Xcode build done.                                           24.6s
Syncing files to device iPhone 15...                                46ms

Flutter run key commands.
r Hot reload. 🔥🔥🔥
R Hot restart.
h List all available interactive commands.
d Detach (terminate "flutter run" but leave application running).
c Clear the screen
q Quit (terminate the application on the device).

A Dart VM Service on iPhone 15 is available at: http://127.0.0.1:50501/JHGBwC_hFJo=/
The Flutter DevTools debugger and profiler on iPhone 15 is available at: http://127.0.0.1:9102?uri=http://127.0.0.1:50501/JHGBwC_hFJo=/

اکنون باید برنامه در حال اجرا را ببینید. محتوا نیاز به به روز رسانی دارد.

برای به روز رسانی محتوا، کد خود را در lib/main.dart با کد زیر به روز کنید. برای تغییر آنچه برنامه‌تان نمایش می‌دهد، یک بارگیری مجدد داغ انجام دهید.

  • اگر برنامه را با استفاده از خط فرمان اجرا می کنید، r در کنسول تایپ کنید تا دوباره بارگیری شود.
  • اگر برنامه را با استفاده از یک IDE اجرا می کنید، وقتی فایل را ذخیره می کنید، برنامه دوباره بارگیری می شود.

lib/main.dart

import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
      ),
      home: const ResizeablePage(),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    final mediaQuery = MediaQuery.of(context);
    final themePlatform = Theme.of(context).platform;

    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'Window properties',
              style: Theme.of(context).textTheme.headlineSmall,
            ),
            const SizedBox(height: 8),
            SizedBox(
              width: 350,
              child: Table(
                textBaseline: TextBaseline.alphabetic,
                children: <TableRow>[
                  _fillTableRow(
                    context: context,
                    property: 'Window Size',
                    value: '${mediaQuery.size.width.toStringAsFixed(1)} x '
                        '${mediaQuery.size.height.toStringAsFixed(1)}',
                  ),
                  _fillTableRow(
                    context: context,
                    property: 'Device Pixel Ratio',
                    value: mediaQuery.devicePixelRatio.toStringAsFixed(2),
                  ),
                  _fillTableRow(
                    context: context,
                    property: 'Platform.isXXX',
                    value: platformDescription(),
                  ),
                  _fillTableRow(
                    context: context,
                    property: 'Theme.of(ctx).platform',
                    value: themePlatform.toString(),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }

  TableRow _fillTableRow(
      {required BuildContext context,
      required String property,
      required String value}) {
    return TableRow(
      children: [
        TableCell(
          verticalAlignment: TableCellVerticalAlignment.baseline,
          child: Padding(
            padding: const EdgeInsets.all(8.0),
            child: Text(property),
          ),
        ),
        TableCell(
          verticalAlignment: TableCellVerticalAlignment.baseline,
          child: Padding(
            padding: const EdgeInsets.all(8.0),
            child: Text(value),
          ),
        ),
      ],
    );
  }

  String platformDescription() {
    if (kIsWeb) {
      return 'Web';
    } else if (Platform.isAndroid) {
      return 'Android';
    } else if (Platform.isIOS) {
      return 'iOS';
    } else if (Platform.isWindows) {
      return 'Windows';
    } else if (Platform.isMacOS) {
      return 'macOS';
    } else if (Platform.isLinux) {
      return 'Linux';
    } else if (Platform.isFuchsia) {
      return 'Fuchsia';
    } else {
      return 'Unknown';
    }
  }
}

برنامه فوق به گونه ای طراحی شده است که به شما این احساس را بدهد که چگونه می توان پلتفرم های مختلف را شناسایی و با آنها سازگار کرد. این برنامه به صورت بومی در اندروید و iOS اجرا می شود:

نمایش ویژگی های پنجره در شبیه ساز اندروید

نمایش ویژگی های پنجره در شبیه ساز iOS

و اینجا همان کدی است که به صورت بومی روی macOS و داخل کروم اجرا می شود و دوباره روی macOS اجرا می شود.

نمایش ویژگی های پنجره در macOS

نمایش مشخصات پنجره در مرورگر کروم

نکته مهمی که در اینجا باید به آن اشاره کرد این است که در نگاه اول، Flutter هر کاری را که می تواند انجام می دهد تا محتوا را با صفحه نمایشی که در حال اجرا است تطبیق دهد. لپ تاپی که این اسکرین شات ها روی آن گرفته شده است دارای صفحه نمایش مک با وضوح بالا است، به همین دلیل است که هر دو نسخه macOS و وب برنامه با نسبت پیکسل دستگاه 2 ارائه می شوند. در همین حال، در آیفون 12، نسبت 3 را مشاهده می کنید. و 2.63 در Pixel 2. در همه موارد، متن نمایش داده شده تقریباً مشابه است، و کار ما را به عنوان توسعه دهنده بسیار آسان می کند.

دومین نکته ای که باید به آن توجه کرد این است که دو گزینه برای بررسی اینکه کد روی کدام پلتفرم اجرا می شود، مقادیر متفاوتی دارند. گزینه اول شی Platform وارد شده از dart:io را بررسی می کند، در حالی که گزینه دوم (فقط در روش build ویجت موجود است)، شی Theme را از آرگومان BuildContext بازیابی می کند.

دلیل اینکه این دو روش نتایج متفاوتی را ارائه می دهند این است که هدف آنها متفاوت است. شی Platform وارد شده از dart:io برای تصمیم گیری مستقل از انتخاب های رندر استفاده می شود. یک مثال برجسته از این موضوع تصمیم گیری از کدام افزونه ها برای استفاده است، که ممکن است پیاده سازی های بومی مشابه برای یک پلت فرم فیزیکی خاص داشته باشند یا نداشته باشند.

استخراج Theme از BuildContext برای تصمیم‌گیری‌های پیاده‌سازی است که موضوع محور هستند. نمونه بارز این موضوع تصمیم گیری در مورد استفاده از نوار لغزنده Material یا لغزنده کوپرتینویی است، همانطور که در Slider.adaptive بحث شده است.

در بخش بعدی یک برنامه کاوشگر اصلی لیست پخش YouTube می‌سازید که صرفاً برای اندروید و iOS بهینه شده است. در بخش‌های بعدی، انطباق‌های مختلفی را اضافه می‌کنید تا برنامه در دسک‌تاپ و وب بهتر کار کند.

4. یک اپلیکیشن موبایل بسازید

بسته ها را اضافه کنید

در این برنامه از بسته‌های فلاتر متنوعی برای دسترسی به YouTube Data API ، مدیریت وضعیت و لمسی از موضوع استفاده خواهید کرد.

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

این دستور تعدادی بسته به برنامه اضافه می کند:

  • googleapis : یک کتابخانه دارت ایجاد شده که دسترسی به APIهای Google را فراهم می کند.
  • http : کتابخانه ای برای ایجاد درخواست های HTTP که تفاوت های بین مرورگرهای بومی و وب را پنهان می کند.
  • provider : مدیریت دولتی را ارائه می دهد.
  • url_launcher : ابزاری را برای پرش به یک ویدیو از فهرست پخش فراهم می کند. همانطور که از وابستگی‌های حل‌شده نشان داده شده است، url_launcher علاوه بر اندروید و iOS پیش‌فرض، پیاده‌سازی‌هایی برای ویندوز، macOS، لینوکس و وب دارد. استفاده از این بسته به این معنی است که شما نیازی به ایجاد پلتفرم خاص برای این عملکرد ندارید.
  • flex_color_scheme : یک طرح رنگی پیش‌فرض زیبا به برنامه می‌دهد. برای کسب اطلاعات بیشتر، اسناد flex_color_scheme API را بررسی کنید.
  • go_router : پیمایش بین صفحه های مختلف را پیاده سازی می کند. این بسته یک API راحت و مبتنی بر url برای پیمایش با استفاده از روتر Flutter ارائه می‌کند.

پیکربندی برنامه های تلفن همراه برای url_launcher

افزونه url_launcher به پیکربندی برنامه‌های اجراکننده Android و iOS نیاز دارد. در iOS Flutter runner، خطوط زیر را به فرهنگ لغت plist اضافه کنید.

ios/Runner/Info.plist

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

در رانر Android Flutter، خطوط زیر را به Manifest.xml اضافه کنید. این گره queries را به عنوان فرزند مستقیم گره manifest و همتای گره application اضافه کنید.

android/app/src/main/AndroidManifest.xml

<queries>
    <intent>
        <action android:name="android.intent.action.VIEW" />
        <data android:scheme="https" />
    </intent>
    <intent>
        <action android:name="android.intent.action.DIAL" />
        <data android:scheme="tel" />
    </intent>
    <intent>
        <action android:name="android.intent.action.SEND" />
        <data android:mimeType="*/*" />
    </intent>
</queries>

برای جزئیات بیشتر در مورد این تغییرات پیکربندی مورد نیاز، لطفاً به مستندات url_launcher مراجعه کنید.

دسترسی به YouTube Data API

برای دسترسی به YouTube Data API برای فهرست کردن لیست‌های پخش، باید یک پروژه API ایجاد کنید تا کلیدهای API مورد نیاز را ایجاد کنید. در این مراحل فرض می‌شود که شما از قبل یک حساب Google دارید، بنابراین اگر قبلاً آن را ندارید، یک حساب کاربری ایجاد کنید.

برای ایجاد یک پروژه API به Developer Console بروید:

نمایش کنسول GCP در جریان ایجاد پروژه

هنگامی که یک پروژه دارید، به صفحه کتابخانه API بروید. در کادر جستجو، «youtube» را وارد کرده و youtube data api v3 را انتخاب کنید.

انتخاب YouTube Data API v3 در کنسول GCP

در صفحه جزئیات YouTube Data API v3، API را فعال کنید.

5a877ea82b83ae42.png

هنگامی که API را فعال کردید، به صفحه اعتبارنامه بروید و یک کلید API ایجاد کنید.

ایجاد اعتبار در کنسول GCP

پس از چند ثانیه، باید یک دیالوگ با کلید جدید API براق خود ببینید. به زودی از این کلید استفاده خواهید کرد.

کلید API ایجاد شده است که کلید API ایجاد شده را نشان می دهد

کد اضافه کنید

در ادامه این مرحله، کدهای زیادی را برای ساختن یک اپلیکیشن موبایل، بدون هیچ توضیحی در مورد کد، برش نمی دهید. هدف این کد لبه این است که اپلیکیشن موبایل را بگیرد و آن را هم با دسکتاپ و هم با وب تطبیق دهد. برای آشنایی بیشتر با ساخت اپلیکیشن‌های فلاتر برای موبایل، لطفاً اولین برنامه فلاتر را بنویسید، قسمت 1 ، قسمت 2 و ساختن رابط‌های کاربری زیبا با فلاتر را ببینید.

فایل های زیر را اضافه کنید، ابتدا شیء حالت برنامه را اضافه کنید.

lib/src/app_state.dart

import 'dart:collection';

import 'package:flutter/foundation.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:http/http.dart' as http;

class FlutterDevPlaylists extends ChangeNotifier {
  FlutterDevPlaylists({
    required String flutterDevAccountId,
    required String youTubeApiKey,
  }) : _flutterDevAccountId = flutterDevAccountId {
    _api = YouTubeApi(
      _ApiKeyClient(
        client: http.Client(),
        key: youTubeApiKey,
      ),
    );
    _loadPlaylists();
  }

  Future<void> _loadPlaylists() async {
    String? nextPageToken;
    _playlists.clear();

    do {
      final response = await _api.playlists.list(
        ['snippet', 'contentDetails', 'id'],
        channelId: _flutterDevAccountId,
        maxResults: 50,
        pageToken: nextPageToken,
      );
      _playlists.addAll(response.items!);
      _playlists.sort((a, b) => a.snippet!.title!
          .toLowerCase()
          .compareTo(b.snippet!.title!.toLowerCase()));
      notifyListeners();
      nextPageToken = response.nextPageToken;
    } while (nextPageToken != null);
  }

  final String _flutterDevAccountId;
  late final YouTubeApi _api;

  final List<Playlist> _playlists = [];
  List<Playlist> get playlists => UnmodifiableListView(_playlists);

  final Map<String, List<PlaylistItem>> _playlistItems = {};
  List<PlaylistItem> playlistItems({required String playlistId}) {
    if (!_playlistItems.containsKey(playlistId)) {
      _playlistItems[playlistId] = [];
      _retrievePlaylist(playlistId);
    }
    return UnmodifiableListView(_playlistItems[playlistId]!);
  }

  Future<void> _retrievePlaylist(String playlistId) async {
    String? nextPageToken;
    do {
      var response = await _api.playlistItems.list(
        ['snippet', 'contentDetails'],
        playlistId: playlistId,
        maxResults: 25,
        pageToken: nextPageToken,
      );
      var items = response.items;
      if (items != null) {
        _playlistItems[playlistId]!.addAll(items);
      }
      notifyListeners();
      nextPageToken = response.nextPageToken;
    } while (nextPageToken != null);
  }
}

class _ApiKeyClient extends http.BaseClient {
  _ApiKeyClient({required this.key, required this.client});

  final String key;
  final http.Client client;

  @override
  Future<http.StreamedResponse> send(http.BaseRequest request) {
    final url = request.url.replace(queryParameters: <String, List<String>>{
      ...request.url.queryParametersAll,
      'key': [key]
    });

    return client.send(http.Request(request.method, url));
  }
}

سپس، صفحه جزئیات لیست پخش را اضافه کنید.

lib/src/playlist_details.dart

import 'package:flutter/material.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/link.dart';

import 'app_state.dart';

class PlaylistDetails extends StatelessWidget {
  const PlaylistDetails(
      {required this.playlistId, required this.playlistName, super.key});
  final String playlistId;
  final String playlistName;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(playlistName),
      ),
      body: Consumer<FlutterDevPlaylists>(
        builder: (context, playlists, _) {
          final playlistItems = playlists.playlistItems(playlistId: playlistId);
          if (playlistItems.isEmpty) {
            return const Center(child: CircularProgressIndicator());
          }

          return _PlaylistDetailsListView(playlistItems: playlistItems);
        },
      ),
    );
  }
}

class _PlaylistDetailsListView extends StatelessWidget {
  const _PlaylistDetailsListView({required this.playlistItems});
  final List<PlaylistItem> playlistItems;

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: playlistItems.length,
      itemBuilder: (context, index) {
        final playlistItem = playlistItems[index];
        return Padding(
          padding: const EdgeInsets.all(8.0),
          child: ClipRRect(
            borderRadius: BorderRadius.circular(4),
            child: Stack(
              alignment: Alignment.center,
              children: [
                if (playlistItem.snippet!.thumbnails!.high != null)
                  Image.network(playlistItem.snippet!.thumbnails!.high!.url!),
                _buildGradient(context),
                _buildTitleAndSubtitle(context, playlistItem),
                _buildPlayButton(context, playlistItem),
              ],
            ),
          ),
        );
      },
    );
  }

  Widget _buildGradient(BuildContext context) {
    return Positioned.fill(
      child: DecoratedBox(
        decoration: BoxDecoration(
          gradient: LinearGradient(
            colors: [
              Colors.transparent,
              Theme.of(context).colorScheme.surface,
            ],
            begin: Alignment.topCenter,
            end: Alignment.bottomCenter,
            stops: const [0.5, 0.95],
          ),
        ),
      ),
    );
  }

  Widget _buildTitleAndSubtitle(
      BuildContext context, PlaylistItem playlistItem) {
    return Positioned(
      left: 20,
      right: 0,
      bottom: 20,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            playlistItem.snippet!.title!,
            style: Theme.of(context).textTheme.bodyLarge!.copyWith(
                  fontSize: 18,
                  // fontWeight: FontWeight.bold,
                ),
          ),
          if (playlistItem.snippet!.videoOwnerChannelTitle != null)
            Text(
              playlistItem.snippet!.videoOwnerChannelTitle!,
              style: Theme.of(context).textTheme.bodyMedium!.copyWith(
                    fontSize: 12,
                  ),
            ),
        ],
      ),
    );
  }

  Widget _buildPlayButton(BuildContext context, PlaylistItem playlistItem) {
    return Stack(
      alignment: AlignmentDirectional.center,
      children: [
        Container(
          width: 42,
          height: 42,
          decoration: const BoxDecoration(
            color: Colors.white,
            borderRadius: BorderRadius.all(
              Radius.circular(21),
            ),
          ),
        ),
        Link(
          uri: Uri.parse(
              'https://www.youtube.com/watch?v=${playlistItem.snippet!.resourceId!.videoId}'),
          builder: (context, followLink) => IconButton(
            onPressed: followLink,
            color: Colors.red,
            icon: const Icon(Icons.play_circle_fill),
            iconSize: 45,
          ),
        ),
      ],
    );
  }
}

در مرحله بعد، لیست لیست های پخش را اضافه کنید.

lib/src/playlists.dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:provider/provider.dart';

import 'app_state.dart';

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('FlutterDev Playlists'),
      ),
      body: Consumer<FlutterDevPlaylists>(
        builder: (context, flutterDev, child) {
          final playlists = flutterDev.playlists;
          if (playlists.isEmpty) {
            return const Center(
              child: CircularProgressIndicator(),
            );
          }

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

class _PlaylistsListView extends StatelessWidget {
  const _PlaylistsListView({required this.items});

  final List<Playlist> items;

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: items.length,
      itemBuilder: (context, index) {
        var playlist = items[index];
        return Padding(
          padding: const EdgeInsets.all(8.0),
          child: ListTile(
            leading: Image.network(
              playlist.snippet!.thumbnails!.default_!.url!,
            ),
            title: Text(playlist.snippet!.title!),
            subtitle: Text(
              playlist.snippet!.description!,
            ),
            onTap: () {
              context.go(
                Uri(
                  path: '/playlist/${playlist.id}',
                  queryParameters: <String, String>{
                    'title': playlist.snippet!.title!
                  },
                ).toString(),
              );
            },
          ),
        );
      },
    );
  }
}

و محتوای فایل main.dart را به صورت زیر جایگزین کنید:

lib/main.dart

import 'dart:io';

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

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

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

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

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

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

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'FlutterDev Playlists',
      theme: FlexColorScheme.light(
        scheme: FlexScheme.red,
        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,
    );
  }
}

تقریباً آماده اجرای این کد در اندروید و iOS هستید. فقط یک چیز دیگر را تغییر دهید، ثابت youTubeApiKey در خط 14 با کلید YouTube API ایجاد شده در مرحله قبل تغییر دهید.

lib/main.dart

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

برای اجرای این برنامه در macOS، باید برنامه را فعال کنید تا درخواست های HTTP را به شرح زیر انجام دهد. هر دو فایل DebugProfile.entitlements و Release.entitilements را به صورت زیر ویرایش کنید:

macos/Runner/DebugProfile.entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
        <dict>
                <key>com.apple.security.app-sandbox</key>
                <true/>
                <key>com.apple.security.cs.allow-jit</key>
                <true/>
                <key>com.apple.security.network.server</key>
                <true/>
                <!-- add the following two lines -->
                <key>com.apple.security.network.client</key>
                <true/>
        </dict>
</plist>

macos/Runner/Release.entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
        <dict>
                <key>com.apple.security.app-sandbox</key>
                <true/>
                <!-- add the following two lines -->
                <key>com.apple.security.network.client</key>
                <true/>
        </dict>
</plist>

برنامه را اجرا کنید

اکنون که یک برنامه کامل دارید، باید بتوانید آن را با موفقیت در شبیه ساز اندروید یا شبیه ساز آیفون اجرا کنید. لیستی از لیست های پخش Flutter را مشاهده خواهید کرد، هنگامی که یک لیست پخش را انتخاب می کنید، ویدیوهای موجود در آن لیست پخش را خواهید دید و در نهایت اگر روی دکمه Play کلیک کنید، برای تماشای ویدیو وارد تجربه YouTube خواهید شد.

برنامه ای که لیست های پخش حساب YouTube FlutterDev را نشان می دهد

نمایش ویدیوها در یک لیست پخش خاص

یک ویدیوی انتخابی در حال پخش در داخل پخش کننده YouTube

با این حال، اگر سعی کنید این برنامه را روی دسکتاپ اجرا کنید، خواهید دید که چیدمان در یک پنجره معمولی به اندازه دسکتاپ باز می شود، اشتباه می شود. در مرحله بعدی به دنبال راه هایی برای سازگاری با این موضوع خواهید بود.

5. سازگاری با دسکتاپ

مشکل دسکتاپ

اگر برنامه را روی یکی از پلتفرم‌های دسکتاپ بومی، ویندوز، macOS یا لینوکس اجرا کنید، متوجه مشکل جالبی خواهید شد. این کار می کند، اما به نظر می رسد ... عجیب و غریب.

اپلیکیشنی که روی macOS اجرا می‌شود، فهرستی از لیست‌های پخش را نشان می‌دهد که نسبت عجیبی به نظر می‌رسد

ویدیوها در یک لیست پخش، در macOS

یک راه حل برای این، اضافه کردن یک نمای تقسیم شده، لیست کردن لیست های پخش در سمت چپ و ویدیوها در سمت راست است. با این حال، شما می‌خواهید این طرح‌بندی تنها زمانی فعال شود که کد در اندروید یا iOS اجرا نشود و پنجره به اندازه کافی گسترده باشد. دستورالعمل های زیر نحوه پیاده سازی این قابلیت را نشان می دهد.

ابتدا، بسته split_view را برای کمک به ساخت طرح اضافه کنید.

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

معرفی ویجت های تطبیقی

الگویی که می‌خواهید در این لبه کد استفاده کنید، معرفی ابزارک‌های تطبیقی ​​است که بر اساس ویژگی‌هایی مانند عرض صفحه، موضوع پلتفرم و مواردی از این دست، پیاده‌سازی را انتخاب می‌کنند. در این مورد، می‌خواهید ویجت AdaptivePlaylists را معرفی کنید که نحوه تعامل Playlists و PlaylistDetails را دوباره کار می‌کند. فایل lib/main.dart را به صورت زیر ویرایش کنید:

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

سپس، فایل مربوط به ویجت AdaptivePlaylist را ایجاد کنید:

lib/src/adaptive_playlists.dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:split_view/split_view.dart';

import 'playlist_details.dart';
import 'playlists.dart';

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

  @override
  Widget build(BuildContext context) {
    final screenWidth = MediaQuery.of(context).size.width;
    final targetPlatform = Theme.of(context).platform;

    if (targetPlatform == TargetPlatform.android ||
        targetPlatform == TargetPlatform.iOS ||
        screenWidth <= 600) {
      return const NarrowDisplayPlaylists();
    } else {
      return const WideDisplayPlaylists();
    }
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('FlutterDev Playlists')),
      body: Playlists(
        playlistSelected: (playlist) {
          context.go(
            Uri(
              path: '/playlist/${playlist.id}',
              queryParameters: <String, String>{
                'title': playlist.snippet!.title!
              },
            ).toString(),
          );
        },
      ),
    );
  }
}

class WideDisplayPlaylists extends StatefulWidget {
  const WideDisplayPlaylists({super.key});

  @override
  State<WideDisplayPlaylists> createState() => _WideDisplayPlaylistsState();
}

class _WideDisplayPlaylistsState extends State<WideDisplayPlaylists> {
  Playlist? selectedPlaylist;
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: switch (selectedPlaylist?.snippet?.title) {
          String title => Text('FlutterDev Playlist: $title'),
          _ => const Text('FlutterDev Playlists'),
        },
      ),
      body: SplitView(
        viewMode: SplitViewMode.Horizontal,
        children: [
          Playlists(playlistSelected: (playlist) {
            setState(() {
              selectedPlaylist = playlist;
            });
          }),
          switch ((selectedPlaylist?.id, selectedPlaylist?.snippet?.title)) {
            (String id, String title) =>
              PlaylistDetails(playlistId: id, playlistName: title),
            _ => const Center(child: Text('Select a playlist')),
          },
        ],
      ),
    );
  }
}

این فایل به چند دلیل جالب است. ابتدا، هم از عرض پنجره استفاده می کند (با استفاده از MediaQuery.of(context).size.width )، و هم شما در حال بررسی موضوع هستید (با استفاده از Theme.of(context).platform ) تصمیم می گیرید که آیا یک طرح بندی گسترده را نمایش دهید یا خیر. ویجت SplitView یا یک صفحه نمایش باریک بدون آن.

دوم، این بخش به مدیریت سخت کد ناوبری می پردازد. در ویجت Playlists یک آرگومان پاسخ به تماس را نشان می دهد. این تماس به کد اطراف اطلاع می دهد که کاربر یک لیست پخش را انتخاب کرده است. سپس کد باید کار را برای نمایش آن لیست پخش انجام دهد. این نیاز به Scaffold در ویجت‌های Playlists و PlaylistDetails تغییر می‌دهد. اکنون که آنها در سطح بالایی نیستند، باید Scaffold از آن ویجت ها حذف کنید.

سپس فایل src/lib/playlists.dart را به صورت زیر ویرایش کنید:

lib/src/playlists.dart

import 'package:flutter/material.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:provider/provider.dart';

import 'app_state.dart';

class Playlists extends StatelessWidget {
  const Playlists({super.key, required this.playlistSelected});

  final PlaylistsListSelected playlistSelected;

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

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

typedef PlaylistsListSelected = void Function(Playlist playlist);

class _PlaylistsListView extends StatefulWidget {
  const _PlaylistsListView({
    required this.items,
    required this.playlistSelected,
  });

  final List<Playlist> items;
  final PlaylistsListSelected playlistSelected;

  @override
  State<_PlaylistsListView> createState() => _PlaylistsListViewState();
}

class _PlaylistsListViewState extends State<_PlaylistsListView> {
  late ScrollController _scrollController;

  @override
  void initState() {
    super.initState();
    _scrollController = ScrollController();
  }

  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      controller: _scrollController,
      itemCount: widget.items.length,
      itemBuilder: (context, index) {
        var playlist = widget.items[index];
        return Padding(
          padding: const EdgeInsets.all(8.0),
          child: ListTile(
            leading: Image.network(
              playlist.snippet!.thumbnails!.default_!.url!,
            ),
            title: Text(playlist.snippet!.title!),
            subtitle: Text(
              playlist.snippet!.description!,
            ),
            onTap: () {
              widget.playlistSelected(playlist);
            },
          ),
        );
      },
    );
  }
}

این فایل تغییرات زیادی دارد. جدا از معرفی فوق الذکر از یک playlistSelected callback، و حذف ویجت Scaffold ، ویجت _PlaylistsListView از حالت بدون حالت به حالت حالت تبدیل می شود. این تغییر به دلیل معرفی یک ScrollController که باید ساخته و نابود شود، ضروری است.

معرفی ScrollController جالب است زیرا لازم است زیرا در یک طرح بندی گسترده شما دو ویجت ListView در کنار هم دارید. در تلفن همراه، داشتن یک ListView منفرد سنتی است، و بنابراین می‌توان یک ScrollController با عمر طولانی وجود داشت که همه ListView در طول چرخه زندگی فردی خود به آن متصل می‌شوند و از آن جدا می‌شوند. دسکتاپ متفاوت است، در دنیایی که چندین ListView در کنار هم معنی دارند.

و در نهایت فایل lib/src/playlist_details.dart را به صورت زیر ویرایش کنید:

lib/src/playlist_details.dart

import 'package:flutter/material.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/link.dart';

import 'app_state.dart';

class PlaylistDetails extends StatelessWidget {
  const PlaylistDetails(
      {required this.playlistId, required this.playlistName, super.key});
  final String playlistId;
  final String playlistName;

  @override
  Widget build(BuildContext context) {
    return Consumer<FlutterDevPlaylists>(
      builder: (context, playlists, _) {
        final playlistItems = playlists.playlistItems(playlistId: playlistId);
        if (playlistItems.isEmpty) {
          return const Center(child: CircularProgressIndicator());
        }

        return _PlaylistDetailsListView(playlistItems: playlistItems);
      },
    );
  }
}

class _PlaylistDetailsListView extends StatefulWidget {
  const _PlaylistDetailsListView({required this.playlistItems});
  final List<PlaylistItem> playlistItems;

  @override
  State<_PlaylistDetailsListView> createState() =>
      _PlaylistDetailsListViewState();
}

class _PlaylistDetailsListViewState extends State<_PlaylistDetailsListView> {
  late ScrollController _scrollController;

  @override
  void initState() {
    super.initState();
    _scrollController = ScrollController();
  }

  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      controller: _scrollController,
      itemCount: widget.playlistItems.length,
      itemBuilder: (context, index) {
        final playlistItem = widget.playlistItems[index];
        return Padding(
          padding: const EdgeInsets.all(8.0),
          child: ClipRRect(
            borderRadius: BorderRadius.circular(4),
            child: Stack(
              alignment: Alignment.center,
              children: [
                if (playlistItem.snippet!.thumbnails!.high != null)
                  Image.network(playlistItem.snippet!.thumbnails!.high!.url!),
                _buildGradient(context),
                _buildTitleAndSubtitle(context, playlistItem),
                _buildPlayButton(context, playlistItem),
              ],
            ),
          ),
        );
      },
    );
  }

  Widget _buildGradient(BuildContext context) {
    return Positioned.fill(
      child: DecoratedBox(
        decoration: BoxDecoration(
          gradient: LinearGradient(
            colors: [
              Colors.transparent,
              Theme.of(context).colorScheme.surface,
            ],
            begin: Alignment.topCenter,
            end: Alignment.bottomCenter,
            stops: const [0.5, 0.95],
          ),
        ),
      ),
    );
  }

  Widget _buildTitleAndSubtitle(
      BuildContext context, PlaylistItem playlistItem) {
    return Positioned(
      left: 20,
      right: 0,
      bottom: 20,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            playlistItem.snippet!.title!,
            style: Theme.of(context).textTheme.bodyLarge!.copyWith(
                  fontSize: 18,
                  // fontWeight: FontWeight.bold,
                ),
          ),
          if (playlistItem.snippet!.videoOwnerChannelTitle != null)
            Text(
              playlistItem.snippet!.videoOwnerChannelTitle!,
              style: Theme.of(context).textTheme.bodyMedium!.copyWith(
                    fontSize: 12,
                  ),
            ),
        ],
      ),
    );
  }

  Widget _buildPlayButton(BuildContext context, PlaylistItem playlistItem) {
    return Stack(
      alignment: AlignmentDirectional.center,
      children: [
        Container(
          width: 42,
          height: 42,
          decoration: const BoxDecoration(
            color: Colors.white,
            borderRadius: BorderRadius.all(
              Radius.circular(21),
            ),
          ),
        ),
        Link(
          uri: Uri.parse(
              'https://www.youtube.com/watch?v=${playlistItem.snippet!.resourceId!.videoId}'),
          builder: (context, followLink) => IconButton(
            onPressed: followLink,
            color: Colors.red,
            icon: const Icon(Icons.play_circle_fill),
            iconSize: 45,
          ),
        ),
      ],
    );
  }
}

مشابه ویجت Playlists در بالا، این فایل همچنین دارای تغییراتی برای حذف ویجت Scaffold و معرفی یک ScrollController متعلق به خود است.

دوباره برنامه را اجرا کنید!

اجرای برنامه بر روی دسکتاپ انتخابی شما، خواه ویندوز، macOS یا لینوکس باشد. اکنون باید همانطور که انتظار دارید کار کند.

برنامه در حال اجرا در macOS با نمای تقسیم شده

6. سازگاری با وب

چه خبر از آن تصاویر، نه؟

اکنون مشخص شده است که تلاش برای اجرای این برنامه در وب برای انطباق با مرورگرهای وب، کار بیشتری لازم است.

این برنامه در مرورگر کروم اجرا می‌شود، بدون ریز عکس‌های YouTube

اگر نگاهی به کنسول اشکال‌زدایی بیندازید، راهنمایی ملایمی در مورد کارهای بعدی خواهید دید.

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

When the exception was thrown, this was the stack

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

ایجاد یک پروکسی CORS

یکی از راه‌های مقابله با مشکلات رندر تصویر، معرفی یک وب سرویس پروکسی برای اضافه کردن هدرهای مورد نیاز Cross Origin Resource Sharing است. یک ترمینال بیاورید و یک وب سرور دارت به شرح زیر ایجاد کنید:

$ dart create --template server-shelf yt_cors_proxy
Creating yt_cors_proxy using template server-shelf...

  .gitignore
  analysis_options.yaml
  CHANGELOG.md
  pubspec.yaml
  README.md
  Dockerfile
  .dockerignore
  test/server_test.dart
  bin/server.dart

Running pub get...                     3.9s
  Resolving dependencies...
  Changed 53 dependencies!

Created project yt_cors_proxy in yt_cors_proxy! In order to get started, run the following commands:

  cd yt_cors_proxy
  dart run bin/server.dart

دایرکتوری را به سرور yt_cors_proxy تغییر دهید و چند وابستگی مورد نیاز را اضافه کنید:

$ cd yt_cors_proxy
$ dart pub add shelf_cors_headers http
"http" was found in dev_dependencies. Removing "http" and adding it to dependencies instead.
Resolving dependencies...
  http 1.1.2 (from dev dependency to direct dependency)
  js 0.6.7 (0.7.0 available)
  lints 2.1.1 (3.0.0 available)
+ shelf_cors_headers 0.1.5
Changed 2 dependencies!
2 packages have newer versions incompatible with dependency constraints.
Try `dart pub outdated` for more information.

برخی از وابستگی های فعلی وجود دارند که دیگر مورد نیاز نیستند. اینها را به صورت زیر برش دهید:

$ dart pub remove args shelf_router
Resolving dependencies...
  args 2.4.2 (from direct dependency to transitive dependency)
  js 0.6.7 (0.7.0 available)
  lints 2.1.1 (3.0.0 available)
These packages are no longer being depended on:
- http_methods 1.1.1
- shelf_router 1.1.4
Changed 3 dependencies!
2 packages have newer versions incompatible with dependency constraints.
Try `dart pub outdated` for more information.

در مرحله بعد، محتویات فایل server.dart را برای مطابقت با موارد زیر تغییر دهید:

yt_cors_proxy/bin/server.dart

import 'dart:async';
import 'dart:io';

import 'package:http/http.dart' as http;
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart';
import 'package:shelf_cors_headers/shelf_cors_headers.dart';

Future<Response> _requestHandler(Request req) async {
  final target = req.url.replace(scheme: 'https', host: 'i.ytimg.com');
  final response = await http.get(target);
  return Response.ok(response.bodyBytes, headers: response.headers);
}

void main(List<String> args) async {
  // Use any available host or container IP (usually `0.0.0.0`).
  final ip = InternetAddress.anyIPv4;

  // Configure a pipeline that adds CORS headers and proxies requests.
  final handler = Pipeline()
      .addMiddleware(logRequests())
      .addMiddleware(corsHeaders(headers: {ACCESS_CONTROL_ALLOW_ORIGIN: '*'}))
      .addHandler(_requestHandler);

  // For running in containers, we respect the PORT environment variable.
  final port = int.parse(Platform.environment['PORT'] ?? '8080');
  final server = await serve(handler, ip, port);
  print('Server listening on port ${server.port}');
}

شما می توانید این سرور را به صورت زیر اجرا کنید:

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

همچنین، می‌توانید آن را به‌عنوان یک تصویر Docker بسازید و تصویر Docker حاصل را به صورت زیر اجرا کنید:

$ docker build . -t yt-cors-proxy      
[+] Building 2.7s (14/14) FINISHED
$ docker run -p 8080:8080 yt-cors-proxy 
Server listening on port 8080

در مرحله بعد، کد Flutter را تغییر دهید تا از این پروکسی CORS استفاده کنید، اما فقط زمانی که در یک مرورگر وب اجرا می شود.

یک جفت ویجت سازگار

اولین مورد از جفت ویجت ها نحوه استفاده برنامه شما از پروکسی CORS است.

lib/src/adaptive_image.dart

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';

class AdaptiveImage extends StatelessWidget {
  AdaptiveImage.network(String url, {super.key}) {
    if (kIsWeb) {
      _url = Uri.parse(url)
          .replace(host: 'localhost', port: 8080, scheme: 'http')
          .toString();
    } else {
      _url = url;
    }
  }

  late final String _url;

  @override
  Widget build(BuildContext context) {
    return Image.network(_url);
  }
}

این برنامه به دلیل تفاوت پلت فرم زمان اجرا از ثابت kIsWeb استفاده می کند. ویجت سازگار دیگر برنامه را تغییر می دهد تا مانند سایر صفحات وب کار کند. کاربران مرورگر انتظار دارند متن قابل انتخاب باشد.

lib/src/adaptive_text.dart

import 'package:flutter/material.dart';

class AdaptiveText extends StatelessWidget {
  const AdaptiveText(this.data, {super.key, this.style});
  final String data;
  final TextStyle? style;

  @override
  Widget build(BuildContext context) {
    return switch (Theme.of(context).platform) {
      TargetPlatform.android || TargetPlatform.iOS => Text(data, style: style),
      _ => SelectableText(data, style: style)
    };
  }
}

اکنون، این سازگاری‌ها را در سراسر پایگاه کد پخش کنید:

lib/src/playlist_details.dart

import 'package:flutter/material.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/link.dart';

import 'adaptive_image.dart';                                 // Add this line,
import 'adaptive_text.dart';                                  // And this line
import 'app_state.dart';

class PlaylistDetails extends StatelessWidget {
  const PlaylistDetails(
      {required this.playlistId, required this.playlistName, super.key});
  final String playlistId;
  final String playlistName;

  @override
  Widget build(BuildContext context) {
    return Consumer<FlutterDevPlaylists>(
      builder: (context, playlists, _) {
        final playlistItems = playlists.playlistItems(playlistId: playlistId);
        if (playlistItems.isEmpty) {
          return const Center(child: CircularProgressIndicator());
        }

        return _PlaylistDetailsListView(playlistItems: playlistItems);
      },
    );
  }
}

class _PlaylistDetailsListView extends StatefulWidget {
  const _PlaylistDetailsListView({required this.playlistItems});
  final List<PlaylistItem> playlistItems;

  @override
  State<_PlaylistDetailsListView> createState() =>
      _PlaylistDetailsListViewState();
}

class _PlaylistDetailsListViewState extends State<_PlaylistDetailsListView> {
  late ScrollController _scrollController;

  @override
  void initState() {
    super.initState();
    _scrollController = ScrollController();
  }

  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      controller: _scrollController,
      itemCount: widget.playlistItems.length,
      itemBuilder: (context, index) {
        final playlistItem = widget.playlistItems[index];
        return Padding(
          padding: const EdgeInsets.all(8.0),
          child: ClipRRect(
            borderRadius: BorderRadius.circular(4),
            child: Stack(
              alignment: Alignment.center,
              children: [
                if (playlistItem.snippet!.thumbnails!.high != null)
                  AdaptiveImage.network(                      // Modify this line
                      playlistItem.snippet!.thumbnails!.high!.url!),
                _buildGradient(context),
                _buildTitleAndSubtitle(context, playlistItem),
                _buildPlayButton(context, playlistItem),
              ],
            ),
          ),
        );
      },
    );
  }

  Widget _buildGradient(BuildContext context) {
    return Positioned.fill(
      child: DecoratedBox(
        decoration: BoxDecoration(
          gradient: LinearGradient(
            colors: [
              Colors.transparent,
              Theme.of(context).colorScheme.surface,
            ],
            begin: Alignment.topCenter,
            end: Alignment.bottomCenter,
            stops: const [0.5, 0.95],
          ),
        ),
      ),
    );
  }

  Widget _buildTitleAndSubtitle(
      BuildContext context, PlaylistItem playlistItem) {
    return Positioned(
      left: 20,
      right: 0,
      bottom: 20,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          AdaptiveText(                                       // Also, this line
            playlistItem.snippet!.title!,
            style: Theme.of(context).textTheme.bodyLarge!.copyWith(
                  fontSize: 18,
                  // fontWeight: FontWeight.bold,
                ),
          ),
          if (playlistItem.snippet!.videoOwnerChannelTitle != null)
            AdaptiveText(                                     // And this line
              playlistItem.snippet!.videoOwnerChannelTitle!,
              style: Theme.of(context).textTheme.bodyMedium!.copyWith(
                    fontSize: 12,
                  ),
            ),
        ],
      ),
    );
  }

  Widget _buildPlayButton(BuildContext context, PlaylistItem playlistItem) {
    return Stack(
      alignment: AlignmentDirectional.center,
      children: [
        Container(
          width: 42,
          height: 42,
          decoration: const BoxDecoration(
            color: Colors.white,
            borderRadius: BorderRadius.all(
              Radius.circular(21),
            ),
          ),
        ),
        Link(
          uri: Uri.parse(
              'https://www.youtube.com/watch?v=${playlistItem.snippet!.resourceId!.videoId}'),
          builder: (context, followLink) => IconButton(
            onPressed: followLink,
            color: Colors.red,
            icon: const Icon(Icons.play_circle_fill),
            iconSize: 45,
          ),
        ),
      ],
    );
  }
}

در کد بالا شما هر دو ابزارک Image.network و Text را تطبیق داده اید. سپس، ویجت Playlists را تطبیق دهید.

lib/src/playlists.dart

import 'package:flutter/material.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:provider/provider.dart';

import 'adaptive_image.dart';                                 // Add this line
import 'app_state.dart';

class Playlists extends StatelessWidget {
  const Playlists({super.key, required this.playlistSelected});

  final PlaylistsListSelected playlistSelected;

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

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

typedef PlaylistsListSelected = void Function(Playlist playlist);

class _PlaylistsListView extends StatefulWidget {
  const _PlaylistsListView({
    required this.items,
    required this.playlistSelected,
  });

  final List<Playlist> items;
  final PlaylistsListSelected playlistSelected;

  @override
  State<_PlaylistsListView> createState() => _PlaylistsListViewState();
}

class _PlaylistsListViewState extends State<_PlaylistsListView> {
  late ScrollController _scrollController;

  @override
  void initState() {
    super.initState();
    _scrollController = ScrollController();
  }

  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      controller: _scrollController,
      itemCount: widget.items.length,
      itemBuilder: (context, index) {
        var playlist = widget.items[index];
        return Padding(
          padding: const EdgeInsets.all(8.0),
          child: ListTile(
            leading: AdaptiveImage.network(                   // Change this one.
              playlist.snippet!.thumbnails!.default_!.url!,
            ),
            title: Text(playlist.snippet!.title!),
            subtitle: Text(
              playlist.snippet!.description!,
            ),
            onTap: () {
              widget.playlistSelected(playlist);
            },
          ),
        );
      },
    );
  }
}

این بار فقط ویجت Image.network را تطبیق دادید، اما دو ویجت Text را همانطور که بودند رها کردید. این عمدی بود، زیرا، اگر ویجت‌های Text را تطبیق دهید، وقتی کاربر روی متن ضربه می‌زند، قابلیت onTap ListTile مسدود می‌شود.

برنامه را در وب به درستی اجرا کنید

با اجرای پروکسی CORS، باید بتوانید نسخه وب برنامه را اجرا کنید و چیزی شبیه به زیر داشته باشید:

این برنامه در مرورگر کروم اجرا می شود و تصاویر کوچک تصویر YouTube پر شده است

7. احراز هویت تطبیقی

در این مرحله قصد دارید اپلیکیشن را با دادن قابلیت احراز هویت کاربر گسترش دهید و سپس لیست های پخش آن کاربر را نشان دهید. شما مجبور خواهید بود از چندین پلاگین برای پوشش پلتفرم‌های مختلف استفاده کنید، زیرا مدیریت OAuth بین اندروید، iOS، وب، ویندوز، macOS و لینوکس بسیار متفاوت است.

افزودن پلاگین برای فعال کردن احراز هویت گوگل

شما قصد دارید سه بسته را برای کنترل احراز هویت گوگل نصب کنید.

$ flutter pub add googleapis_auth google_sign_in \
    extension_google_sign_in_as_googleapis_auth
Resolving dependencies...
+ args 2.4.2
+ crypto 3.0.3
+ extension_google_sign_in_as_googleapis_auth 2.0.12
+ google_identity_services_web 0.3.0+2
+ google_sign_in 6.2.1
+ google_sign_in_android 6.1.21
+ google_sign_in_ios 5.7.2
+ google_sign_in_platform_interface 2.4.4
+ google_sign_in_web 0.12.3+2
+ googleapis_auth 1.4.1
+ js 0.6.7 (0.7.0 available)
  matcher 0.12.16 (0.12.16+1 available)
  material_color_utilities 0.5.0 (0.8.0 available)
  meta 1.10.0 (1.11.0 available)
  path 1.8.3 (1.9.0 available)
  test_api 0.6.1 (0.7.0 available)
  web 0.3.0 (0.4.0 available)
Changed 11 dependencies!
7 packages have newer versions incompatible with dependency constraints.
Try `flutter pub outdated` for more information.

برای احراز هویت در Windows، macOS و Linux، از بسته googleapis_auth استفاده کنید. این پلتفرم های دسکتاپ از طریق یک مرورگر وب احراز هویت می شوند. برای احراز هویت در Android، iOS و وب، از بسته‌های google_sign_in و extension_google_sign_in_as_googleapis_auth استفاده کنید. بسته دوم به عنوان یک شیم interop بین دو بسته عمل می کند.

کد را به روز کنید

با ایجاد یک انتزاع قابل استفاده مجدد، ویجت AdaptiveLogin، به روز رسانی را شروع کنید. این ویجت برای استفاده مجدد شما طراحی شده است و به همین دلیل نیاز به پیکربندی دارد:

lib/src/adaptive_login.dart

import 'dart:io' show Platform;

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

import 'app_state.dart';

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

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

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

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

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

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

  final _AdaptiveLoginButtonWidget button;
  final List<String> scopes;

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

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

  late final GoogleSignIn _googleSignIn;

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

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

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

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

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

  Uri? _authUrl;

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

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

این فایل کارهای زیادی انجام می دهد. روش build AdaptiveLogin کارهای سنگین را انجام می دهد. این روش با فراخوانی Platform.isXXX هر دو kIsWeb و dart:io ، پلت فرم زمان اجرا را بررسی می کند. برای Android، iOS، و وب، ویجت حالت‌دهنده _GoogleSignInLogin را نمونه‌سازی می‌کند. برای ویندوز، macOS، و لینوکس، یک ویجت حالتی _GoogleApisAuthLogin را به صورت نمونه ارائه می کند.

پیکربندی اضافی برای استفاده از این کلاس ها مورد نیاز است، که بعداً و پس از به روز رسانی بقیه پایه کد برای استفاده از این ویجت جدید ارائه می شود. با تغییر نام FlutterDevPlaylists به AuthedUserPlaylists شروع کنید تا هدف جدید آن در زندگی بهتر منعکس شود و کد را به روز کنید تا نشان دهد http.Client اکنون پس از ساخت ارسال شده است. در نهایت، کلاس _ApiKeyClient دیگر مورد نیاز نیست:

lib/src/app_state.dart

import 'dart:collection';

import 'package:flutter/foundation.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:http/http.dart' as http;

class AuthedUserPlaylists extends ChangeNotifier {      // Rename class
  set authClient(http.Client client) {                  // Drop constructor, add setter
    _api = YouTubeApi(client);
    _loadPlaylists();
  }

  bool get isLoggedIn => _api != null;                  // Add property

  Future<void> _loadPlaylists() async {
    String? nextPageToken;
    _playlists.clear();

    do {
      final response = await _api!.playlists.list(      // Add ! to _api
        ['snippet', 'contentDetails', 'id'],
        mine: true,                                     // convert from channelId: to mine:
        maxResults: 50,
        pageToken: nextPageToken,
      );
      _playlists.addAll(response.items!);
      _playlists.sort((a, b) => a.snippet!.title!
          .toLowerCase()
          .compareTo(b.snippet!.title!.toLowerCase()));
      notifyListeners();
      nextPageToken = response.nextPageToken;
    } while (nextPageToken != null);
  }

  YouTubeApi? _api;                                     // Convert to optional

  final List<Playlist> _playlists = [];
  List<Playlist> get playlists => UnmodifiableListView(_playlists);

  final Map<String, List<PlaylistItem>> _playlistItems = {};
  List<PlaylistItem> playlistItems({required String playlistId}) {
    if (!_playlistItems.containsKey(playlistId)) {
      _playlistItems[playlistId] = [];
      _retrievePlaylist(playlistId);
    }
    return UnmodifiableListView(_playlistItems[playlistId]!);
  }

  Future<void> _retrievePlaylist(String playlistId) async {
    String? nextPageToken;
    do {
      var response = await _api!.playlistItems.list(    // Add ! to _api
        ['snippet', 'contentDetails'],
        playlistId: playlistId,
        maxResults: 25,
        pageToken: nextPageToken,
      );
      var items = response.items;
      if (items != null) {
        _playlistItems[playlistId]!.addAll(items);
      }
      notifyListeners();
      nextPageToken = response.nextPageToken;
    } while (nextPageToken != null);
  }
}

// Delete the now unused _ApiKeyClient class

سپس، ویجت PlaylistDetails را با نام جدید برای شیء وضعیت برنامه ارائه شده به روز کنید:

lib/src/playlist_details.dart

class PlaylistDetails extends StatelessWidget {
  const PlaylistDetails(
      {required this.playlistId, required this.playlistName, super.key});
  final String playlistId;
  final String playlistName;

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

        return _PlaylistDetailsListView(playlistItems: playlistItems);
      },
    );
  }
}

به طور مشابه، ویجت Playlists را به روز کنید:

lib/src/playlists.dart

class Playlists extends StatelessWidget {
  const Playlists({required this.playlistSelected, super.key});

  final PlaylistsListSelected playlistSelected;

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

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

در نهایت، فایل main.dart را برای استفاده صحیح از ویجت جدید AdaptiveLogin به روز کنید:

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

تغییرات در این فایل منعکس کننده تغییر از نمایش فقط لیست های پخش YouTube Flutter به نمایش لیست های پخش تأیید شده کاربر است. در حالی که کد اکنون کامل است، هنوز یک سری تغییرات در این فایل و فایل‌های تحت برنامه‌های Runner مربوطه لازم است تا بسته‌های google_sign_in و googleapis_auth را برای احراز هویت به درستی پیکربندی کنند.

این برنامه اکنون لیست های پخش YouTube را از کاربر تأیید شده نشان می دهد. با تکمیل ویژگی ها، باید احراز هویت را فعال کنید. برای انجام این کار، بسته های google_sign_in و googleapis_auth را پیکربندی کنید. برای پیکربندی بسته ها، باید فایل main.dart و فایل های برنامه های Runner را تغییر دهید.

در حال پیکربندی googleapis_auth

اولین قدم برای پیکربندی احراز هویت، حذف کلید API است که قبلاً پیکربندی و استفاده کرده‌اید. به صفحه اعتبار پروژه API خود بروید و کلید API را حذف کنید:

صفحه اعتبار پروژه API در کنسول GCP

این یک پاپ آپ ایجاد می کند که با زدن دکمه Delete آن را تأیید می کنید:

پنجره حذف اعتبار

سپس، یک شناسه مشتری OAuth ایجاد کنید:

ایجاد شناسه مشتری OAuth

برای نوع برنامه، برنامه دسکتاپ را انتخاب کنید.

انتخاب نوع برنامه کاربردی دسکتاپ

نام را بپذیرید و روی ایجاد کلیک کنید.

نام گذاری شناسه مشتری

با این کار، Client ID و Client Secret ایجاد می شود که باید به lib/main.dart اضافه کنید تا جریان googleapis_auth را پیکربندی کنید. یک جزئیات پیاده سازی مهم این است که جریان googleapis_auth از یک وب سرور موقت در حال اجرا در localhost برای گرفتن توکن OAuth تولید شده استفاده می کند، که در macOS نیاز به تغییر در فایل macos/Runner/Release.entitlements دارد:

macos/Runner/Release.entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
        <dict>
                <key>com.apple.security.app-sandbox</key>
                <true/>
                <!-- add the following two lines -->
                <key>com.apple.security.network.server</key>
                <true/>
                <key>com.apple.security.network.client</key>
                <true/>
        </dict>
</plist>

نیازی نیست این ویرایش را در فایل macos/Runner/DebugProfile.entitlements انجام دهید زیرا از قبل دارای حقی برای com.apple.security.network.server برای فعال کردن Hot Reload و ابزار اشکال زدایی Dart VM است.

اکنون باید بتوانید برنامه خود را روی ویندوز، macOS یا لینوکس اجرا کنید (اگر برنامه روی آن اهداف کامپایل شده باشد).

برنامه ای که لیست های پخش را برای کاربر وارد شده نشان می دهد

در حال پیکربندی google_sign_in برای Android

به صفحه اعتبار پروژه API خود بازگردید و شناسه مشتری OAuth دیگری ایجاد کنید، به جز این بار Android را انتخاب کنید:

انتخاب نوع برنامه اندروید

برای بقیه فرم، نام بسته را با بسته اعلام شده در android/app/src/main/AndroidManifest.xml پر کنید. اگر دستورالعمل‌های مربوط به نامه را دنبال کرده‌اید، باید com.example.adaptive_app باشد. اثر انگشت گواهی SHA-1 را با استفاده از دستورالعمل‌های صفحه راهنمای کنسول Google Cloud Platform استخراج کنید:

نام گذاری شناسه کلاینت اندروید

این کافی است تا برنامه روی اندروید کار کند. بسته به انتخاب Google API هایی که استفاده می کنید، ممکن است لازم باشد فایل JSON تولید شده را به بسته نرم افزاری خود اضافه کنید.

اجرای برنامه در اندروید

در حال پیکربندی google_sign_in برای iOS

به صفحه اعتبار پروژه API خود بازگردید و شناسه مشتری OAuth دیگری ایجاد کنید، به جز این بار iOS را انتخاب کنید:

. انتخاب نوع برنامه iOS

برای بقیه فرم، ID Bundle را با باز کردن ios/Runner.xcworkspace در Xcode پر کنید. به Project Navigator بروید، Runner را در Navigator انتخاب کنید، سپس زبانه General را انتخاب کنید و Bundle Identifier را کپی کنید. اگر گام به گام این لبه کد را دنبال کرده اید، باید com.example.adaptiveApp باشد.

برای بقیه فرم، ID Bundle را پر کنید. ios/Runner.xcworkspace را در Xcode باز کنید. به Project Navigator بروید. به سربرگ Runner > General بروید. شناسه Bundle را کپی کنید. اگر گام به گام این لبه کد را دنبال کرده اید، مقدار آن باید com.example.adaptiveApp باشد.

شناسه Bundle را در Xcode کجا پیدا کنیم

فعلاً شناسه App Store و Team ID را نادیده بگیرید، زیرا برای توسعه محلی مورد نیاز نیستند:

نام‌گذاری شناسه مشتری iOS

فایل .plist تولید شده را دانلود کنید، نام آن بر اساس شناسه مشتری تولید شده شما است. نام فایل دانلود شده را به GoogleService-Info.plist تغییر دهید، و سپس آن را به ویرایشگر Xcode در حال اجرا خود بکشید، در کنار فایل Info.plist در زیر Runner/Runner در ناوبری سمت چپ. برای گفتگوی گزینه‌ها در Xcode، در صورت نیاز Copy items ، Create folder references و Add to Runner target را انتخاب کنید.

افزودن فایل plist تولید شده به برنامه iOS در Xcode

از Xcode خارج شوید سپس، در IDE انتخابی خود، موارد زیر را به Info.plist خود اضافه کنید:

ios/Runner/Info.plist

<key>CFBundleURLTypes</key>
<array>
        <dict>
                <key>CFBundleTypeRole</key>
                <string>Editor</string>
                <key>CFBundleURLSchemes</key>
                <array>
                        <!-- TODO Replace this value: -->
                        <!-- Copied from GoogleService-Info.plist key REVERSED_CLIENT_ID -->
                        <string>com.googleusercontent.apps.TODO-REPLACE-ME</string>
                </array>
        </dict>
</array>

شما باید مقدار را برای مطابقت با ورودی در فایل GoogleService-Info.plist ایجاد شده خود ویرایش کنید. برنامه خود را اجرا کنید و پس از ورود به سیستم، لیست پخش خود را ببینید.

برنامه در حال اجرا در iOS

در حال پیکربندی google_sign_in برای وب

به صفحه اعتبار پروژه API خود بازگردید و شناسه مشتری OAuth دیگری ایجاد کنید، به جز اینکه این بار برنامه وب را انتخاب کنید:

انتخاب نوع برنامه وب

برای بقیه فرم، مبداهای مجاز جاوا اسکریپت را به صورت زیر پر کنید:

نامگذاری شناسه سرویس گیرنده برنامه وب

این یک شناسه مشتری تولید می کند. meta تگ زیر را به web/index.html اضافه کنید، به‌روزرسانی شده تا شامل شناسه مشتری ایجاد شده باشد:

web/index.html

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

اجرای این نمونه نیاز به کمی نگه داشتن دست دارد. شما باید پروکسی CORS را که در مرحله قبل ایجاد کردید اجرا کنید، و باید برنامه وب Flutter را در پورت مشخص شده در فرم وب اپلیکیشن OAuth Client ID با استفاده از دستورالعمل های زیر اجرا کنید.

در یک ترمینال، سرور پروکسی CORS را به صورت زیر اجرا کنید:

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

در ترمینال دیگری، برنامه Flutter را به صورت زیر اجرا کنید:

$ flutter run -d chrome --web-hostname localhost --web-port 8090
Launching lib/main.dart on Chrome in debug mode...
Waiting for connection from debug service on Chrome...             20.4s
This app is linked to the debug service: ws://127.0.0.1:52430/Nb3Q7puZqvI=/ws
Debug service listening on ws://127.0.0.1:52430/Nb3Q7puZqvI=/ws

💪 Running with sound null safety 💪

🔥  To hot restart changes while running, press "r" or "R".
For a more detailed help message, press "h". To quit, press "q".

پس از یک بار ورود به سیستم، باید لیست های پخش خود را ببینید:

برنامه در حال اجرا در مرورگر کروم

8. مراحل بعدی

تبریک می گویم!

شما نرم افزار Codelab را تکمیل کرده اید و یک برنامه تطبیقی ​​Flutter ساخته اید که بر روی هر شش پلت فرمی که Flutter پشتیبانی می کند اجرا می شود. شما کد را برای رسیدگی به تفاوت‌ها در نحوه چیدمان صفحه‌ها، نحوه تعامل با متن، نحوه بارگیری تصاویر و نحوه عملکرد احراز هویت تطبیق دادید.

چیزهای زیادی وجود دارد که می توانید در برنامه های خود تطبیق دهید. برای یادگیری روش‌های اضافی برای تطبیق کد خود با محیط‌های مختلف که در آن اجرا می‌شود، به ساخت برنامه‌های تطبیقی ​​مراجعه کنید.