التطبيقات التكيُّفية في Flutter

1. مقدمة

‫Flutter هي مجموعة أدوات واجهة مستخدم من Google يمكن استخدامها لإنشاء تطبيقات محلية ومميّزة للأجهزة الجوّالة والويب وأجهزة سطح المكتب من خلال قاعدة رموز برمجية واحدة. في هذا الدرس البرمجي، ستتعلّم كيفية إنشاء تطبيق Flutter يتكيّف مع النظام الأساسي الذي يعمل عليه، سواء كان Android أو iOS أو الويب أو Windows أو macOS أو Linux.

المُعطيات

  • كيفية توسيع نطاق تطبيق Flutter مصمَّم للأجهزة الجوّالة ليعمل على جميع الأنظمة الأساسية الستة التي يتيحها Flutter
  • واجهات برمجة التطبيقات المختلفة في Flutter لرصد الأنظمة الأساسية وحالات استخدام كل واجهة برمجة تطبيقات
  • التكيّف مع القيود والتوقّعات المتعلّقة بتشغيل تطبيق على الويب
  • كيفية استخدام حِزم مختلفة معًا لتوفير النطاق الكامل لمنصات Flutter

ما ستنشئه

في هذا الدرس التطبيقي حول الترميز، ستنشئ في البداية تطبيق Flutter لنظامَي التشغيل Android وiOS يستكشف قوائم تشغيل Flutter على YouTube. بعد ذلك، ستعدّل هذا التطبيق ليعمل على أنظمة التشغيل الثلاثة لأجهزة الكمبيوتر (Windows وmacOS وLinux) من خلال تغيير طريقة عرض المعلومات حسب حجم نافذة التطبيق. بعد ذلك، عليك تعديل التطبيق ليتوافق مع الويب من خلال إتاحة إمكانية تحديد النص المعروض في التطبيق، كما يتوقّع مستخدمو الويب. أخيرًا، ستضيف مصادقة إلى التطبيق حتى تتمكّن من استكشاف قوائم التشغيل الخاصة بك، بدلاً من تلك التي أنشأها فريق Flutter، والتي تتطلّب أساليب مختلفة للمصادقة على Android وiOS والويب، مقارنةً بأنظمة التشغيل الثلاثة لأجهزة الكمبيوتر، وهي Windows وmacOS وLinux.

في ما يلي لقطة شاشة لتطبيق Flutter على Android وiOS:

التطبيق المكتمل الذي يعمل على محاكي Android

التطبيق النهائي الذي يتم تشغيله على محاكي iOS

يجب أن يشبه هذا التطبيق الذي يتم تشغيله في وضع ملء الشاشة على جهاز macOS لقطة الشاشة التالية.

التطبيق المكتمل الذي يعمل على جهاز macOS

يركّز هذا الدرس العملي على تحويل تطبيق Flutter للأجهزة الجوّالة إلى تطبيق متكيّف يعمل على جميع منصات Flutter الست. يتم تجاهل المفاهيم ومجموعات الرموز غير ذات الصلة، ويتم توفيرها لك لنسخها ولصقها.

ما الذي تريد تعلّمه من هذا الدرس العملي؟

أنا جديد على هذا الموضوع وأريد الحصول على نظرة عامة جيدة. أعرف بعض المعلومات عن هذا الموضوع، ولكنّني أريد مراجعتها. أبحث عن نموذج رمز لاستخدامه في مشروعي. أبحث عن شرح لموضوع معيّن.

2. إعداد بيئة تطوير Flutter

تحتاج إلى برنامجَين لإكمال هذا الدرس التطبيقي، وهما حزمة تطوير البرامج (SDK) من Flutter ومحرِّر.

يمكنك تشغيل الدرس العملي باستخدام أيّ من الأجهزة التالية:

  • جهاز Android أو iOS فعلي متصل بالكمبيوتر وتم ضبطه على وضع "المطوّر"
  • محاكي iOS (يتطلّب تثبيت أدوات Xcode)
  • محاكي Android (يتطلّب الإعداد في "استوديو Android")
  • متصفّح (يجب استخدام Chrome لتصحيح الأخطاء).
  • كتطبيق سطح مكتب على Windows أو Linux أو macOS يجب أن يتم التطوير على النظام الأساسي الذي تخطّط للنشر عليه. لذا، إذا أردت تطوير تطبيق Windows لسطح المكتب، يجب أن يتم التطوير على Windows للوصول إلى سلسلة الإنشاء المناسبة. هناك متطلبات خاصة بنظام التشغيل يتم تناولها بالتفصيل على docs.flutter.dev/desktop.

3- البدء

تأكيد بيئة التطوير

أسهل طريقة للتأكّد من أنّ كل شيء جاهز للتطوير هي تنفيذ الأمر التالي:

flutter doctor

إذا ظهر أي شيء بدون علامة اختيار، نفِّذ ما يلي للحصول على مزيد من التفاصيل حول المشكلة:

flutter doctor -v

قد تحتاج إلى تثبيت أدوات المطوّرين لتطوير التطبيقات على الأجهزة الجوّالة أو أجهزة الكمبيوتر. للحصول على مزيد من التفاصيل حول إعداد الأدوات حسب نظام التشغيل المضيف، يُرجى الاطّلاع على المستندات في مستندات تثبيت Flutter.

إنشاء مشروع Flutter

