لمحة عن هذا الدرس التطبيقي حول الترميز
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:
من المفترض أن تشبه لقطة الشاشة التالية هذا التطبيق الذي يعمل على شاشة عريضة في نظام التشغيل 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:
في ما يلي الرمز البرمجي نفسه الذي يعمل بشكل أصلي على نظام التشغيل macOS وداخل Chrome، والذي يعمل مرة أخرى على نظام التشغيل macOS.
تجدر الإشارة إلى أنّ 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 لإنشاء مشروع واجهة برمجة تطبيقات:
بعد إنشاء مشروع، انتقِل إلى صفحة "مكتبة واجهات برمجة التطبيقات". في مربّع البحث، أدخِل youtube، ثم اختَر youtube data api v3.
في صفحة تفاصيل الإصدار 3 من YouTube Data API، فعِّل واجهة برمجة التطبيقات.
بعد تفعيل واجهة برمجة التطبيقات، انتقِل إلى صفحة "بيانات الاعتماد" وأنشئ مفتاح واجهة برمجة التطبيقات.
بعد بضع ثوانٍ، من المفترض أن يظهر لك مربّع حوار يتضمّن مفتاح واجهة برمجة التطبيقات الجديد. ستستخدم هذا المفتاح بعد قليل.
إضافة رمز
خلال بقية هذه الخطوة، ستقتطع الكثير من الرموز وتلصقها لإنشاء تطبيق متوافق مع الأجهزة الجوّالة، بدون أي تعليق على الرمز. الغرض من هذا الدليل التعليمي حول رموز البرامج هو استخدام التطبيق المتوافق مع الأجهزة الجوّالة وتعديله ليناسب أجهزة الكمبيوتر المكتبي والويب. للحصول على مقدمة أكثر تفصيلاً حول إنشاء تطبيقات 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 لمشاهدة الفيديو.
في حال محاولة تشغيل هذا التطبيق على الكمبيوتر المكتبي، سيبدو لك التنسيق غير صحيح عند توسيعه إلى نافذة عادية بحجم الكمبيوتر المكتبي. ستبحث عن طرق للتكيّف مع ذلك في الخطوة التالية.
5. التكيّف مع أجهزة الكمبيوتر المكتبي
مشكلة سطح المكتب
إذا شغّلت التطبيق على أحد أنظمة التشغيل المتوافقة مع أجهزة الكمبيوتر المكتبي، مثل Windows أو macOS أو Linux، ستلاحظ مشكلة مثيرة للاهتمام. يعمل التطبيق، ولكن يبدو أنّه ... غريب.
لحلّ هذه المشكلة، يمكنك إضافة عرض مُقسَّم يعرض قوائم التشغيل على يمين الشاشة والفيديوهات على يسارها. ومع ذلك، لا تريد تفعيل هذا التنسيق إلا عندما لا يتم تشغيل الرمز على 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 من المفترض أن يعمل الآن على النحو المتوقّع.
6. التكيّف مع الويب
ما هي المشكلة في هذه الصور؟
تشير محاولة تشغيل هذا التطبيق على الويب الآن إلى أنّه يجب إجراء المزيد من العمل للتكيّف مع متصفّحات الويب.
إذا اطّلعت على وحدة تحكّم تصحيح الأخطاء، سترى تلميحًا بسيطًا حول الخطوة التالية التي يجب اتّخاذها.
══╡ 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، من المفترض أن تتمكّن من تشغيل إصدار الويب من التطبيق وأن يظهر بالشكل التالي:
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
الخطوة الأولى لضبط المصادقة هي إزالة مفتاح واجهة برمجة التطبيقات الذي سبق لك ضبطه واستخدامه. انتقِل إلى صفحة بيانات اعتماد مشروع واجهة برمجة التطبيقات، واحذِف مفتاح واجهة برمجة التطبيقات:
يؤدي ذلك إلى إنشاء مربّع حوار تُقرّ به من خلال النقر على الزر "حذف":
بعد ذلك، أنشئ معرِّف عميل OAuth:
بالنسبة إلى نوع التطبيق، اختَر "تطبيق كمبيوتر مكتبي".
اقبل الاسم وانقر على إنشاء.
يؤدي ذلك إلى إنشاء معرِّف العميل وسرّ العميل اللذَين يجب إضافتهما إلى 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/app/src/main/AndroidManifest.xml
. إذا اتّبعت التعليمات بدقة، من المفترض أن يكون com.example.adaptive_app
. استخرِج الملف المرجعي لشهادة SHA-1 باستخدام التعليمات الواردة في صفحة مساعدة Google Cloud Console:
وهذا كافٍ لتشغيل التطبيق على Android. استنادًا إلى اختيار واجهات برمجة تطبيقات Google التي تستخدمها، قد تحتاج إلى إضافة ملف JSON الذي تم إنشاؤه إلى حِزمة تطبيقك.
ضبط google_sign_in
لأجهزة iOS
ارجع إلى صفحة بيانات اعتماد مشروع واجهة برمجة التطبيقات، وأنشئ معرّف عميل OAuth آخر، ولكن اختَر هذه المرة iOS:
بالنسبة إلى بقية النموذج، املأ رقم تعريف الحزمة من خلال فتح ios/Runner.xcworkspace
في Xcode. انتقِل إلى "مستكشف المشاريع"، واختَر "المشغِّل" في المستكشف، ثم انقر على علامة التبويب "عام" وانسخ معرّف الحِزمة. إذا اتبعت هذا الدليل التعليمي خطوة بخطوة، من المفترض أن يكون com.example.adaptiveApp
.
بالنسبة إلى بقية النموذج، املأ معرّف الحزمة. افتح ios/Runner.xcworkspace
في Xcode. انتقِل إلى "مستكشف المشاريع". انتقِل إلى "عداء" > علامة التبويب "عام". انسخ معرّف الحزمة. إذا اتبعت هذا الدليل التعليمي المبرمَج خطوة بخطوة، من المفترض أن تكون قيمته com.example.adaptiveApp
.
تجاهل رقم تعريف متجر التطبيقات ورقم تعريف الفريق في الوقت الحالي، لأنّهما غير مطلوبَين لتطوير التطبيقات على الجهاز فقط:
نزِّل ملف .plist
الذي تم إنشاؤه، ويستند اسمه إلى معرّف العميل الذي تم إنشاؤه. أعِد تسمية الملف الذي تم تنزيله إلى GoogleService-Info.plist
، ثم اسحبه إلى محرِّر Xcode الذي يعمل بجانب ملف Info.plist
ضمن Runner/Runner
في شريط التنقّل الأيمن. في مربّع حوار الخيارات في Xcode، اختَر نسخ العناصر إذا لزم الأمر، وإنشاء إحالات إلى المجلدات، والإضافة إلى مستهدَف Runner.
اخرج من 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
الذي تم إنشاؤه. شغِّل تطبيقك، وبعد تسجيل الدخول، من المفترض أن تظهر لك قوائم التشغيل.
ضبط 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".
بعد تسجيل الدخول مرة أخرى، من المفترض أن تظهر لك قوائم التشغيل:
8. الخطوات التالية
تهانينا!
لقد أكملت دورة codelab وأنشأت تطبيق Flutter متوافقًا مع جميع المنصات الستة التي يتوافق معها Flutter. لقد عدّلت الرمز البرمجي للتعامل مع الاختلافات في طريقة عرض الشاشات وكيفية التفاعل مع النص وتحميل الصور وطريقة المصادقة.
هناك العديد من الإجراءات الأخرى التي يمكنك تكييفها في تطبيقاتك. للتعرّف على طرق إضافية لتكييف الرمز البرمجي مع البيئات المختلفة التي سيتم تشغيله فيها، اطّلِع على مقالة إنشاء تطبيقات قابلة للتكيّف.