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

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

لمحة عن هذا الدرس التطبيقي حول الترميز

subjectتاريخ التعديل الأخير: يونيو 3, 2025
account_circleتأليف: Brett Morgan

1. مقدمة

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

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

ما ستُنشئه

في هذا الدرس التطبيقي حول الترميز، ستنشئ في البداية تطبيق Flutter لنظامَي التشغيل Android وiOS يستكشف قوائم التشغيل على YouTube باستخدام Flutter. بعد ذلك، عليك تكييف هذا التطبيق للعمل على الأنظمة الأساسية الثلاثة لأجهزة الكمبيوتر المكتبي (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 الأساسي كتطبيق متوافق مع الأجهزة الجوّالة كما هو موضّح أدناه. بدلاً من ذلك، يمكنك فتح هذا المشروع في بيئة تطوير البرامج المتكاملة واستخدام أدواتها لتشغيل التطبيق. بفضل الخطوة السابقة، من المفترض أن يكون الخيار الوحيد المتاح هو التشغيل كتطبيق كمبيوتر مكتبي.

$ 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 في وحدة التحكّم لإعادة التحميل السريع.
  • في حال تشغيل التطبيق باستخدام بيئة تطوير متكاملة، تتم إعادة تحميل التطبيق عند حفظ الملف.

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 Design أو شريط التمرير في 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
+ flex_color_scheme 8.2.0
+ flex_seed_scheme 3.5.1
+ flutter_web_plugins 0.0.0 from sdk flutter
+ go_router 15.1.2
+ googleapis 14.0.0
+ http 1.4.0
+ http_parser 4.1.2
  leak_tracker 10.0.9 (11.0.1 available)
  leak_tracker_flutter_testing 3.0.9 (3.0.10 available)
  leak_tracker_testing 3.0.1 (3.0.2 available)
  lints 5.1.1 (6.0.0 available)
+ logging 1.3.0
  material_color_utilities 0.11.1 (0.12.0 available)
  meta 1.16.0 (1.17.0 available)
+ nested 1.0.0
+ plugin_platform_interface 2.1.8
+ provider 6.1.5
  test_api 0.7.4 (0.7.6 available)
+ typed_data 1.4.0
+ url_launcher 6.3.1
+ url_launcher_android 6.3.16
+ url_launcher_ios 6.3.3
+ url_launcher_linux 3.2.1
+ url_launcher_macos 3.2.2
+ url_launcher_platform_interface 2.3.2
+ url_launcher_web 2.4.1
+ url_launcher_windows 3.1.4
  vector_math 2.1.4 (2.1.5 available)
+ web 1.1.1
Changed 22 dependencies!
8 packages have newer versions incompatible with dependency constraints.
Try `flutter pub outdated` for more information.

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

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

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

يتطلب المكوّن الإضافي url_launcher ضبط تطبيقات أداة التشغيل لنظامَي التشغيل Android وiOS. في أداة تشغيل Flutter لنظام التشغيل iOS، أضِف الأسطر التالية إلى قاموس plist.

ios/Runner/Info.plist

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

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

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

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

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

اختيار الإصدار 3 من YouTube Data API في وحدة تحكّم Google Cloud Platform

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

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...
  leak_tracker 10.0.9 (11.0.1 available)
  leak_tracker_flutter_testing 3.0.9 (3.0.10 available)
  leak_tracker_testing 3.0.1 (3.0.2 available)
  lints 5.1.1 (6.0.0 available)
  material_color_utilities 0.11.1 (0.12.0 available)
  meta 1.16.0 (1.17.0 available)
+ split_view 3.2.1
  test_api 0.7.4 (0.7.6 available)
  vector_math 2.1.4 (2.1.5 available)
Changed 1 dependency!
8 packages have newer versions incompatible with dependency constraints.
Try `flutter pub outdated` for more information.

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

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

lib/main.dart

import 'dart:io';

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

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

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

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

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

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

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

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

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

بعد ذلك، أنشئ ملفًا لأداة AdaptivePlaylist المصغّرة:

lib/src/adaptive_playlists.dart

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

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

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

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

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

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

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

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

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

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

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

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

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

lib/src/playlists.dart

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

import 'app_state.dart';

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

 
final PlaylistsListSelected playlistSelected;

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

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

typedef PlaylistsListSelected = void Function(Playlist playlist);

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

 
final List<Playlist> items;
 
final PlaylistsListSelected playlistSelected;

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

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

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

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

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

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

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

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

lib/src/playlist_details.dart

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

import 'app_state.dart';

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

التطبيق الذي يعمل على نظام التشغيل macOS مع عرض مُقسَّم

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...
  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 المصغّرَين كما هما. وقد تم إجراء ذلك عن قصد، لأنّه في حال تعديل التطبيقات المصغّرة "النص"، يتم حظر وظيفة 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
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. تعمل الحزمة الثانية كوسيط تفاعل بين الحزمتَين.

تعديل الرمز

ابدأ عملية التعديل من خلال إنشاء عنصر واجهة مستخدِم جديد قابل لإعادة الاستخدام، وهو التطبيق المصغّر 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) {
         
final context = this.context;
         
if (authClient != null && context.mounted) {
           
context.read<AuthedUserPlaylists>().authClient = authClient;
           
context.go('/');
         
}
       
});
     
}
   
});
 
}

 
late final GoogleSignIn _googleSignIn;

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

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

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

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

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

 
Uri? _authUrl;

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

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

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

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

lib/src/app_state.dart

import 'dart:collection';

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

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

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

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

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

 
YouTubeApi? _api;                                     // Convert to optional

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

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

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

// Delete the now unused _ApiKeyClient class

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

lib/src/playlist_details.dart

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

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

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

وبالمثل، يمكنك تعديل التطبيق المصغّر Playlists باتّباع الخطوات التالية:

lib/src/playlists.dart

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

  final PlaylistsListSelected playlistSelected;

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

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

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

lib/main.dart

// Drop dart:io import

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

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

// Drop flutterDevAccountId and youTubeApiKey

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

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

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

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

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

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

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

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

ضبط googleapis_auth

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

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

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

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

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

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

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

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

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

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

يؤدي ذلك إلى إنشاء معرِّف العميل وسرّ العميل اللذَين يجب إضافتهما إلى 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 لتفعيل ميزة "إعادة التحميل السريع" وأدوات تصحيح أخطاء Dart VM.

من المفترض أن تتمكّن الآن من تشغيل تطبيقك على نظام التشغيل 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 التي تستخدمها، قد تحتاج إلى إضافة ملف JSON الذي تم إنشاؤه إلى حِزمة تطبيقك.

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

ضبط google_sign_in لأجهزة iOS

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

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

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

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

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

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

تسمية معرّف العميل على نظام التشغيل iOS

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

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

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

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. الخطوات التالية

تهانينا!

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

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