إحدى طرق البدء في كتابة تطبيقات Flutter لسطح المكتب هي استخدام أداة سطر الأوامر في Flutter لإنشاء مشروع Flutter. بدلاً من ذلك، قد توفّر بيئة التطوير المتكاملة سير عمل لإنشاء مشروع Flutter من خلال واجهة المستخدم.

$ flutter create adaptive_app
Creating project adaptive_app...
Resolving dependencies in adaptive_app... (1.8s)
Got dependencies in adaptive_app.
Wrote 129 files.

All done!
You can find general documentation for Flutter at: https://docs.flutter.dev/
Detailed API documentation is available at: https://api.flutter.dev/
If you prefer video documentation, consider: https://www.youtube.com/c/flutterdev

In order to run your application, type:

  $ cd adaptive_app
  $ flutter run

Your application code is in adaptive_app/lib/main.dart.

للتأكّد من أنّ كل شيء يعمل بشكل صحيح، شغِّل تطبيق Flutter النموذجي كتطبيق للأجهزة الجوّالة كما هو موضّح أدناه. بدلاً من ذلك، افتح هذا المشروع في بيئة التطوير المتكاملة (IDE)، واستخدِم أدواته لتشغيل التطبيق. بفضل الخطوة السابقة، يجب أن يكون التشغيل كتطبيق على الكمبيوتر هو الخيار الوحيد المتاح.

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

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

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

من المفترض أن يظهر لك التطبيق قيد التشغيل. يجب تعديل المحتوى.

لتعديل المحتوى، عدِّل الرمز في lib/main.dart باستخدام الرمز التالي. لتغيير ما يعرضه تطبيقك، نفِّذ عملية إعادة تحميل سريعة.

  • إذا كنت تشغّل التطبيق باستخدام سطر الأوامر، اكتب r في وحدة التحكّم لإجراء إعادة تحميل سريعة.
  • إذا شغّلت التطبيق باستخدام بيئة تطوير متكاملة (IDE)، سيتم إعادة تحميل التطبيق عند حفظ الملف.

lib/main.dart

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

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

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

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

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

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

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

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

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

تم تصميم التطبيق ليعطيك فكرة عن كيفية رصد المنصات المختلفة والتكيّف معها. في ما يلي التطبيق الذي يعمل بشكل أصلي على Android وiOS:

عرض خصائص النافذة على محاكي Android

عرض خصائص النافذة على محاكي iOS

في ما يلي الرمز نفسه الذي يتم تشغيله تلقائيًا على نظام التشغيل macOS وداخل Chrome، ويتم تشغيله أيضًا على نظام التشغيل macOS.

عرض خصائص النافذة على جهاز macOS

عرض خصائص النافذة في متصفّح Chrome

النقطة المهمة التي يجب ملاحظتها هنا هي أنّ Flutter، من النظرة الأولى، يبذل قصارى جهده لتكييف المحتوى مع الشاشة التي يتم تشغيله عليها. يحتوي الكمبيوتر المحمول الذي تم التقاط لقطات الشاشة عليه على شاشة Mac عالية الدقة، ولهذا السبب يتم عرض كل من إصدار macOS وإصدار الويب من التطبيق بنسبة بكسل الجهاز إلى البكسل على الشاشة تبلغ 2. في المقابل، تظهر نسبة 3 على هاتف iPhone 12، و2.63 على هاتف Pixel 2. في جميع الحالات، يكون النص المعروض متشابهًا إلى حد كبير، ما يسهّل عملنا كمطوّرين.

النقطة الثانية التي يجب ملاحظتها هي أنّ الخيارَين المتاحَين لمعرفة النظام الأساسي الذي يتم تشغيل الرمز عليه يؤديان إلى قيم مختلفة. يفحص الخيار الأول العنصر Platform الذي تم استيراده من dart:io، بينما يسترد الخيار الثاني (المتاح فقط داخل طريقة build الخاصة بالأداة) العنصر Theme من الوسيط BuildContext.

والسبب في اختلاف النتائج التي تعرضها هاتان الطريقتان هو اختلاف الغرض منهما. يهدف العنصر Platform الذي تم استيراده من dart:io إلى استخدامه لاتخاذ قرارات مستقلة عن خيارات العرض. ومن الأمثلة البارزة على ذلك تحديد المكوّنات الإضافية التي سيتم استخدامها، والتي قد تتطابق أو لا تتطابق مع عمليات التنفيذ الأصلية لمنصة مادية معيّنة.

يهدف استخراج Theme من BuildContext إلى اتخاذ قرارات التنفيذ التي تركّز على المظهر. من الأمثلة البارزة على ذلك تحديد ما إذا كنت ستستخدم شريط التمرير Material أو شريط التمرير Cupertino، كما هو موضّح في Slider.adaptive.

في القسم التالي، ستنشئ تطبيقًا أساسيًا لاستكشاف قوائم تشغيل YouTube تم تحسينه لنظامَي التشغيل Android وiOS فقط. في الأقسام التالية، ستضيف تعديلات مختلفة لتحسين أداء التطبيق على أجهزة الكمبيوتر المكتبي والويب.

4. إنشاء تطبيق للأجهزة الجوّالة

إضافة حِزم

في هذا التطبيق، ستستخدم مجموعة متنوعة من حِزم Flutter للوصول إلى YouTube Data API وإدارة الحالة وتطبيق بعض السمات.

