1. مقدمة
Flutter هو مجموعة أدوات واجهة المستخدم من Google لإنشاء تطبيقات رائعة ومُجمَّعة إلى رموز أصلية للأجهزة الجوّالة والويب وأجهزة الكمبيوتر المكتبي، وذلك من خلال قاعدة رموز برمجية واحدة. في هذا الدرس التطبيقي حول الترميز، ستتعلّم كيفية إنشاء تطبيق Flutter يتكيّف مع النظام الأساسي الذي يعمل عليه، سواء كان نظام التشغيل Android أو iOS أو الويب أو Windows أو macOS أو Linux.
ما ستتعرَّف عليه
- طريقة تطوير تطبيق Flutter المصمَّمة للأجهزة الجوّالة ليعمل على جميع الأنظمة الأساسية الست المتوافقة مع Flutter
- واجهات برمجة تطبيقات Flutter API المختلفة لرصد الأنظمة الأساسية وحالات استخدام كل واجهة برمجة تطبيقات
- التكيّف مع القيود والتوقعات عند تشغيل تطبيق على الويب
- كيفية استخدام حزم مختلفة جنبًا إلى جنب لدعم المجموعة الكاملة من منصات Flutter
ما الذي ستقوم ببنائه
في هذا الدرس التطبيقي حول الترميز، ستعمل مبدئيًا على إنشاء تطبيق Flutter لأجهزة Android وiOS لاستكشاف قوائم التشغيل في تطبيق Flutter على YouTube. ستقوم بعد ذلك بتكييف هذا التطبيق للعمل على الأنظمة الأساسية الثلاثة لسطح المكتب (Windows وmacOS وLinux) عن طريق تعديل كيفية عرض المعلومات بالنظر إلى حجم نافذة التطبيق. ثم ستقوم بتكييف التطبيق مع الويب من خلال جعل النص المعروض في التطبيق قابلاً للاختيار، كما يتوقع مستخدمو الويب. وأخيرًا، يجب إضافة المصادقة إلى التطبيق حتى تتمكّن من استكشاف قوائم التشغيل الخاصة بك، بدلاً من القوائم التي أنشأها فريق Flutter، والتي تتطلّب أساليب مختلفة للمصادقة على أجهزة Android وiOS والويب، مقارنةً بأنظمة التشغيل الثلاثة المتوافقة مع أجهزة الكمبيوتر المكتبي والتي تعمل بأنظمة التشغيل Windows وmacOS وLinux.
في ما يلي لقطة شاشة لتطبيق Flutter على أجهزة Android وiOS:
من المفترض أن يكون هذا التطبيق الذي يعمل على الشاشة العريضة على نظام التشغيل macOS مشابهًا للقطة الشاشة التالية.
يركّز هذا الدرس التطبيقي حول الترميز على تحويل تطبيق Flutter للأجهزة الجوّالة إلى تطبيق تكيُّفي يعمل على جميع منصات Flutter الست. يتم تمويه المفاهيم غير ذات الصلة وكتل الرموز، ويتم توفيرها لك لنسخها ولصقها ببساطة.
ما الذي تريد تعلّمه من هذا الدرس التطبيقي حول الترميز؟
2. إعداد بيئة تطوير Flutter
لإكمال هذا التمرين، تحتاج إلى برنامجَين، وهما Flutter SDK ومحرِّر.
يمكنك تشغيل الدرس التطبيقي حول الترميز باستخدام أي من الأجهزة التالية:
- جهاز 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. يمكن أيضًا أن يوفّر بيئة التطوير المتكاملة (IDE) سير عمل لإنشاء مشروع 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 كتطبيق للأجهزة الجوّالة كما هو موضّح أدناه. يمكنك بدلاً من ذلك فتح هذا المشروع في بيئة التطوير المتكاملة (IDE) واستخدام أدواته لتشغيل التطبيق. بفضل الخطوة السابقة، من المفترض أن يكون التشغيل كتطبيق سطح المكتب هو الخيار الوحيد المتاح.
$ flutter run Launching lib/main.dart on iPhone 15 in debug mode... Running Xcode build... └─Compiling, linking and signing... 6.5s Xcode build done. 24.6s Syncing files to device iPhone 15... 46ms Flutter run key commands. r Hot reload. 🔥🔥🔥 R Hot restart. h List all available interactive commands. d Detach (terminate "flutter run" but leave application running). c Clear the screen q Quit (terminate the application on the device). A Dart VM Service on iPhone 15 is available at: http://127.0.0.1:50501/JHGBwC_hFJo=/ The Flutter DevTools debugger and profiler on iPhone 15 is available at: http://127.0.0.1:9102?uri=http://127.0.0.1:50501/JHGBwC_hFJo=/
من المفترض أن يظهر لك الآن التطبيق قيد التشغيل. يجب تعديل المحتوى.
لتعديل المحتوى، عليك تعديل الرمز في lib/main.dart
باستخدام الرمز التالي. لتغيير ما يعرضه تطبيقك، عليك إعادة تحميل الصفحة مباشرةً.
- إذا تم تشغيل التطبيق باستخدام سطر الأوامر، اكتب
r
في وحدة التحكّم لإعادة التحميل السريع. - في حال تشغيل التطبيق باستخدام بيئة تطوير متكاملة (IDE)، تتم إعادة تحميل التطبيق عند حفظ الملف.
lib/main.dart
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
),
home: const ResizeablePage(),
);
}
}
class ResizeablePage extends StatelessWidget {
const ResizeablePage({super.key});
@override
Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
final themePlatform = Theme.of(context).platform;
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'Window properties',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
SizedBox(
width: 350,
child: Table(
textBaseline: TextBaseline.alphabetic,
children: <TableRow>[
_fillTableRow(
context: context,
property: 'Window Size',
value: '${mediaQuery.size.width.toStringAsFixed(1)} x '
'${mediaQuery.size.height.toStringAsFixed(1)}',
),
_fillTableRow(
context: context,
property: 'Device Pixel Ratio',
value: mediaQuery.devicePixelRatio.toStringAsFixed(2),
),
_fillTableRow(
context: context,
property: 'Platform.isXXX',
value: platformDescription(),
),
_fillTableRow(
context: context,
property: 'Theme.of(ctx).platform',
value: themePlatform.toString(),
),
],
),
),
],
),
),
);
}
TableRow _fillTableRow(
{required BuildContext context,
required String property,
required String value}) {
return TableRow(
children: [
TableCell(
verticalAlignment: TableCellVerticalAlignment.baseline,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(property),
),
),
TableCell(
verticalAlignment: TableCellVerticalAlignment.baseline,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(value),
),
),
],
);
}
String platformDescription() {
if (kIsWeb) {
return 'Web';
} else if (Platform.isAndroid) {
return 'Android';
} else if (Platform.isIOS) {
return 'iOS';
} else if (Platform.isWindows) {
return 'Windows';
} else if (Platform.isMacOS) {
return 'macOS';
} else if (Platform.isLinux) {
return 'Linux';
} else if (Platform.isFuchsia) {
return 'Fuchsia';
} else {
return 'Unknown';
}
}
}
تم تصميم التطبيق أعلاه لمنحك فكرة عن كيفية اكتشاف الأنظمة الأساسية المختلفة والتكيّف معها. إليك التطبيق الذي يتم تشغيله في الأصل على نظامَي التشغيل Android وiOS:
إليك الرمز نفسه الذي يتم تشغيله في الأصل على نظام التشغيل macOS وداخل Chrome، ويتم تشغيله مرة أخرى على نظام التشغيل macOS.
للوهلة الأولى، لا شك في أنّ Flutter يبذل قصارى جهده لتكييف المحتوى مع الشاشة المعروضة عليه. يحتوي الكمبيوتر المحمول الذي تم أخذ لقطات الشاشة هذه عليه على شاشة Mac عالية الدقة، ولهذا السبب يتم عرض كل من إصداري macOS والويب من التطبيق على نسبة Pixel 2 للجهاز. حتى ذلك الحين، ترون على هاتف iPhone 12 نسبة 3 و2.63 على هاتف Pixel 2. في جميع الحالات، يكون النص المعروض مماثلاً تقريبًا، مما يجعل مهمتنا أسهل كثيرًا للمطورين.
النقطة الثانية التي يجب ملاحظتها هي أن خيارين للتحقق من المنصة التي تعمل التعليمة البرمجية علىهما يؤديان إلى قيم مختلفة. يفحص الخيار الأول الكائن Platform
الذي تم استيراده من dart:io
، بينما يفحص الخيار الثاني (متاح فقط داخل طريقة build
الخاصة بالأداة) الكائن Theme
من الوسيطة BuildContext
.
سبب عرض هاتين الطريقتين نتائج مختلفة هو أن الغرض منهما مختلف. إنّ العنصر Platform
المستورَد من dart:io
يُستخدم لاتخاذ القرارات المستقلة عن خيارات العرض. وخير مثال على ذلك هو تحديد المكوّنات الإضافية المراد استخدامها، والتي قد تتضمن أو لا تتضمّن عمليات تنفيذ أصلية متطابقة لنظام أساسي مادي محدد.
إنّ استخراج Theme
من BuildContext
مخصّص لاتخاذ قرارات التنفيذ التي تركّز على الموضوع. وأبرز مثال على ذلك هو تحديد ما إذا كان يجب استخدام شريط تمرير "المادة" أو شريط تمرير 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.6 + flex_color_scheme 7.3.1 + flex_seed_scheme 1.5.0 + flutter_web_plugins 0.0.0 from sdk flutter + go_router 14.0.1 + googleapis 13.1.0 + http 1.2.1 + http_parser 4.0.2 leak_tracker 10.0.4 (10.0.5 available) leak_tracker_flutter_testing 3.0.3 (3.0.5 available) + logging 1.2.0 material_color_utilities 0.8.0 (0.11.1 available) meta 1.12.0 (1.14.0 available) + nested 1.0.0 + plugin_platform_interface 2.1.8 + provider 6.1.2 test_api 0.7.0 (0.7.1 available) + typed_data 1.3.2 + url_launcher 6.2.6 + url_launcher_android 6.3.1 + url_launcher_ios 6.2.5 + url_launcher_linux 3.1.1 + url_launcher_macos 3.1.0 + url_launcher_platform_interface 2.3.2 + url_launcher_web 2.3.1 + url_launcher_windows 3.1.1 + web 0.5.1 Changed 22 dependencies! 5 packages have newer versions incompatible with dependency constraints. Try `flutter pub outdated` for more information.
يضيف هذا الأمر عددًا من الحزم إلى التطبيق:
googleapis
: مكتبة Dart تم إنشاؤها وتوفّر إمكانية الوصول إلى Google APIshttp
: مكتبة لإنشاء طلبات 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. في مشغّل iOS Flutter، أضِف الأسطر التالية إلى قاموس plist
.
ios/Runner/Info.plist
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
<string>http</string>
<string>tel</string>
<string>mailto</string>
</array>
في برنامج التشغيل Android Flutter، أضِف الأسطر التالية إلى Manifest.xml
. أضِف عقدة queries
هذه كعنصر ثانوي مباشر للعقدة manifest
ونظير للعقدة application
.
android/app/src/main/AndroidManifest.xml
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
<intent>
<action android:name="android.intent.action.DIAL" />
<data android:scheme="tel" />
</intent>
<intent>
<action android:name="android.intent.action.SEND" />
<data android:mimeType="*/*" />
</intent>
</queries>
لمزيد من التفاصيل حول تغييرات الضبط المطلوبة هذه، يُرجى الاطّلاع على مستندات url_launcher
.
الوصول إلى YouTube Data API
للوصول إلى YouTube Data API لإدراج قوائم التشغيل، عليك إنشاء مشروع واجهة برمجة تطبيقات لإنشاء مفاتيح واجهة برمجة التطبيقات المطلوبة. تفترض هذه الخطوات أن لديك حساب Google، لذا يجب إنشاء حساب إذا لم يكن لديك حساب حاليًا.
انتقِل إلى Developer Console من أجل إنشاء مشروع واجهة برمجة تطبيقات:
بعد إنشاء مشروع، انتقِل إلى صفحة "مكتبة واجهة برمجة التطبيقات". في مربّع البحث، أدخِل "youtube"، ثم اختَر الإصدار 3 من واجهة برمجة تطبيقات بيانات youtube.
في صفحة تفاصيل الإصدار الثالث من YouTube Data API، فعِّل واجهة برمجة التطبيقات.
بعد تفعيل واجهة برمجة التطبيقات، انتقِل إلى صفحة بيانات الاعتماد وأنشِئ مفتاح واجهة برمجة التطبيقات.
بعد بضع ثوانٍ، من المفترض أن يظهر لك مربع حوار يحتوي على مفتاح واجهة برمجة تطبيقات جديد ولامع. ستستخدم هذا المفتاح قريبًا.
إضافة رمز
في الجزء المتبقي من هذه الخطوة، عليك قص الكثير من التعليمات البرمجية لإنشاء تطبيق للأجهزة الجوّالة، بدون أي تعليق على الرمز. يكمن الهدف من هذا الدرس التطبيقي في إعداد تطبيق للأجهزة الجوّالة ليناسب كلاً من أجهزة الكمبيوتر المكتبي والويب. للاطّلاع على مقدمة أكثر تفصيلاً حول إنشاء تطبيقات Flutter للأجهزة الجوّالة، يُرجى الاطّلاع على مقالة كتابة أول تطبيق Flutter، الجزء 1 والجزء 2، وإنشاء واجهات مستخدم رائعة باستخدام 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,
useMaterial3: true,
).toTheme,
darkTheme: FlexColorScheme.dark(
scheme: FlexScheme.red,
useMaterial3: true,
).toTheme,
themeMode: ThemeMode.dark, // Or ThemeMode.System if you'd prefer
debugShowCheckedModeBanner: false,
routerConfig: _router,
);
}
}
أصبحت جاهزًا تقريبًا لتشغيل هذا الرمز على Android وiOS. هناك شيء آخر مطلوب تغييره، وهو تعديل ثابت youTubeApiKey
في السطر 14 باستخدام مفتاح واجهة برمجة تطبيقات YouTube الذي تم إنشاؤه في الخطوة السابقة.
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... + split_view 3.1.0 test_api 0.4.3 (0.4.8 available) Changed 1 dependency!
إضافة التطبيقات المصغّرة التكيُّفية
النمط الذي ستستخدمه في هذا الدرس التطبيقي حول الترميز هو تقديم التطبيقات المصغّرة التكيُّفية التي تتخذ خيارات للتنفيذ بناءً على سمات مثل عرض الشاشة ومظهر النظام الأساسي وما شابه ذلك. في هذه الحالة، ستقدّم التطبيق المصغّر AdaptivePlaylists
الذي يعيد صياغة طريقة تفاعل Playlists
مع PlaylistDetails
. عدِّل ملف lib/main.dart
على النحو التالي:
lib/main.dart
import 'dart:io';
import 'package:flex_color_scheme/flex_color_scheme.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'src/adaptive_playlists.dart'; // Add this import
import 'src/app_state.dart';
import 'src/playlist_details.dart';
// Remove the src/playlists.dart import
// From https://www.youtube.com/channel/UCwXdFgeE9KYzlDdR7TG9cMw
const flutterDevAccountId = 'UCwXdFgeE9KYzlDdR7TG9cMw';
// TODO: Replace with your YouTube API Key
const youTubeApiKey = 'AIzaNotAnApiKey';
final _router = GoRouter(
routes: <RouteBase>[
GoRoute(
path: '/',
builder: (context, state) {
return const AdaptivePlaylists(); // Modify this line
},
routes: <RouteBase>[
GoRoute(
path: 'playlist/:id',
builder: (context, state) {
final title = state.uri.queryParameters['title']!;
final id = state.pathParameters['id']!;
return Scaffold( // Modify from here
appBar: AppBar(title: Text(title)),
body: PlaylistDetails(
playlistId: id,
playlistName: title,
), // To here.
);
},
),
],
),
],
);
void main() {
if (youTubeApiKey == 'AIzaNotAnApiKey') {
print('youTubeApiKey has not been configured.');
exit(1);
}
runApp(ChangeNotifierProvider<FlutterDevPlaylists>(
create: (context) => FlutterDevPlaylists(
flutterDevAccountId: flutterDevAccountId,
youTubeApiKey: youTubeApiKey,
),
child: const PlaylistsApp(),
));
}
class PlaylistsApp extends StatelessWidget {
const PlaylistsApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'FlutterDev Playlists',
theme: FlexColorScheme.light(
scheme: FlexScheme.red,
useMaterial3: true,
).toTheme,
darkTheme: FlexColorScheme.dark(
scheme: FlexScheme.red,
useMaterial3: true,
).toTheme,
themeMode: ThemeMode.dark, // Or ThemeMode.System if you'd prefer
debugShowCheckedModeBanner: false,
routerConfig: _router,
);
}
}
أنشِئ بعد ذلك الملف لتطبيق AdaptivePlaylist المصغّر:
lib/src/adaptive_playlists.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:split_view/split_view.dart';
import 'playlist_details.dart';
import 'playlists.dart';
class AdaptivePlaylists extends StatelessWidget {
const AdaptivePlaylists({super.key});
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final targetPlatform = Theme.of(context).platform;
if (targetPlatform == TargetPlatform.android ||
targetPlatform == TargetPlatform.iOS ||
screenWidth <= 600) {
return const NarrowDisplayPlaylists();
} else {
return const WideDisplayPlaylists();
}
}
}
class NarrowDisplayPlaylists extends StatelessWidget {
const NarrowDisplayPlaylists({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('FlutterDev Playlists')),
body: Playlists(
playlistSelected: (playlist) {
context.go(
Uri(
path: '/playlist/${playlist.id}',
queryParameters: <String, String>{
'title': playlist.snippet!.title!
},
).toString(),
);
},
),
);
}
}
class WideDisplayPlaylists extends StatefulWidget {
const WideDisplayPlaylists({super.key});
@override
State<WideDisplayPlaylists> createState() => _WideDisplayPlaylistsState();
}
class _WideDisplayPlaylistsState extends State<WideDisplayPlaylists> {
Playlist? selectedPlaylist;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: switch (selectedPlaylist?.snippet?.title) {
String title => Text('FlutterDev Playlist: $title'),
_ => const Text('FlutterDev Playlists'),
},
),
body: SplitView(
viewMode: SplitViewMode.Horizontal,
children: [
Playlists(playlistSelected: (playlist) {
setState(() {
selectedPlaylist = playlist;
});
}),
switch ((selectedPlaylist?.id, selectedPlaylist?.snippet?.title)) {
(String id, String title) =>
PlaylistDetails(playlistId: id, playlistName: title),
_ => const Center(child: Text('Select a playlist')),
},
],
),
);
}
}
هذا الملف مثير للاهتمام لعدة أسباب. أولاً، يتم استخدام عرض النافذة (باستخدام MediaQuery.of(context).size.width
)، كما يتم فحص المظهر (باستخدام Theme.of(context).platform
) لتحديد ما إذا كان سيتم عرض تنسيق عريض باستخدام التطبيق المصغّر SplitView
أو شاشة ضيقة بدونه.
ثانيًا، يتناول هذا القسم المعالجة غير المشفّرة للتنقل. يعرض وسيطة معاودة الاتصال في التطبيق المصغّر Playlists
. إنّ معاودة الاتصال هذه تُعلِم الرمز المحيط بأنّ المستخدم قد اختار قائمة تشغيل. بعد ذلك، يجب أن ينفّذ الرمز عملية عرض قائمة التشغيل هذه. يؤدي ذلك إلى تغيير الحاجة إلى Scaffold
في الأداتَين Playlists
وPlaylistDetails
. لم تعُد هذه الأدوات في المستوى الأعلى، يجب إزالة Scaffold
من تلك التطبيقات المصغّرة.
بعد ذلك، عدِّل ملف src/lib/playlists.dart
على النحو التالي:
lib/src/playlists.dart
import 'package:flutter/material.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:provider/provider.dart';
import 'app_state.dart';
class Playlists extends StatelessWidget {
const Playlists({super.key, required this.playlistSelected});
final PlaylistsListSelected playlistSelected;
@override
Widget build(BuildContext context) {
return Consumer<FlutterDevPlaylists>(
builder: (context, flutterDev, child) {
final playlists = flutterDev.playlists;
if (playlists.isEmpty) {
return const Center(
child: CircularProgressIndicator(),
);
}
return _PlaylistsListView(
items: playlists,
playlistSelected: playlistSelected,
);
},
);
}
}
typedef PlaylistsListSelected = void Function(Playlist playlist);
class _PlaylistsListView extends StatefulWidget {
const _PlaylistsListView({
required this.items,
required this.playlistSelected,
});
final List<Playlist> items;
final PlaylistsListSelected playlistSelected;
@override
State<_PlaylistsListView> createState() => _PlaylistsListViewState();
}
class _PlaylistsListViewState extends State<_PlaylistsListView> {
late ScrollController _scrollController;
@override
void initState() {
super.initState();
_scrollController = ScrollController();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListView.builder(
controller: _scrollController,
itemCount: widget.items.length,
itemBuilder: (context, index) {
var playlist = widget.items[index];
return Padding(
padding: const EdgeInsets.all(8.0),
child: ListTile(
leading: Image.network(
playlist.snippet!.thumbnails!.default_!.url!,
),
title: Text(playlist.snippet!.title!),
subtitle: Text(
playlist.snippet!.description!,
),
onTap: () {
widget.playlistSelected(playlist);
},
),
);
},
);
}
}
هناك الكثير من التغييرات في هذا الملف. باستثناء المقدّمة المذكورة أعلاه حول قائمة معاودة الاتصال التي تم اختيارها، بالإضافة إلى إزالة التطبيق المصغّر Scaffold
، يتم تحويل التطبيق المصغّر _PlaylistsListView
من الحالة "بلا حالة" إلى الحالة. هذا التغيير مطلوب بسبب إدخال ScrollController
مملوك يجب إنشائه وتدميره.
إنّ تقديم ScrollController
هو أمر شيّق لأنّه يتطلب توفُّر أداتَين ListView
جنبًا إلى جنب على التنسيق العريض. من المعتاد استخدام جهاز ListView
واحد على الهواتف الجوّالة، وبالتالي يمكن أن يكون هناك جهاز Scroll Console واحد طويل الأجل يمكن توصيله بـ "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) {
if (authClient != null) {
context.read<AuthedUserPlaylists>().authClient = authClient;
context.go('/');
}
});
}
});
}
late final GoogleSignIn _googleSignIn;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: widget.button(onPressed: () {
_googleSignIn.signIn();
}),
),
);
}
}
class _GoogleApisAuthLogin extends StatefulWidget {
const _GoogleApisAuthLogin({
required this.button,
required this.scopes,
required this.clientId,
});
final _AdaptiveLoginButtonWidget button;
final List<String> scopes;
final ClientId clientId;
@override
State<_GoogleApisAuthLogin> createState() => _GoogleApisAuthLoginState();
}
class _GoogleApisAuthLoginState extends State<_GoogleApisAuthLogin> {
@override
initState() {
super.initState();
clientViaUserConsent(widget.clientId, widget.scopes, (url) {
setState(() {
_authUrl = Uri.parse(url);
});
}).then((authClient) {
context.read<AuthedUserPlaylists>().authClient = authClient;
context.go('/');
});
}
Uri? _authUrl;
@override
Widget build(BuildContext context) {
final authUrl = _authUrl;
if (authUrl != null) {
return Scaffold(
body: Center(
child: Link(
uri: authUrl,
builder: (context, followLink) =>
widget.button(onPressed: followLink),
),
),
);
}
return const Scaffold(
body: Center(
child: CircularProgressIndicator(),
),
);
}
}
يفعل هذا الملف الكثير. تؤدي طريقة build
المتّبعة في AdaptiveLogin
إلى تنفيذ المهام الصعبة. تطلب هذه الطريقة استخدام Platform.isXXX
لكل من kIsWeb
وdart:io
، وهي تتحقّق من النظام الأساسي لبيئة التشغيل. على نظامَي التشغيل Android وiOS والويب، تُنشئ الأداة مثيلاً للأداة _GoogleSignInLogin
ذات الحالة الخاصة. على أنظمة التشغيل 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({required this.playlistSelected, super.key});
final PlaylistsListSelected playlistSelected;
@override
Widget build(BuildContext context) {
return Consumer<AuthedUserPlaylists>( // Update this line
builder: (context, flutterDev, child) {
final playlists = flutterDev.playlists;
if (playlists.isEmpty) {
return const Center(
child: CircularProgressIndicator(),
);
}
return _PlaylistsListView(
items: playlists,
playlistSelected: playlistSelected,
);
},
);
}
}
أخيرًا، عدِّل ملف main.dart
لاستخدام أداة AdaptiveLogin
الجديدة بشكل صحيح:
lib/main.dart
// Drop dart:io import
import 'package:flex_color_scheme/flex_color_scheme.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:googleapis_auth/googleapis_auth.dart'; // Add this line
import 'package:provider/provider.dart';
import 'src/adaptive_login.dart'; // Add this line
import 'src/adaptive_playlists.dart';
import 'src/app_state.dart';
import 'src/playlist_details.dart';
// Drop flutterDevAccountId and youTubeApiKey
// Add from this line
// From https://developers.google.com/youtube/v3/guides/auth/installed-apps#identify-access-scopes
final scopes = [
'https://www.googleapis.com/auth/youtube.readonly',
];
// TODO: Replace with your Client ID and Client Secret for Desktop configuration
final clientId = ClientId(
'TODO-Client-ID.apps.googleusercontent.com',
'TODO-Client-secret',
);
// To this line
final _router = GoRouter(
routes: <RouteBase>[
GoRoute(
path: '/',
builder: (context, state) {
return const AdaptivePlaylists();
},
// Add redirect configuration
redirect: (context, state) {
if (!context.read<AuthedUserPlaylists>().isLoggedIn) {
return '/login';
} else {
return null;
}
},
// To this line
routes: <RouteBase>[
// Add new login Route
GoRoute(
path: 'login',
builder: (context, state) {
return AdaptiveLogin(
clientId: clientId,
scopes: scopes,
loginButtonChild: const Text('Login to YouTube'),
);
},
),
// To this line
GoRoute(
path: 'playlist/:id',
builder: (context, state) {
final title = state.uri.queryParameters['title']!;
final id = state.pathParameters['id']!;
return Scaffold(
appBar: AppBar(title: Text(title)),
body: PlaylistDetails(
playlistId: id,
playlistName: title,
),
);
},
),
],
),
],
);
void main() {
runApp(ChangeNotifierProvider<AuthedUserPlaylists>( // Modify this line
create: (context) => AuthedUserPlaylists(), // Modify this line
child: const PlaylistsApp(),
));
}
class PlaylistsApp extends StatelessWidget {
const PlaylistsApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'Your Playlists', // Change FlutterDev to Your
theme: FlexColorScheme.light(
scheme: FlexScheme.red,
useMaterial3: true,
).toTheme,
darkTheme: FlexColorScheme.dark(
scheme: FlexScheme.red,
useMaterial3: true,
).toTheme,
themeMode: ThemeMode.dark, // Or ThemeMode.System
debugShowCheckedModeBanner: false,
routerConfig: _router,
);
}
}
تعكس التغييرات في هذا الملف التغيير من مجرد عرض قوائم تشغيل YouTube على Flutter إلى عرض قوائم تشغيل المستخدم الذي تمت مصادقته. على الرغم من اكتمال الرمز الآن، لا تزال هناك سلسلة من التعديلات المطلوبة على هذا الملف والملفات ضمن تطبيق Runner المعني لضبط حزمتَي google_sign_in
وgoogleapis_auth
بشكلٍ صحيح للمصادقة.
يعرض التطبيق الآن قوائم تشغيل YouTube من المستخدم الذي تمت مصادقته. عند اكتمال هذه الميزات، ستحتاج إلى تفعيل المصادقة. لإجراء ذلك، يجب ضبط حزمتَي google_sign_in
وgoogleapis_auth
. لضبط الحِزم، عليك تغيير ملف "main.dart
" وملفات تطبيقات Runner.
جارٍ إعداد googleapis_auth
تتمثل الخطوة الأولى لإعداد المصادقة في إزالة مفتاح واجهة برمجة التطبيقات الذي تم إعداده واستخدامه من قبل. انتقِل إلى صفحة بيانات اعتماد مشروع واجهة برمجة التطبيقات واحذف مفتاح واجهة برمجة التطبيقات:
يؤدي هذا إلى إنشاء نافذة منبثقة تقرّ بها بالنقر على الزر "حذف":
بعد ذلك، يمكنك إنشاء معرِّف عميل OAuth:
بالنسبة إلى نوع التطبيق، اختَر تطبيق سطح المكتب.
اقبل الاسم وانقر على إنشاء.
يؤدي ذلك إلى إنشاء معرِّف العميل وسر العميل اللذين يجب إضافتهما إلى lib/main.dart
لضبط مسار googleapis_auth
. من تفاصيل التنفيذ المهمة أنّ مسار googleapis_auth يستخدم خادم ويب مؤقتًا يعمل على مضيف محلي للحصول على رمز 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 Platform Console:
هذا يكفي لتشغيل التطبيق على Android. بناءً على اختيار Google APIs التي تستخدمها، قد تحتاج إلى إضافة ملف JSON الذي تم إنشاؤه إلى حِزمة تطبيقك.
جارٍ إعداد google_sign_in
لنظام التشغيل iOS
ارجع إلى صفحة بيانات اعتماد مشروع واجهة برمجة التطبيقات وأنشِئ معرِّف عميل OAuth آخر، باستثناء ما يلي في هذه المرة اختَر iOS:
.
بالنسبة إلى بقية النموذج، يُرجى ملء معرّف الحزمة من خلال فتح ios/Runner.xcworkspace
في Xcode. انتقل إلى Project Navigator (المستكشف)، وحدد Runner في هذا المتصفح، ثم حدد علامة التبويب General (عام)، وانسخ معرّف الحزمة. إذا كنت قد اتّبعت هذا الدرس التطبيقي حول الترميز خطوة بخطوة، من المفترض أن تكون القيمة هي com.example.adaptiveApp
.
بالنسبة إلى بقية النموذج، يُرجى ملء معرّف الحزمة. افتح ios/Runner.xcworkspace
في Xcode. انتقِل إلى Project Navigator. الانتقال إلى Runner > علامة التبويب "إعدادات عامة" انسخ معرّف الحزمة. إذا كنت قد اتّبعت هذا الدرس التطبيقي حول الترميز خطوة بخطوة، يجب أن تكون قيمته com.example.adaptiveApp
.
تجاهل رقم تعريف متجر التطبيقات ورقم تعريف الفريق في الوقت الحالي، حيث أنهما غير مطلوبين للتطوير المحلي:
نزِّل ملف ".plist
" الذي تم إنشاؤه، ويستند اسمه إلى معرّف العميل الذي تم إنشاؤه. أعِد تسمية الملف الذي تم تنزيله إلى GoogleService-Info.plist
، ثم اسحبه إلى مُحرِّر Xcode قيد التشغيل، إلى جانب ملف Info.plist
ضمن Runner/Runner
في أداة التنقّل اليمنى. بالنسبة إلى مربّع حوار الخيارات في Xcode، اختَر نسخ العناصر إذا لزم الأمر، وإنشاء مراجع للمجلدات، وإضافة إلى Runner.
اخرج من Xcode، ثم أضِف ما يلي إلى Info.plist
في بيئة التطوير المتكاملة التي تختارها:
ios/Runner/Info.plist
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<!-- TODO Replace this value: -->
<!-- Copied from GoogleService-Info.plist key REVERSED_CLIENT_ID -->
<string>com.googleusercontent.apps.TODO-REPLACE-ME</string>
</array>
</dict>
</array>
يجب تعديل القيمة لتتطابق مع الإدخال في ملف GoogleService-Info.plist
الذي تم إنشاؤه. شغِّل التطبيق، وبعد تسجيل الدخول، من المفترض أن تظهر قوائم التشغيل الخاصة بك.
جارٍ إعداد 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 كما يلي:
$ 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. الخطوات التالية
تهانينا!
لقد أكملت الدرس التطبيقي حول الترميز وأنشأت تطبيقًا تكيُّفيًا من Flutter يعمل على جميع الأنظمة الأساسية الست المتوافقة مع Flutter. لقد قمت بتكييف الرمز البرمجي للتعامل مع الاختلافات في كيفية تخطيط الشاشات، وكيفية التفاعل مع النص، وكيفية تحميل الصور، وكيفية عمل المصادقة.
هناك العديد من الأشياء التي يمكنك تكييفها في تطبيقاتك. لمعرفة طرق إضافية لتكييف الرمز البرمجي مع البيئات المختلفة التي سيتم تشغيلها فيها، يُرجى الاطّلاع على إنشاء تطبيقات تكيُّفية.