$ flutter pub add googleapis http provider url_launcher flex_color_scheme go_router
Resolving dependencies...
Downloading packages...
+ _discoveryapis_commons 1.0.7
  characters 1.4.0 (1.4.1 available)
+ flex_color_scheme 8.3.0
+ flex_seed_scheme 3.5.1
> flutter_lints 6.0.0 (was 5.0.0)
+ flutter_web_plugins 0.0.0 from sdk flutter
+ go_router 16.2.0
+ googleapis 14.0.0
+ http 1.5.0
+ http_parser 4.1.2
> lints 6.0.0 (was 5.1.1)
+ logging 1.3.0
  material_color_utilities 0.11.1 (0.13.0 available)
  meta 1.16.0 (1.17.0 available)
+ nested 1.0.0
+ plugin_platform_interface 2.1.8
+ provider 6.1.5
  test_api 0.7.6 (0.7.7 available)
+ typed_data 1.4.0
+ url_launcher 6.3.2
+ url_launcher_android 6.3.17
+ url_launcher_ios 6.3.4
+ url_launcher_linux 3.2.1
+ url_launcher_macos 3.2.3
+ url_launcher_platform_interface 2.3.2
+ url_launcher_web 2.4.1
+ url_launcher_windows 3.1.4
+ web 1.1.1
Changed 24 dependencies!
4 packages have newer versions incompatible with dependency constraints.
Try `flutter pub outdated` for more information.

يضيف هذا الأمر عددًا من الحِزم إلى التطبيق:

  • googleapis: مكتبة Dart تم إنشاؤها وتتيح الوصول إلى واجهات Google APIs.
  • http: هي مكتبة لإنشاء طلبات HTTP تخفي الاختلافات بين المتصفّحات الأصلية ومتصفّحات الويب.
  • provider: توفّر إدارة الحالة.
  • url_launcher: يوفّر الوسائل للانتقال إلى فيديو من قائمة تشغيل. كما هو موضّح من التبعيات التي تم حلّها، يتضمّن url_launcher عمليات تنفيذ لأنظمة التشغيل Windows وmacOS وLinux والويب، بالإضافة إلى Android وiOS التلقائيين. يعني استخدام هذه الحزمة أنّك لن تحتاج إلى إنشاء رمز خاص بالمنصة لهذه الوظيفة.
  • flex_color_scheme: يمنح التطبيق نظام ألوان تلقائيًا جميلاً. لمزيد من المعلومات، يُرجى الاطّلاع على مستندات flex_color_scheme API.
  • go_router: تنفِّذ هذه السمة عملية التنقّل بين الشاشات المختلفة. توفر هذه الحزمة واجهة برمجة تطبيقات سهلة الاستخدام تستند إلى عناوين URL للتنقّل باستخدام Router في Flutter.

ضبط تطبيقات الأجهزة الجوّالة للحساب url_launcher

يتطلّب المكوّن الإضافي url_launcher ضبط تطبيقات التشغيل على Android وiOS. في مشغّل iOS Flutter، أضِف الأسطر التالية إلى قاموس 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، لذا أنشئ حسابًا إذا لم يكن لديك حساب.

انتقِل إلى Developer Console من أجل إنشاء مشروع لواجهة برمجة التطبيقات:

عرض وحدة تحكّم Google Cloud Platform أثناء عملية إنشاء المشروع

بعد إنشاء مشروع، انتقِل إلى صفحة "مكتبة واجهة برمجة التطبيقات". في مربّع البحث، أدخِل "youtube"، ثم اختَر youtube data api v3.

اختيار الإصدار 3 من YouTube Data API في Google Cloud Console

في صفحة تفاصيل YouTube Data API الإصدار 3، فعِّل واجهة برمجة التطبيقات.

5a877ea82b83ae42.png

بعد تفعيل واجهة برمجة التطبيقات، انتقِل إلى صفحة بيانات الاعتماد وأنشئ مفتاحًا لواجهة برمجة التطبيقات.

إنشاء بيانات الاعتماد في وحدة تحكّم Google Cloud Platform

بعد بضع ثوانٍ، من المفترض أن يظهر مربّع حوار يتضمّن مفتاح واجهة برمجة التطبيقات الجديد. ستستخدم هذا المفتاح قريبًا.

النافذة المنبثقة التي تعرض مفتاح واجهة برمجة التطبيقات الذي تم إنشاؤه

إضافة رمز

في بقية هذه الخطوة، ستنسخ الكثير من الرموز وتلصقها لإنشاء تطبيق على الأجهزة الجوّالة، بدون أي تعليق على الرموز. تهدف تجربة البرمجة هذه إلى أخذ تطبيق الأجهزة الجوّالة وتكييفه ليعمل على كلّ من أجهزة الكمبيوتر والويب. للحصول على مقدمة أكثر تفصيلاً حول إنشاء تطبيقات Flutter للأجهزة الجوّالة، يمكنك الاطّلاع على تطبيق Flutter الأول.

أضِف الملفات التالية، بدءًا بكائن الحالة للتطبيق.

lib/src/app_state.dart

import 'dart:collection';

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

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

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

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

  final String _flutterDevAccountId;
  late final YouTubeApi _api;

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

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

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

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

  final String key;
  final http.Client client;

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

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

بعد ذلك، أضِف صفحة تفاصيل قائمة التشغيل الفردية.

lib/src/playlist_details.dart

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

import 'app_state.dart';

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

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

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

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

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

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

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

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

بعد ذلك، أضِف قائمة قوائم التشغيل.

lib/src/playlists.dart

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

import 'app_state.dart';

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

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

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

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

  final List<Playlist> items;

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

واستبدِل محتوى الملف main.dart بما يلي:

lib/main.dart

import 'dart:io';

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

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

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

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

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

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

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

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

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

أنت على وشك الانتهاء من إعداد هذا الرمز البرمجي لتشغيله على Android وiOS. يجب إجراء تغيير واحد إضافي، وهو تعديل الثابت youTubeApiKey باستخدام مفتاح 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>

تشغيل التطبيق

بعد إكمال التطبيق، من المفترض أن تتمكّن من تشغيله بنجاح على محاكي Android أو محاكي iPhone. ستظهر لك قائمة بقوائم تشغيل Flutter، وعند اختيار قائمة تشغيل، ستظهر لك الفيديوهات في تلك القائمة، وأخيرًا، إذا نقرت على زر "تشغيل"، سيتم توجيهك إلى تجربة YouTube لمشاهدة الفيديو.

التطبيق الذي يعرض قوائم التشغيل الخاصة بحساب FlutterDev على YouTube

عرض الفيديوهات في قائمة تشغيل محدّدة

فيديو محدّد يتم تشغيله داخل مشغّل YouTube

ومع ذلك، إذا حاولت تشغيل هذا التطبيق على الكمبيوتر المكتبي، ستلاحظ أنّ التنسيق يبدو غير مناسب عند توسيعه إلى نافذة عادية بحجم الكمبيوتر المكتبي. ستبحث في الخطوة التالية عن طرق للتكيّف مع هذا التغيير.

5- التكيّف مع الكمبيوتر المكتبي

المشكلة في الكمبيوتر المكتبي

إذا شغّلت التطبيق على إحدى منصات أجهزة الكمبيوتر الأصلية، أي Windows أو macOS أو Linux، ستلاحظ مشكلة مثيرة للاهتمام. يعمل هذا الحلّ، ولكن يبدو ... غريبًا.

التطبيق الذي يتم تشغيله على جهاز macOS ويعرض قائمة بقوائم التشغيل، ويبدو غير متناسب

الفيديوهات في قائمة تشغيل على جهاز macOS

يمكن حلّ هذه المشكلة من خلال إضافة عرض مقسّم، حيث يتم عرض قوائم التشغيل على اليمين والفيديوهات على اليسار. ومع ذلك، لا تريد أن يتم تفعيل هذا التصميم إلا عندما لا يتم تشغيل الرمز على Android أو iOS، ويكون حجم النافذة كبيرًا بما يكفي. توضّح التعليمات التالية كيفية تنفيذ هذه الإمكانية.

أولاً، أضِف حزمة split_view للمساعدة في إنشاء التنسيق.

$ flutter pub add split_view
Resolving dependencies...
Downloading packages...
  characters 1.4.0 (1.4.1 available)
  material_color_utilities 0.11.1 (0.13.0 available)
  meta 1.16.0 (1.17.0 available)
+ split_view 3.2.1
  test_api 0.7.6 (0.7.7 available)
Changed 1 dependency!
4 packages have newer versions incompatible with dependency constraints.
Try `flutter pub outdated` for more information.

تقديم التطبيقات المصغّرة التكيّفية

النمط الذي ستستخدمه في هذا الدرس التطبيقي حول الترميز هو تقديم أدوات Adaptive Widgets التي تتخذ خيارات التنفيذ استنادًا إلى سمات مثل عرض الشاشة وسمة النظام الأساسي وما شابه ذلك. في هذه الحالة، ستضيف أداة AdaptivePlaylists تعيد صياغة طريقة تفاعل Playlists وPlaylistDetails. عدِّل ملف lib/main.dart على النحو التالي:

lib/main.dart

import 'dart:io';

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

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

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

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

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

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

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

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

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

بعد ذلك، أنشئ الملف الخاص بأداة AdaptivePlaylist:

lib/src/adaptive_playlists.dart

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

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

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

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

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

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

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

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

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

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

هذا الملف مهم لعدة أسباب. أولاً، يتم استخدام عرض النافذة (باستخدام MediaQuery.of(context).size.width)، كما يتم فحص المظهر (باستخدام Theme.of(context).platform) لتحديد ما إذا كان سيتم عرض تخطيط عريض باستخدام الأداة SplitView أو عرض ضيق بدونها.

ثانيًا، يتناول هذا القسم معالجة التنقّل المبرمَج. تعرض هذه السمة وسيطة ردّ الاتصال في الأداة Playlists. يُعلم هذا الإجراء الاحتياطي الرمز المحيط بأنّ المستخدم قد اختار قائمة تشغيل. بعد ذلك، يجب أن تنفّذ التعليمات البرمجية العمل اللازم لعرض قائمة التشغيل هذه. يؤدي ذلك إلى تغيير الحاجة إلى Scaffold في أدوات Playlists وPlaylistDetails. وبما أنّها لم تعُد في المستوى الأعلى، عليك إزالة الرمز Scaffold من تلك التطبيقات المصغّرة.

بعد ذلك، عدِّل ملف src/lib/playlists.dart ليطابق الرمز التالي:

lib/src/playlists.dart

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

import 'app_state.dart';

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

  final PlaylistsListSelected playlistSelected;

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

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

typedef PlaylistsListSelected = void Function(Playlist playlist);

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

  final List<Playlist> items;
  final PlaylistsListSelected playlistSelected;

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

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

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

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

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

هناك الكثير من التغييرات في هذا الملف. بالإضافة إلى تقديم وظيفة رد الاتصال playlistSelected المذكورة أعلاه وإزالة الأداة Scaffold، يتم تحويل الأداة _PlaylistsListView من أداة عديمة الحالة إلى أداة ذات حالة. هذا التغيير مطلوب بسبب تقديم ScrollController مملوكة يجب إنشاؤها وإتلافها.

إنّ إضافة ScrollController أمر مهم لأنّه مطلوب في التصميمات ذات العرض الكامل، إذ يتضمّن التصميم عنصرَي ListView جنبًا إلى جنب. على الهاتف الجوّال، من الشائع أن يكون هناك ListView واحد، وبالتالي يمكن أن يكون هناك ScrollController واحد طويل الأمد ترتبط به جميع ListViews وتنفصل عنها خلال دورات حياتها الفردية. يختلف الأمر على الكمبيوتر المكتبي، حيث يكون من المنطقي عرض عدة ListView جنبًا إلى جنب.

وأخيرًا، عدِّل ملف lib/src/playlist_details.dart على النحو التالي:

lib/src/playlist_details.dart

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

import 'app_state.dart';

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

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

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

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

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

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

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

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

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

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

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

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

على غرار أداة Playlists الموضّحة أعلاه، يتضمّن هذا الملف أيضًا تغييرات لإزالة أداة Scaffold، وإضافة أداة ScrollController مملوكة.

شغِّل التطبيق مرة أخرى.

تشغيل التطبيق على جهاز سطح المكتب الذي تختاره، سواء كان يعمل بنظام التشغيل Windows أو macOS أو Linux من المفترض أن تعمل الآن على النحو المتوقّع.

التطبيق الذي يتم تشغيله على macOS في وضع &quot;تقسيم العرض&quot;

6. التكيّف مع الويب

ما المشكلة في هذه الصور؟

عند محاولة تشغيل هذا التطبيق على الويب، سيظهر الآن أنّه يجب بذل المزيد من الجهد لتكييفه مع متصفّحات الويب.

التطبيق الذي يتم تشغيله في متصفّح Chrome بدون صور مصغّرة من YouTube

إذا ألقيت نظرة في وحدة تصحيح الأخطاء، ستظهر لك إشارة بسيطة إلى ما يجب فعله بعد ذلك.

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

When the exception was thrown, this was the stack

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

إنشاء خادم وكيل CORS

إحدى طرق التعامل مع مشاكل عرض الصور هي استخدام خدمة ويب وسيطة لإضافة العناوين المطلوبة لمشاركة الموارد المشتركة النطاق. افتح نافذة طرفية وأنشئ خادم ويب Dart على النحو التالي:

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

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

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

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

  cd yt_cors_proxy
  dart run bin/server.dart

غيِّر الدليل إلى خادم yt_cors_proxy، وأضِف بعض التبعيات المطلوبة:

$ cd yt_cors_proxy
$ dart pub add shelf_cors_headers http
"http" was found in dev_dependencies. Removing "http" and adding it to dependencies instead.
Resolving dependencies...
Downloading packages...
  http 1.5.0 (from dev dependency to direct dependency)
+ shelf_cors_headers 0.1.5
Changed 2 dependencies!

هناك تبعية حالية لم تعُد مطلوبة. يجب اقتطاعها على النحو التالي:

$ dart pub remove shelf_router
Resolving dependencies...
Downloading packages...
These packages are no longer being depended on:
- http_methods 1.1.1
- shelf_router 1.1.4
Changed 2 dependencies!

بعد ذلك، عدِّل محتوى ملف server.dart ليطابق ما يلي:

yt_cors_proxy/bin/server.dart

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

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

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

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

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

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

يمكنك تشغيل هذا الخادم على النحو التالي:

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

بدلاً من ذلك، يمكنك إنشاءها كصورة Docker وتشغيل صورة Docker الناتجة على النحو التالي:

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

بعد ذلك، عدِّل رمز Flutter للاستفادة من وكيل CORS هذا، ولكن فقط عند التشغيل داخل متصفّح ويب.

مجموعة من التطبيقات المصغّرة القابلة للتكيّف

يتمثّل العنصر الأول من مجموعة الأدوات في طريقة استخدام تطبيقك لخادم وكيل CORS.

lib/src/adaptive_image.dart

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

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

  late final String _url;

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

يستخدم هذا التطبيق الثابت kIsWeb بسبب الاختلافات في منصة وقت التشغيل. تغيّر الأداة التكيّفية الأخرى التطبيق ليعمل مثل صفحات الويب الأخرى. يتوقّع مستخدمو المتصفّح أن يكون النص قابلاً للتحديد.

lib/src/adaptive_text.dart

import 'package:flutter/material.dart';

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

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

الآن، وزِّع هذه التعديلات على مستوى قاعدة الرموز:

lib/src/playlist_details.dart

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

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

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

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

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

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

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

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

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

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

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

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

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

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

في الرمز أعلاه، عدّلت كلاً من الأداة Image.network والأداة Text. بعد ذلك، عدِّل أداة Playlists.

lib/src/playlists.dart

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

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

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

  final PlaylistsListSelected playlistSelected;

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

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

typedef PlaylistsListSelected = void Function(Playlist playlist);

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

  final List<Playlist> items;
  final PlaylistsListSelected playlistSelected;

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

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

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

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

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

في هذه المرة، عدّلت أداة Image.network فقط، ولكن تركت أداتَي Text كما هما. كان ذلك مقصودًا لأنّه في حال تعديل أدوات Text، يتم حظر وظيفة onTap في ListTile عندما ينقر المستخدم على النص.

تشغيل التطبيق على الويب بشكل صحيح

بعد تشغيل خادم وكيل CORS، من المفترض أن تتمكّن من تشغيل إصدار الويب من التطبيق وأن يظهر بالشكل التالي:

التطبيق الذي يتم تشغيله في متصفّح Chrome، مع ملء الصور المصغّرة لفيديوهات YouTube

7. المصادقة التكيّفية

في هذه الخطوة، ستوسّع نطاق التطبيق من خلال منحه القدرة على مصادقة المستخدم، ثم عرض قوائم التشغيل الخاصة بهذا المستخدم. عليك استخدام العديد من المكوّنات الإضافية لتغطية الأنظمة الأساسية المختلفة التي يمكن تشغيل التطبيق عليها، لأنّ التعامل مع OAuth يختلف بشكل كبير بين Android وiOS والويب وWindows وmacOS وLinux.

إضافة مكوّنات إضافية لتفعيل المصادقة باستخدام Google

ستثبّت ثلاث حِزم للتعامل مع مصادقة Google.

$ flutter pub add googleapis_auth google_sign_in \
    extension_google_sign_in_as_googleapis_auth logging
Resolving dependencies...
Downloading packages...
+ args 2.7.0
  characters 1.4.0 (1.4.1 available)
+ crypto 3.0.6
+ extension_google_sign_in_as_googleapis_auth 3.0.0
+ google_identity_services_web 0.3.3+1
+ google_sign_in 7.1.1
+ google_sign_in_android 7.0.3
+ google_sign_in_ios 6.1.0
+ google_sign_in_platform_interface 3.0.0
+ google_sign_in_web 1.0.0
+ googleapis_auth 2.0.0
  logging 1.3.0 (from transitive dependency to direct dependency)
  material_color_utilities 0.11.1 (0.13.0 available)
  meta 1.16.0 (1.17.0 available)
  test_api 0.7.6 (0.7.7 available)
Changed 11 dependencies!
4 packages have newer versions incompatible with dependency constraints.
Try `flutter pub outdated` for more information.

لإجراء المصادقة على أجهزة Windows وmacOS وLinux، استخدِم حزمة googleapis_auth. تتم المصادقة على هذه المنصات المخصصة لأجهزة الكمبيوتر باستخدام متصفّح ويب. للمصادقة على Android وiOS والويب، استخدِم الحزمتَين google_sign_in وextension_google_sign_in_as_googleapis_auth. تعمل الحزمة الثانية كطبقة توافق بين الحزمتين.

تعديل الرمز

ابدأ التحديث بإنشاء تجريد جديد قابل لإعادة الاستخدام، وهو الأداة AdaptiveLogin. تم تصميم هذا التطبيق المصغّر لتتمكّن من إعادة استخدامه، وبالتالي يتطلّب بعض الإعدادات:

lib/src/adaptive_login.dart

import 'dart:async';
import 'dart:io' show Platform;

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

import 'app_state.dart';

final _log = Logger('AdaptiveLogin');

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

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

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

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

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

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

  final _AdaptiveLoginButtonWidget button;
  final List<String> scopes;

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

class _GoogleSignInLoginState extends State<_GoogleSignInLogin> {
  @override
  initState() {
    super.initState();
    _googleSignIn = GoogleSignIn.instance;
    _googleSignIn.initialize();
    _authEventsSubscription = _googleSignIn.authenticationEvents.listen((
      event,
    ) async {
      _log.fine('Google Sign-In authentication event: $event');
      if (event is GoogleSignInAuthenticationEventSignIn) {
        final googleSignInClientAuthorization = await event
            .user
            .authorizationClient
            .authorizationForScopes(widget.scopes);
        if (googleSignInClientAuthorization == null) {
          _log.warning('Google Sign-In authenticated client creation failed');
          return;
        }
        _log.fine('Google Sign-In authenticated client created');
        final context = this.context;
        if (context.mounted) {
          context.read<AuthedUserPlaylists>().authClient =
              googleSignInClientAuthorization.authClient(scopes: widget.scopes);
          context.go('/');
        }
      }
    });

    // Check if user is already authenticated
    _log.fine('Attempting lightweight authentication');
    _googleSignIn.attemptLightweightAuthentication();
  }

  @override
  dispose() {
    _authEventsSubscription.cancel();
    super.dispose();
  }

  late final GoogleSignIn _googleSignIn;
  late final StreamSubscription _authEventsSubscription;

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

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

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

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

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

  Uri? _authUrl;

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

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

هذا الملف يضم الكثير من البيانات. تتولّى الطريقة build في AdaptiveLogin المهام الصعبة. تتطلّب هذه الطريقة استدعاء Platform.isXXX لكلّ من kIsWeb وdart:io، وتتحقّق من منصة وقت التشغيل. بالنسبة إلى Android وiOS والويب، يتم إنشاء _GoogleSignInLogin stateful widget. بالنسبة إلى أنظمة التشغيل Windows وmacOS وLinux، يتم إنشاء _GoogleApisAuthLogin أداة ذات حالة.

يجب إجراء إعدادات إضافية لاستخدام هذه الفئات، وسيتم ذلك لاحقًا بعد تعديل بقية قاعدة الرموز البرمجية لاستخدام هذا التطبيق المصغّر الجديد. ابدأ بإعادة تسمية FlutterDevPlaylists إلى AuthedUserPlaylists لتعكس بشكل أفضل الغرض الجديد منها، وعدِّل الرمز ليعكس أنّه يتم الآن تمرير http.Client بعد الإنشاء. أخيرًا، لم تعُد الفئة _ApiKeyClient مطلوبة:

lib/src/app_state.dart

import 'dart:collection';

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

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

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

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

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

  YouTubeApi? _api;                                     // Convert to optional

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

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

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

// Delete the now unused _ApiKeyClient class

بعد ذلك، عدِّل أداة PlaylistDetails باستخدام الاسم الجديد لكائن حالة التطبيق المقدَّم:

lib/src/playlist_details.dart

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

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

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

وبالمثل، عدِّل أداة Playlists باتّباع الخطوات التالية:

lib/src/playlists.dart

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

  final PlaylistsListSelected playlistSelected;

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

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

أخيرًا، عدِّل ملف main.dart لاستخدام أداة AdaptiveLogin الجديدة بشكل صحيح:

lib/main.dart

// Drop dart:io import

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

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

// Drop flutterDevAccountId and youTubeApiKey

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

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

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

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

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

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

تعكس التغييرات في هذا الملف التغيير من عرض قوائم تشغيل YouTube الخاصة بـ Flutter فقط إلى عرض قوائم تشغيل المستخدم الذي تمّت مصادقته. على الرغم من أنّ الرمز البرمجي قد اكتمل الآن، لا يزال هناك سلسلة من التعديلات المطلوبة على هذا الملف والملفات ضِمن تطبيقات Runner المعنية لإعداد حزمتَي google_sign_in وgoogleapis_auth بشكل صحيح للمصادقة.

يعرض التطبيق الآن قوائم تشغيل YouTube الخاصة بالمستخدم الذي تمّت المصادقة عليه. بعد إكمال الميزات، عليك تفعيل المصادقة. لإجراء ذلك، اضبط حزمتَي google_sign_in وgoogleapis_auth. لضبط الحِزم، عليك تغيير الملف main.dart وملفات تطبيقات Runner.

ضبط googleapis_auth

تتمثّل الخطوة الأولى في إعداد المصادقة في إزالة مفتاح واجهة برمجة التطبيقات الذي سبق لك إعداده واستخدامه. انتقِل إلى صفحة بيانات الاعتماد الخاصة بمشروع واجهة برمجة التطبيقات، واحذف مفتاح واجهة برمجة التطبيقات:

صفحة بيانات الاعتماد الخاصة بمشروع واجهة برمجة التطبيقات في وحدة تحكّم Google Cloud Platform

سيؤدي ذلك إلى إنشاء مربّع حوار تقرّ فيه بالحذف من خلال النقر على الزر "حذف":

النافذة المنبثقة &quot;حذف بيانات الاعتماد&quot;

بعد ذلك، أنشئ معرّف عميل OAuth:

إنشاء معرّف عميل OAuth

بالنسبة إلى "نوع التطبيق"، اختَر "تطبيق على الكمبيوتر".

اختيار نوع تطبيق &quot;تطبيق سطح المكتب&quot;

اقبل الاسم وانقر على إنشاء.

تسمية معرّف العميل

يؤدي ذلك إلى إنشاء معرّف العميل وسرّ العميل اللذين يجب إضافتهما إلى lib/main.dart لإعداد مسار googleapis_auth. من التفاصيل المهمة في عملية التنفيذ أنّ مسار googleapis_auth يستخدم خادم ويب مؤقتًا يعمل على المضيف المحلي لالتقاط رمز OAuth المميز الذي تم إنشاؤه، وهو ما يتطلّب تعديل الملف macos/Runner/Release.entitlements على نظام التشغيل macOS:

macos/Runner/Release.entitlements

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

لست بحاجة إلى إجراء هذا التعديل على ملف macos/Runner/DebugProfile.entitlements لأنّه يتضمّن بالفعل إذنًا لـ com.apple.security.network.server من أجل تفعيل ميزة "إعادة التحميل السريع" وأدوات تصحيح الأخطاء في "الجهاز الظاهري لـ Dart".

من المفترض أن تتمكّن الآن من تشغيل تطبيقك على Windows أو macOS أو Linux (إذا تم تجميع التطبيق على هذه الأنظمة).

التطبيق الذي يعرض قوائم التشغيل للمستخدم الذي سجّل الدخول

ضبط google_sign_in على Android

ارجع إلى صفحة بيانات الاعتماد الخاصة بمشروع واجهة برمجة التطبيقات، وأنشئ معرّف عميل OAuth آخر، ولكن اختَر Android: هذه المرة.

اختيار نوع تطبيق Android

بالنسبة إلى بقية النموذج، املأ حقل "اسم الحزمة" بالحزمة المحدّدة في android/app/src/main/AndroidManifest.xml. إذا اتّبعت التعليمات بدقة، يجب أن تكون النتيجة com.example.adaptive_app. استخرِج الملف المرجعي لشهادة SHA-1 باتّباع التعليمات الواردة في صفحة مساعدة Google Cloud Console:

تسمية معرّف العميل على Android

وهذا يكفي لتشغيل التطبيق على Android. بناءً على اختيارك لواجهات Google APIs التي تستخدمها، قد تحتاج إلى إضافة ملف JSON الذي تم إنشاؤه إلى حِزمة تطبيقك.

تشغيل التطبيق على Android

ضبط google_sign_in على أجهزة iOS

ارجع إلى صفحة بيانات الاعتماد الخاصة بمشروع واجهة برمجة التطبيقات، وأنشئ معرّف عميل OAuth آخر، ولكن اختَر iOS هذه المرة.

اختيار نوع تطبيق iOS

بالنسبة إلى بقية النموذج، املأ رقم تعريف الحزمة من خلال فتح ios/Runner.xcworkspace في Xcode. انتقِل إلى "مستكشف المشروع" (Project Navigator)، واختَر Runner في المستكشف، ثم انقر على علامة التبويب "عام" (General)، وانسخ معرّف الحزمة (Bundle Identifier). إذا اتّبعت خطوات هذا الدرس التعليمي البرمجي بالتفصيل، من المفترض أن تكون القيمة com.example.adaptiveApp.

بالنسبة إلى بقية النموذج، املأ حقل "معرّف الحزمة". افتح ios/Runner.xcworkspace في Xcode. انتقِل إلى "مستكشف المشروع". انتقِل إلى Runner > علامة التبويب "عام". انسخ معرّف الحزمة. إذا اتّبعت هذا الدرس التطبيقي خطوة بخطوة، من المفترض أن تكون القيمة com.example.adaptiveApp.

مكان العثور على معرّف الحزمة في Xcode

تجاهَل رقم تعريف App Store ورقم تعريف الفريق في الوقت الحالي، لأنّهما غير مطلوبَين للتطوير المحلي:

تسمية معرّف عميل iOS

نزِّل ملف .plist الذي تم إنشاؤه، ويستند اسمه إلى معرّف العميل الذي تم إنشاؤه. أعِد تسمية الملف الذي تم تنزيله إلى GoogleService-Info.plist، ثم اسحبه إلى محرّر Xcode الذي تستخدمه، بجانب الملف Info.plist ضمن Runner/Runner في أداة التنقّل اليمنى. في مربّع الحوار "الخيارات" في Xcode، اختَر نسخ العناصر إذا لزم الأمر، ثم إنشاء مراجع للمجلدات، ثم الإضافة إلى هدف Runner.

إضافة ملف plist الذي تم إنشاؤه إلى تطبيق iOS في Xcode

اخرج من Xcode، ثم أضِف ما يلي إلى Info.plist في بيئة التطوير المتكاملة التي تختارها:

ios/Runner/Info.plist

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

عليك تعديل القيمة لتتطابق مع الإدخال في ملف GoogleService-Info.plist الذي تم إنشاؤه. شغِّل تطبيقك، وبعد تسجيل الدخول، من المفترض أن تظهر لك قوائم التشغيل.

التطبيق الذي يتم تشغيله على iOS

ضبط google_sign_in للويب

ارجع إلى صفحة بيانات الاعتماد الخاصة بمشروع واجهة برمجة التطبيقات، وأنشئ معرّف عميل OAuth آخر، ولكن اختَر تطبيق الويب هذه المرة:

اختيار نوع تطبيق الويب

بالنسبة إلى بقية النموذج، املأ حقل "مصادر JavaScript المسموح بها" على النحو التالي:

تسمية معرّف العميل الخاص بتطبيق الويب

سيؤدي ذلك إلى إنشاء معرّف عميل. أضِف علامة meta التالية إلى web/index.html، مع تعديلها لتضمين معرّف العميل الذي تم إنشاؤه:

web/index.html

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

يتطلّب تشغيل هذا النموذج بعض المساعدة. عليك تشغيل خادم وكيل CORS الذي أنشأته في الخطوة السابقة، وتشغيل تطبيق الويب Flutter على المنفذ المحدّد في نموذج معرّف عميل OAuth لتطبيق الويب باستخدام التعليمات التالية.

في إحدى الوحدات الطرفية، شغِّل خادم CORS Proxy على النحو التالي:

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

في وحدة طرفية أخرى، شغِّل تطبيق Flutter على النحو التالي:

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

💪 Running with sound null safety 💪

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

بعد تسجيل الدخول مرة أخرى، من المفترض أن تظهر لك قوائم التشغيل:

التطبيق الذي يتم تشغيله في متصفّح Chrome

‫8. الخطوات التالية

تهانينا!

لقد أكملت تجربة البرمجة وأنشأت تطبيق Flutter متكيّفًا يعمل على جميع الأنظمة الأساسية الستة التي يتيحها Flutter. لقد عدّلت الرمز البرمجي للتعامل مع الاختلافات في طريقة عرض الشاشات، وطريقة التفاعل مع النصوص، وطريقة تحميل الصور، وطريقة عمل المصادقة.

هناك العديد من العناصر الأخرى التي يمكنك تعديلها في تطبيقاتك. للتعرّف على طرق إضافية لتكييف الرمز البرمجي مع البيئات المختلفة التي سيتم تشغيله فيها، اطّلِع على إنشاء تطبيقات متكيّفة.