1. مقدمة
Flutter هي مجموعة أدوات واجهة مستخدم من Google يمكن استخدامها لإنشاء تطبيقات محلية ومميّزة للأجهزة الجوّالة والويب وأجهزة سطح المكتب من خلال قاعدة رموز برمجية واحدة. في هذا الدرس البرمجي، ستتعلّم كيفية إنشاء تطبيق Flutter يتكيّف مع النظام الأساسي الذي يعمل عليه، سواء كان Android أو iOS أو الويب أو Windows أو macOS أو Linux.
المُعطيات
- كيفية توسيع نطاق تطبيق Flutter مصمَّم للأجهزة الجوّالة ليعمل على جميع الأنظمة الأساسية الستة التي يتيحها Flutter
- واجهات برمجة التطبيقات المختلفة في Flutter لرصد الأنظمة الأساسية وحالات استخدام كل واجهة برمجة تطبيقات
- التكيّف مع القيود والتوقّعات المتعلّقة بتشغيل تطبيق على الويب
- كيفية استخدام حِزم مختلفة معًا لتوفير النطاق الكامل لمنصات Flutter
ما ستنشئه
في هذا الدرس التطبيقي حول الترميز، ستنشئ في البداية تطبيق Flutter لنظامَي التشغيل Android وiOS يستكشف قوائم تشغيل Flutter على YouTube. بعد ذلك، ستعدّل هذا التطبيق ليعمل على أنظمة التشغيل الثلاثة لأجهزة الكمبيوتر (Windows وmacOS وLinux) من خلال تغيير طريقة عرض المعلومات حسب حجم نافذة التطبيق. بعد ذلك، عليك تعديل التطبيق ليتوافق مع الويب من خلال إتاحة إمكانية تحديد النص المعروض في التطبيق، كما يتوقّع مستخدمو الويب. أخيرًا، ستضيف مصادقة إلى التطبيق حتى تتمكّن من استكشاف قوائم التشغيل الخاصة بك، بدلاً من تلك التي أنشأها فريق Flutter، والتي تتطلّب أساليب مختلفة للمصادقة على Android وiOS والويب، مقارنةً بأنظمة التشغيل الثلاثة لأجهزة الكمبيوتر، وهي Windows وmacOS وLinux.
في ما يلي لقطة شاشة لتطبيق Flutter على Android وiOS:
يجب أن يشبه هذا التطبيق الذي يتم تشغيله في وضع ملء الشاشة على جهاز macOS لقطة الشاشة التالية.
يركّز هذا الدرس العملي على تحويل تطبيق Flutter للأجهزة الجوّالة إلى تطبيق متكيّف يعمل على جميع منصات Flutter الست. يتم تجاهل المفاهيم ومجموعات الرموز غير ذات الصلة، ويتم توفيرها لك لنسخها ولصقها.
ما الذي تريد تعلّمه من هذا الدرس العملي؟
2. إعداد بيئة تطوير Flutter
تحتاج إلى برنامجَين لإكمال هذا الدرس التطبيقي، وهما حزمة تطوير البرامج (SDK) من Flutter ومحرِّر.
يمكنك تشغيل الدرس العملي باستخدام أيّ من الأجهزة التالية:
- جهاز Android أو iOS فعلي متصل بالكمبيوتر وتم ضبطه على وضع "المطوّر"
- محاكي iOS (يتطلّب تثبيت أدوات Xcode)
- محاكي Android (يتطلّب الإعداد في "استوديو Android")
- متصفّح (يجب استخدام Chrome لتصحيح الأخطاء).
- كتطبيق سطح مكتب على Windows أو Linux أو macOS يجب أن يتم التطوير على النظام الأساسي الذي تخطّط للنشر عليه. لذا، إذا أردت تطوير تطبيق Windows لسطح المكتب، يجب أن يتم التطوير على Windows للوصول إلى سلسلة الإنشاء المناسبة. هناك متطلبات خاصة بنظام التشغيل يتم تناولها بالتفصيل على docs.flutter.dev/desktop.
3- البدء
تأكيد بيئة التطوير
أسهل طريقة للتأكّد من أنّ كل شيء جاهز للتطوير هي تنفيذ الأمر التالي:
flutter doctor
إذا ظهر أي شيء بدون علامة اختيار، نفِّذ ما يلي للحصول على مزيد من التفاصيل حول المشكلة:
flutter doctor -v
قد تحتاج إلى تثبيت أدوات المطوّرين لتطوير التطبيقات على الأجهزة الجوّالة أو أجهزة الكمبيوتر. للحصول على مزيد من التفاصيل حول إعداد الأدوات حسب نظام التشغيل المضيف، يُرجى الاطّلاع على المستندات في مستندات تثبيت Flutter.
إنشاء مشروع Flutter
إحدى طرق البدء في كتابة تطبيقات Flutter لسطح المكتب هي استخدام أداة سطر الأوامر في Flutter لإنشاء مشروع Flutter. بدلاً من ذلك، قد توفّر بيئة التطوير المتكاملة سير عمل لإنشاء مشروع Flutter من خلال واجهة المستخدم.
$ flutter create adaptive_app Creating project adaptive_app... Resolving dependencies in adaptive_app... (1.8s) Got dependencies in adaptive_app. Wrote 129 files. All done! You can find general documentation for Flutter at: https://docs.flutter.dev/ Detailed API documentation is available at: https://api.flutter.dev/ If you prefer video documentation, consider: https://www.youtube.com/c/flutterdev In order to run your application, type: $ cd adaptive_app $ flutter run Your application code is in adaptive_app/lib/main.dart.
للتأكّد من أنّ كل شيء يعمل بشكل صحيح، شغِّل تطبيق Flutter النموذجي كتطبيق للأجهزة الجوّالة كما هو موضّح أدناه. بدلاً من ذلك، افتح هذا المشروع في بيئة التطوير المتكاملة (IDE)، واستخدِم أدواته لتشغيل التطبيق. بفضل الخطوة السابقة، يجب أن يكون التشغيل كتطبيق على الكمبيوتر هو الخيار الوحيد المتاح.
$ flutter run Launching lib/main.dart on iPhone 15 in debug mode... Running Xcode build... └─Compiling, linking and signing... 6.5s Xcode build done. 24.6s Syncing files to device iPhone 15... 46ms Flutter run key commands. r Hot reload. 🔥🔥🔥 R Hot restart. h List all available interactive commands. d Detach (terminate "flutter run" but leave application running). c Clear the screen q Quit (terminate the application on the device). A Dart VM Service on iPhone 15 is available at: http://127.0.0.1:50501/JHGBwC_hFJo=/ The Flutter DevTools debugger and profiler on iPhone 15 is available at: http://127.0.0.1:9102?uri=http://127.0.0.1:50501/JHGBwC_hFJo=/
من المفترض أن يظهر لك التطبيق قيد التشغيل. يجب تعديل المحتوى.
لتعديل المحتوى، عدِّل الرمز في lib/main.dart
باستخدام الرمز التالي. لتغيير ما يعرضه تطبيقك، نفِّذ عملية إعادة تحميل سريعة.
- إذا كنت تشغّل التطبيق باستخدام سطر الأوامر، اكتب
r
في وحدة التحكّم لإجراء إعادة تحميل سريعة. - إذا شغّلت التطبيق باستخدام بيئة تطوير متكاملة (IDE)، سيتم إعادة تحميل التطبيق عند حفظ الملف.
lib/main.dart
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
),
home: const ResizeablePage(),
);
}
}
class ResizeablePage extends StatelessWidget {
const ResizeablePage({super.key});
@override
Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
final themePlatform = Theme.of(context).platform;
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'Window properties',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
SizedBox(
width: 350,
child: Table(
textBaseline: TextBaseline.alphabetic,
children: <TableRow>[
_fillTableRow(
context: context,
property: 'Window Size',
value:
'${mediaQuery.size.width.toStringAsFixed(1)} x '
'${mediaQuery.size.height.toStringAsFixed(1)}',
),
_fillTableRow(
context: context,
property: 'Device Pixel Ratio',
value: mediaQuery.devicePixelRatio.toStringAsFixed(2),
),
_fillTableRow(
context: context,
property: 'Platform.isXXX',
value: platformDescription(),
),
_fillTableRow(
context: context,
property: 'Theme.of(ctx).platform',
value: themePlatform.toString(),
),
],
),
),
],
),
),
);
}
TableRow _fillTableRow({
required BuildContext context,
required String property,
required String value,
}) {
return TableRow(
children: [
TableCell(
verticalAlignment: TableCellVerticalAlignment.baseline,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(property),
),
),
TableCell(
verticalAlignment: TableCellVerticalAlignment.baseline,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(value),
),
),
],
);
}
String platformDescription() {
if (kIsWeb) {
return 'Web';
} else if (Platform.isAndroid) {
return 'Android';
} else if (Platform.isIOS) {
return 'iOS';
} else if (Platform.isWindows) {
return 'Windows';
} else if (Platform.isMacOS) {
return 'macOS';
} else if (Platform.isLinux) {
return 'Linux';
} else if (Platform.isFuchsia) {
return 'Fuchsia';
} else {
return 'Unknown';
}
}
}
تم تصميم التطبيق ليعطيك فكرة عن كيفية رصد المنصات المختلفة والتكيّف معها. في ما يلي التطبيق الذي يعمل بشكل أصلي على Android وiOS:
في ما يلي الرمز نفسه الذي يتم تشغيله تلقائيًا على نظام التشغيل 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 أو شريط التمرير Cupertino، كما هو موضّح في Slider.adaptive
.
في القسم التالي، ستنشئ تطبيقًا أساسيًا لاستكشاف قوائم تشغيل YouTube تم تحسينه لنظامَي التشغيل Android وiOS فقط. في الأقسام التالية، ستضيف تعديلات مختلفة لتحسين أداء التطبيق على أجهزة الكمبيوتر المكتبي والويب.
4. إنشاء تطبيق للأجهزة الجوّالة
إضافة حِزم
في هذا التطبيق، ستستخدم مجموعة متنوعة من حِزم Flutter للوصول إلى YouTube Data API وإدارة الحالة وتطبيق بعض السمات.
$ flutter pub add googleapis http provider url_launcher flex_color_scheme go_router Resolving dependencies... Downloading packages... + _discoveryapis_commons 1.0.7 characters 1.4.0 (1.4.1 available) + flex_color_scheme 8.3.0 + flex_seed_scheme 3.5.1 > flutter_lints 6.0.0 (was 5.0.0) + flutter_web_plugins 0.0.0 from sdk flutter + go_router 16.2.0 + googleapis 14.0.0 + http 1.5.0 + http_parser 4.1.2 > lints 6.0.0 (was 5.1.1) + logging 1.3.0 material_color_utilities 0.11.1 (0.13.0 available) meta 1.16.0 (1.17.0 available) + nested 1.0.0 + plugin_platform_interface 2.1.8 + provider 6.1.5 test_api 0.7.6 (0.7.7 available) + typed_data 1.4.0 + url_launcher 6.3.2 + url_launcher_android 6.3.17 + url_launcher_ios 6.3.4 + url_launcher_linux 3.2.1 + url_launcher_macos 3.2.3 + url_launcher_platform_interface 2.3.2 + url_launcher_web 2.4.1 + url_launcher_windows 3.1.4 + web 1.1.1 Changed 24 dependencies! 4 packages have newer versions incompatible with dependency constraints. Try `flutter pub outdated` for more information.
يضيف هذا الأمر عددًا من الحِزم إلى التطبيق:
googleapis
: مكتبة Dart تم إنشاؤها وتتيح الوصول إلى واجهات Google APIs.-
http
: هي مكتبة لإنشاء طلبات HTTP تخفي الاختلافات بين المتصفّحات الأصلية ومتصفّحات الويب. -
provider
: توفّر إدارة الحالة. url_launcher
: يوفّر الوسائل للانتقال إلى فيديو من قائمة تشغيل. كما هو موضّح من التبعيات التي تم حلّها، يتضمّنurl_launcher
عمليات تنفيذ لأنظمة التشغيل Windows وmacOS وLinux والويب، بالإضافة إلى Android وiOS التلقائيين. يعني استخدام هذه الحزمة أنّك لن تحتاج إلى إنشاء رمز خاص بالمنصة لهذه الوظيفة.flex_color_scheme
: يمنح التطبيق نظام ألوان تلقائيًا جميلاً. لمزيد من المعلومات، يُرجى الاطّلاع على مستنداتflex_color_scheme
API.go_router
: تنفِّذ هذه السمة عملية التنقّل بين الشاشات المختلفة. توفر هذه الحزمة واجهة برمجة تطبيقات سهلة الاستخدام تستند إلى عناوين URL للتنقّل باستخدام Router في Flutter.
ضبط تطبيقات الأجهزة الجوّالة للحساب url_launcher
يتطلّب المكوّن الإضافي url_launcher
ضبط تطبيقات التشغيل على Android وiOS. في مشغّل iOS Flutter، أضِف الأسطر التالية إلى قاموس plist
.
ios/Runner/Info.plist
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
<string>http</string>
<string>tel</string>
<string>mailto</string>
</array>
في مشغّل Android Flutter، أضِف الأسطر التالية إلى Manifest.xml
. أضِف العقدة queries
هذه كعقدة فرعية مباشرة للعقدة manifest
وعقدة مماثلة للعقدة application
.
android/app/src/main/AndroidManifest.xml
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
<intent>
<action android:name="android.intent.action.DIAL" />
<data android:scheme="tel" />
</intent>
<intent>
<action android:name="android.intent.action.SEND" />
<data android:mimeType="*/*" />
</intent>
</queries>
لمزيد من التفاصيل حول تغييرات الإعدادات المطلوبة هذه، يُرجى الاطّلاع على مستندات url_launcher
.
الوصول إلى YouTube Data API
للوصول إلى YouTube Data API من أجل إدراج قوائم التشغيل، عليك إنشاء مشروع API لإنشاء مفاتيح API المطلوبة. تفترض هذه الخطوات أنّ لديك حسابًا على Google، لذا أنشئ حسابًا إذا لم يكن لديك حساب.
انتقِل إلى Developer Console من أجل إنشاء مشروع لواجهة برمجة التطبيقات:
بعد إنشاء مشروع، انتقِل إلى صفحة "مكتبة واجهة برمجة التطبيقات". في مربّع البحث، أدخِل "youtube"، ثم اختَر youtube data api v3.
في صفحة تفاصيل YouTube Data API الإصدار 3، فعِّل واجهة برمجة التطبيقات.
بعد تفعيل واجهة برمجة التطبيقات، انتقِل إلى صفحة بيانات الاعتماد وأنشئ مفتاحًا لواجهة برمجة التطبيقات.
بعد بضع ثوانٍ، من المفترض أن يظهر مربّع حوار يتضمّن مفتاح واجهة برمجة التطبيقات الجديد. ستستخدم هذا المفتاح قريبًا.
إضافة رمز
في بقية هذه الخطوة، ستنسخ الكثير من الرموز وتلصقها لإنشاء تطبيق على الأجهزة الجوّالة، بدون أي تعليق على الرموز. تهدف تجربة البرمجة هذه إلى أخذ تطبيق الأجهزة الجوّالة وتكييفه ليعمل على كلّ من أجهزة الكمبيوتر والويب. للحصول على مقدمة أكثر تفصيلاً حول إنشاء تطبيقات 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... characters 1.4.0 (1.4.1 available) material_color_utilities 0.11.1 (0.13.0 available) meta 1.16.0 (1.17.0 available) + split_view 3.2.1 test_api 0.7.6 (0.7.7 available) Changed 1 dependency! 4 packages have newer versions incompatible with dependency constraints. Try `flutter pub outdated` for more information.
تقديم التطبيقات المصغّرة التكيّفية
النمط الذي ستستخدمه في هذا الدرس التطبيقي حول الترميز هو تقديم أدوات Adaptive Widgets التي تتخذ خيارات التنفيذ استنادًا إلى سمات مثل عرض الشاشة وسمة النظام الأساسي وما شابه ذلك. في هذه الحالة، ستضيف أداة AdaptivePlaylists
تعيد صياغة طريقة تفاعل Playlists
وPlaylistDetails
. عدِّل ملف lib/main.dart
على النحو التالي:
lib/main.dart
import 'dart:io';
import 'package:flex_color_scheme/flex_color_scheme.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'src/adaptive_playlists.dart'; // Add this import
import 'src/app_state.dart';
import 'src/playlist_details.dart';
// Remove the src/playlists.dart import
// From https://www.youtube.com/channel/UCwXdFgeE9KYzlDdR7TG9cMw
const flutterDevAccountId = 'UCwXdFgeE9KYzlDdR7TG9cMw';
// TODO: Replace with your YouTube API Key
const youTubeApiKey = 'AIzaNotAnApiKey';
final _router = GoRouter(
routes: <RouteBase>[
GoRoute(
path: '/',
builder: (context, state) {
return const AdaptivePlaylists(); // Modify this line
},
routes: <RouteBase>[
GoRoute(
path: 'playlist/:id',
builder: (context, state) {
final title = state.uri.queryParameters['title']!;
final id = state.pathParameters['id']!;
return Scaffold( // Modify from here
appBar: AppBar(title: Text(title)),
body: PlaylistDetails(playlistId: id, playlistName: title),
); // To here.
},
),
],
),
],
);
void main() {
if (youTubeApiKey == 'AIzaNotAnApiKey') {
print('youTubeApiKey has not been configured.');
exit(1);
}
runApp(
ChangeNotifierProvider<FlutterDevPlaylists>(
create: (context) => FlutterDevPlaylists(
flutterDevAccountId: flutterDevAccountId,
youTubeApiKey: youTubeApiKey,
),
child: const PlaylistsApp(),
),
);
}
class PlaylistsApp extends StatelessWidget {
const PlaylistsApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'FlutterDev Playlists',
theme: FlexColorScheme.light(scheme: FlexScheme.red).toTheme,
darkTheme: FlexColorScheme.dark(scheme: FlexScheme.red).toTheme,
themeMode: ThemeMode.dark, // Or ThemeMode.System if you'd prefer
debugShowCheckedModeBanner: false,
routerConfig: _router,
);
}
}
بعد ذلك، أنشئ الملف الخاص بأداة AdaptivePlaylist:
lib/src/adaptive_playlists.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:split_view/split_view.dart';
import 'playlist_details.dart';
import 'playlists.dart';
class AdaptivePlaylists extends StatelessWidget {
const AdaptivePlaylists({super.key});
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final targetPlatform = Theme.of(context).platform;
if (targetPlatform == TargetPlatform.android ||
targetPlatform == TargetPlatform.iOS ||
screenWidth <= 600) {
return const NarrowDisplayPlaylists();
} else {
return const WideDisplayPlaylists();
}
}
}
class NarrowDisplayPlaylists extends StatelessWidget {
const NarrowDisplayPlaylists({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('FlutterDev Playlists')),
body: Playlists(
playlistSelected: (playlist) {
context.go(
Uri(
path: '/playlist/${playlist.id}',
queryParameters: <String, String>{
'title': playlist.snippet!.title!,
},
).toString(),
);
},
),
);
}
}
class WideDisplayPlaylists extends StatefulWidget {
const WideDisplayPlaylists({super.key});
@override
State<WideDisplayPlaylists> createState() => _WideDisplayPlaylistsState();
}
class _WideDisplayPlaylistsState extends State<WideDisplayPlaylists> {
Playlist? selectedPlaylist;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: switch (selectedPlaylist?.snippet?.title) {
String title => Text('FlutterDev Playlist: $title'),
_ => const Text('FlutterDev Playlists'),
},
),
body: SplitView(
viewMode: SplitViewMode.Horizontal,
children: [
Playlists(
playlistSelected: (playlist) {
setState(() {
selectedPlaylist = playlist;
});
},
),
switch ((selectedPlaylist?.id, selectedPlaylist?.snippet?.title)) {
(String id, String title) => PlaylistDetails(
playlistId: id,
playlistName: title,
),
_ => const Center(child: Text('Select a playlist')),
},
],
),
);
}
}
هذا الملف مهم لعدة أسباب. أولاً، يتم استخدام عرض النافذة (باستخدام MediaQuery.of(context).size.width
)، كما يتم فحص المظهر (باستخدام Theme.of(context).platform
) لتحديد ما إذا كان سيتم عرض تخطيط عريض باستخدام الأداة SplitView
أو عرض ضيق بدونها.
ثانيًا، يتناول هذا القسم معالجة التنقّل المبرمَج. تعرض هذه السمة وسيطة ردّ الاتصال في الأداة Playlists
. يُعلم هذا الإجراء الاحتياطي الرمز المحيط بأنّ المستخدم قد اختار قائمة تشغيل. بعد ذلك، يجب أن تنفّذ التعليمات البرمجية العمل اللازم لعرض قائمة التشغيل هذه. يؤدي ذلك إلى تغيير الحاجة إلى Scaffold
في أدوات Playlists
وPlaylistDetails
. وبما أنّها لم تعُد في المستوى الأعلى، عليك إزالة الرمز Scaffold
من تلك التطبيقات المصغّرة.
بعد ذلك، عدِّل ملف src/lib/playlists.dart
ليطابق الرمز التالي:
lib/src/playlists.dart
import 'package:flutter/material.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:provider/provider.dart';
import 'app_state.dart';
class Playlists extends StatelessWidget {
const Playlists({super.key, required this.playlistSelected});
final PlaylistsListSelected playlistSelected;
@override
Widget build(BuildContext context) {
return Consumer<FlutterDevPlaylists>(
builder: (context, flutterDev, child) {
final playlists = flutterDev.playlists;
if (playlists.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
return _PlaylistsListView(
items: playlists,
playlistSelected: playlistSelected,
);
},
);
}
}
typedef PlaylistsListSelected = void Function(Playlist playlist);
class _PlaylistsListView extends StatefulWidget {
const _PlaylistsListView({
required this.items,
required this.playlistSelected,
});
final List<Playlist> items;
final PlaylistsListSelected playlistSelected;
@override
State<_PlaylistsListView> createState() => _PlaylistsListViewState();
}
class _PlaylistsListViewState extends State<_PlaylistsListView> {
late ScrollController _scrollController;
@override
void initState() {
super.initState();
_scrollController = ScrollController();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListView.builder(
controller: _scrollController,
itemCount: widget.items.length,
itemBuilder: (context, index) {
var playlist = widget.items[index];
return Padding(
padding: const EdgeInsets.all(8.0),
child: ListTile(
leading: Image.network(
playlist.snippet!.thumbnails!.default_!.url!,
),
title: Text(playlist.snippet!.title!),
subtitle: Text(playlist.snippet!.description!),
onTap: () {
widget.playlistSelected(playlist);
},
),
);
},
);
}
}
هناك الكثير من التغييرات في هذا الملف. بالإضافة إلى تقديم وظيفة رد الاتصال playlistSelected
المذكورة أعلاه وإزالة الأداة Scaffold
، يتم تحويل الأداة _PlaylistsListView
من أداة عديمة الحالة إلى أداة ذات حالة. هذا التغيير مطلوب بسبب تقديم ScrollController
مملوكة يجب إنشاؤها وإتلافها.
إنّ إضافة ScrollController
أمر مهم لأنّه مطلوب في التصميمات ذات العرض الكامل، إذ يتضمّن التصميم عنصرَي ListView
جنبًا إلى جنب. على الهاتف الجوّال، من الشائع أن يكون هناك ListView
واحد، وبالتالي يمكن أن يكون هناك ScrollController
واحد طويل الأمد ترتبط به جميع ListView
s وتنفصل عنها خلال دورات حياتها الفردية. يختلف الأمر على الكمبيوتر المكتبي، حيث يكون من المنطقي عرض عدة 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... Downloading packages... http 1.5.0 (from dev dependency to direct dependency) + shelf_cors_headers 0.1.5 Changed 2 dependencies!
هناك تبعية حالية لم تعُد مطلوبة. يجب اقتطاعها على النحو التالي:
$ dart pub remove shelf_router Resolving dependencies... Downloading packages... These packages are no longer being depended on: - http_methods 1.1.1 - shelf_router 1.1.4 Changed 2 dependencies!
بعد ذلك، عدِّل محتوى ملف server.dart ليطابق ما يلي:
yt_cors_proxy/bin/server.dart
import 'dart:async';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart';
import 'package:shelf_cors_headers/shelf_cors_headers.dart';
Future<Response> _requestHandler(Request req) async {
final target = req.url.replace(scheme: 'https', host: 'i.ytimg.com');
final response = await http.get(target);
return Response.ok(response.bodyBytes, headers: response.headers);
}
void main(List<String> args) async {
// Use any available host or container IP (usually `0.0.0.0`).
final ip = InternetAddress.anyIPv4;
// Configure a pipeline that adds CORS headers and proxies requests.
final handler = Pipeline()
.addMiddleware(logRequests())
.addMiddleware(corsHeaders(headers: {ACCESS_CONTROL_ALLOW_ORIGIN: '*'}))
.addHandler(_requestHandler);
// For running in containers, we respect the PORT environment variable.
final port = int.parse(Platform.environment['PORT'] ?? '8080');
final server = await serve(handler, ip, port);
print('Server listening on port ${server.port}');
}
يمكنك تشغيل هذا الخادم على النحو التالي:
$ dart run bin/server.dart Server listening on port 8080
بدلاً من ذلك، يمكنك إنشاءها كصورة Docker وتشغيل صورة Docker الناتجة على النحو التالي:
$ docker build . -t yt-cors-proxy [+] Building 2.7s (14/14) FINISHED $ docker run -p 8080:8080 yt-cors-proxy Server listening on port 8080
بعد ذلك، عدِّل رمز Flutter للاستفادة من وكيل CORS هذا، ولكن فقط عند التشغيل داخل متصفّح ويب.
مجموعة من التطبيقات المصغّرة القابلة للتكيّف
يتمثّل العنصر الأول من مجموعة الأدوات في طريقة استخدام تطبيقك لخادم وكيل CORS.
lib/src/adaptive_image.dart
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class AdaptiveImage extends StatelessWidget {
AdaptiveImage.network(String url, {super.key}) {
if (kIsWeb) {
_url = Uri.parse(
url,
).replace(host: 'localhost', port: 8080, scheme: 'http').toString();
} else {
_url = url;
}
}
late final String _url;
@override
Widget build(BuildContext context) {
return Image.network(_url);
}
}
يستخدم هذا التطبيق الثابت kIsWeb
بسبب الاختلافات في منصة وقت التشغيل. تغيّر الأداة التكيّفية الأخرى التطبيق ليعمل مثل صفحات الويب الأخرى. يتوقّع مستخدمو المتصفّح أن يكون النص قابلاً للتحديد.
lib/src/adaptive_text.dart
import 'package:flutter/material.dart';
class AdaptiveText extends StatelessWidget {
const AdaptiveText(this.data, {super.key, this.style});
final String data;
final TextStyle? style;
@override
Widget build(BuildContext context) {
return switch (Theme.of(context).platform) {
TargetPlatform.android || TargetPlatform.iOS => Text(data, style: style),
_ => SelectableText(data, style: style),
};
}
}
الآن، وزِّع هذه التعديلات على مستوى قاعدة الرموز:
lib/src/playlist_details.dart
import 'package:flutter/material.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/link.dart';
import 'adaptive_image.dart'; // Add this line,
import 'adaptive_text.dart'; // And this line
import 'app_state.dart';
class PlaylistDetails extends StatelessWidget {
const PlaylistDetails({
required this.playlistId,
required this.playlistName,
super.key,
});
final String playlistId;
final String playlistName;
@override
Widget build(BuildContext context) {
return Consumer<FlutterDevPlaylists>(
builder: (context, playlists, _) {
final playlistItems = playlists.playlistItems(playlistId: playlistId);
if (playlistItems.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
return _PlaylistDetailsListView(playlistItems: playlistItems);
},
);
}
}
class _PlaylistDetailsListView extends StatefulWidget {
const _PlaylistDetailsListView({required this.playlistItems});
final List<PlaylistItem> playlistItems;
@override
State<_PlaylistDetailsListView> createState() =>
_PlaylistDetailsListViewState();
}
class _PlaylistDetailsListViewState extends State<_PlaylistDetailsListView> {
late ScrollController _scrollController;
@override
void initState() {
super.initState();
_scrollController = ScrollController();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListView.builder(
controller: _scrollController,
itemCount: widget.playlistItems.length,
itemBuilder: (context, index) {
final playlistItem = widget.playlistItems[index];
return Padding(
padding: const EdgeInsets.all(8.0),
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Stack(
alignment: Alignment.center,
children: [
if (playlistItem.snippet!.thumbnails!.high != null)
AdaptiveImage.network( // Modify this line
playlistItem.snippet!.thumbnails!.high!.url!,
),
_buildGradient(context),
_buildTitleAndSubtitle(context, playlistItem),
_buildPlayButton(context, playlistItem),
],
),
),
);
},
);
}
Widget _buildGradient(BuildContext context) {
return Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.transparent, Theme.of(context).colorScheme.surface],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: const [0.5, 0.95],
),
),
),
);
}
Widget _buildTitleAndSubtitle(
BuildContext context,
PlaylistItem playlistItem,
) {
return Positioned(
left: 20,
right: 0,
bottom: 20,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AdaptiveText( // Also, this line
playlistItem.snippet!.title!,
style: Theme.of(context).textTheme.bodyLarge!.copyWith(
fontSize: 18,
// fontWeight: FontWeight.bold,
),
),
if (playlistItem.snippet!.videoOwnerChannelTitle != null)
AdaptiveText( // And this line
playlistItem.snippet!.videoOwnerChannelTitle!,
style: Theme.of(
context,
).textTheme.bodyMedium!.copyWith(fontSize: 12),
),
],
),
);
}
Widget _buildPlayButton(BuildContext context, PlaylistItem playlistItem) {
return Stack(
alignment: AlignmentDirectional.center,
children: [
Container(
width: 42,
height: 42,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(Radius.circular(21)),
),
),
Link(
uri: Uri.parse(
'https://www.youtube.com/watch?v=${playlistItem.snippet!.resourceId!.videoId}',
),
builder: (context, followLink) => IconButton(
onPressed: followLink,
color: Colors.red,
icon: const Icon(Icons.play_circle_fill),
iconSize: 45,
),
),
],
);
}
}
في الرمز أعلاه، عدّلت كلاً من الأداة Image.network
والأداة Text
. بعد ذلك، عدِّل أداة Playlists
.
lib/src/playlists.dart
import 'package:flutter/material.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:provider/provider.dart';
import 'adaptive_image.dart'; // Add this line
import 'app_state.dart';
class Playlists extends StatelessWidget {
const Playlists({super.key, required this.playlistSelected});
final PlaylistsListSelected playlistSelected;
@override
Widget build(BuildContext context) {
return Consumer<FlutterDevPlaylists>(
builder: (context, flutterDev, child) {
final playlists = flutterDev.playlists;
if (playlists.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
return _PlaylistsListView(
items: playlists,
playlistSelected: playlistSelected,
);
},
);
}
}
typedef PlaylistsListSelected = void Function(Playlist playlist);
class _PlaylistsListView extends StatefulWidget {
const _PlaylistsListView({
required this.items,
required this.playlistSelected,
});
final List<Playlist> items;
final PlaylistsListSelected playlistSelected;
@override
State<_PlaylistsListView> createState() => _PlaylistsListViewState();
}
class _PlaylistsListViewState extends State<_PlaylistsListView> {
late ScrollController _scrollController;
@override
void initState() {
super.initState();
_scrollController = ScrollController();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListView.builder(
controller: _scrollController,
itemCount: widget.items.length,
itemBuilder: (context, index) {
var playlist = widget.items[index];
return Padding(
padding: const EdgeInsets.all(8.0),
child: ListTile(
leading: AdaptiveImage.network( // Change this one.
playlist.snippet!.thumbnails!.default_!.url!,
),
title: Text(playlist.snippet!.title!),
subtitle: Text(playlist.snippet!.description!),
onTap: () {
widget.playlistSelected(playlist);
},
),
);
},
);
}
}
في هذه المرة، عدّلت أداة Image.network
فقط، ولكن تركت أداتَي Text
كما هما. كان ذلك مقصودًا لأنّه في حال تعديل أدوات Text، يتم حظر وظيفة onTap
في ListTile
عندما ينقر المستخدم على النص.
تشغيل التطبيق على الويب بشكل صحيح
بعد تشغيل خادم وكيل CORS، من المفترض أن تتمكّن من تشغيل إصدار الويب من التطبيق وأن يظهر بالشكل التالي:
7. المصادقة التكيّفية
في هذه الخطوة، ستوسّع نطاق التطبيق من خلال منحه القدرة على مصادقة المستخدم، ثم عرض قوائم التشغيل الخاصة بهذا المستخدم. عليك استخدام العديد من المكوّنات الإضافية لتغطية الأنظمة الأساسية المختلفة التي يمكن تشغيل التطبيق عليها، لأنّ التعامل مع OAuth يختلف بشكل كبير بين Android وiOS والويب وWindows وmacOS وLinux.
إضافة مكوّنات إضافية لتفعيل المصادقة باستخدام Google
ستثبّت ثلاث حِزم للتعامل مع مصادقة Google.
$ flutter pub add googleapis_auth google_sign_in \ extension_google_sign_in_as_googleapis_auth logging Resolving dependencies... Downloading packages... + args 2.7.0 characters 1.4.0 (1.4.1 available) + crypto 3.0.6 + extension_google_sign_in_as_googleapis_auth 3.0.0 + google_identity_services_web 0.3.3+1 + google_sign_in 7.1.1 + google_sign_in_android 7.0.3 + google_sign_in_ios 6.1.0 + google_sign_in_platform_interface 3.0.0 + google_sign_in_web 1.0.0 + googleapis_auth 2.0.0 logging 1.3.0 (from transitive dependency to direct dependency) material_color_utilities 0.11.1 (0.13.0 available) meta 1.16.0 (1.17.0 available) test_api 0.7.6 (0.7.7 available) Changed 11 dependencies! 4 packages have newer versions incompatible with dependency constraints. Try `flutter pub outdated` for more information.
لإجراء المصادقة على أجهزة Windows وmacOS وLinux، استخدِم حزمة googleapis_auth
. تتم المصادقة على هذه المنصات المخصصة لأجهزة الكمبيوتر باستخدام متصفّح ويب. للمصادقة على Android وiOS والويب، استخدِم الحزمتَين google_sign_in
وextension_google_sign_in_as_googleapis_auth
. تعمل الحزمة الثانية كطبقة توافق بين الحزمتين.
تعديل الرمز
ابدأ التحديث بإنشاء تجريد جديد قابل لإعادة الاستخدام، وهو الأداة AdaptiveLogin. تم تصميم هذا التطبيق المصغّر لتتمكّن من إعادة استخدامه، وبالتالي يتطلّب بعض الإعدادات:
lib/src/adaptive_login.dart
import 'dart:async';
import 'dart:io' show Platform;
import 'package:extension_google_sign_in_as_googleapis_auth/extension_google_sign_in_as_googleapis_auth.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:googleapis_auth/auth_io.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/link.dart';
import 'app_state.dart';
final _log = Logger('AdaptiveLogin');
typedef _AdaptiveLoginButtonWidget =
Widget Function({required VoidCallback? onPressed});
class AdaptiveLogin extends StatelessWidget {
const AdaptiveLogin({
super.key,
required this.clientId,
required this.scopes,
required this.loginButtonChild,
});
final ClientId clientId;
final List<String> scopes;
final Widget loginButtonChild;
@override
Widget build(BuildContext context) {
if (kIsWeb || Platform.isAndroid || Platform.isIOS) {
return _GoogleSignInLogin(button: _loginButton, scopes: scopes);
} else {
return _GoogleApisAuthLogin(
button: _loginButton,
scopes: scopes,
clientId: clientId,
);
}
}
Widget _loginButton({required VoidCallback? onPressed}) =>
ElevatedButton(onPressed: onPressed, child: loginButtonChild);
}
class _GoogleSignInLogin extends StatefulWidget {
const _GoogleSignInLogin({required this.button, required this.scopes});
final _AdaptiveLoginButtonWidget button;
final List<String> scopes;
@override
State<_GoogleSignInLogin> createState() => _GoogleSignInLoginState();
}
class _GoogleSignInLoginState extends State<_GoogleSignInLogin> {
@override
initState() {
super.initState();
_googleSignIn = GoogleSignIn.instance;
_googleSignIn.initialize();
_authEventsSubscription = _googleSignIn.authenticationEvents.listen((
event,
) async {
_log.fine('Google Sign-In authentication event: $event');
if (event is GoogleSignInAuthenticationEventSignIn) {
final googleSignInClientAuthorization = await event
.user
.authorizationClient
.authorizationForScopes(widget.scopes);
if (googleSignInClientAuthorization == null) {
_log.warning('Google Sign-In authenticated client creation failed');
return;
}
_log.fine('Google Sign-In authenticated client created');
final context = this.context;
if (context.mounted) {
context.read<AuthedUserPlaylists>().authClient =
googleSignInClientAuthorization.authClient(scopes: widget.scopes);
context.go('/');
}
}
});
// Check if user is already authenticated
_log.fine('Attempting lightweight authentication');
_googleSignIn.attemptLightweightAuthentication();
}
@override
dispose() {
_authEventsSubscription.cancel();
super.dispose();
}
late final GoogleSignIn _googleSignIn;
late final StreamSubscription _authEventsSubscription;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: widget.button(
onPressed: () {
_googleSignIn.authenticate();
},
),
),
);
}
}
class _GoogleApisAuthLogin extends StatefulWidget {
const _GoogleApisAuthLogin({
required this.button,
required this.scopes,
required this.clientId,
});
final _AdaptiveLoginButtonWidget button;
final List<String> scopes;
final ClientId clientId;
@override
State<_GoogleApisAuthLogin> createState() => _GoogleApisAuthLoginState();
}
class _GoogleApisAuthLoginState extends State<_GoogleApisAuthLogin> {
@override
initState() {
super.initState();
clientViaUserConsent(widget.clientId, widget.scopes, (url) {
setState(() {
_authUrl = Uri.parse(url);
});
}).then((authClient) {
final context = this.context;
if (context.mounted) {
context.read<AuthedUserPlaylists>().authClient = authClient;
context.go('/');
}
});
}
Uri? _authUrl;
@override
Widget build(BuildContext context) {
final authUrl = _authUrl;
if (authUrl != null) {
return Scaffold(
body: Center(
child: Link(
uri: authUrl,
builder: (context, followLink) =>
widget.button(onPressed: followLink),
),
),
);
}
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
}
هذا الملف يضم الكثير من البيانات. تتولّى الطريقة build
في AdaptiveLogin
المهام الصعبة. تتطلّب هذه الطريقة استدعاء Platform.isXXX
لكلّ من kIsWeb
وdart:io
، وتتحقّق من منصة وقت التشغيل. بالنسبة إلى Android وiOS والويب، يتم إنشاء _GoogleSignInLogin
stateful widget. بالنسبة إلى أنظمة التشغيل Windows وmacOS وLinux، يتم إنشاء _GoogleApisAuthLogin
أداة ذات حالة.
يجب إجراء إعدادات إضافية لاستخدام هذه الفئات، وسيتم ذلك لاحقًا بعد تعديل بقية قاعدة الرموز البرمجية لاستخدام هذا التطبيق المصغّر الجديد. ابدأ بإعادة تسمية FlutterDevPlaylists
إلى AuthedUserPlaylists
لتعكس بشكل أفضل الغرض الجديد منها، وعدِّل الرمز ليعكس أنّه يتم الآن تمرير http.Client
بعد الإنشاء. أخيرًا، لم تعُد الفئة _ApiKeyClient
مطلوبة:
lib/src/app_state.dart
import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:http/http.dart' as http;
class AuthedUserPlaylists extends ChangeNotifier { // Rename class
set authClient(http.Client client) { // Drop constructor, add setter
_api = YouTubeApi(client);
_loadPlaylists();
}
bool get isLoggedIn => _api != null; // Add property
Future<void> _loadPlaylists() async {
String? nextPageToken;
_playlists.clear();
do {
final response = await _api!.playlists.list( // Add ! to _api
['snippet', 'contentDetails', 'id'],
mine: true, // convert from channelId: to mine:
maxResults: 50,
pageToken: nextPageToken,
);
_playlists.addAll(response.items!);
_playlists.sort(
(a, b) => a.snippet!.title!.toLowerCase().compareTo(
b.snippet!.title!.toLowerCase(),
),
);
notifyListeners();
nextPageToken = response.nextPageToken;
} while (nextPageToken != null);
}
YouTubeApi? _api; // Convert to optional
final List<Playlist> _playlists = [];
List<Playlist> get playlists => UnmodifiableListView(_playlists);
final Map<String, List<PlaylistItem>> _playlistItems = {};
List<PlaylistItem> playlistItems({required String playlistId}) {
if (!_playlistItems.containsKey(playlistId)) {
_playlistItems[playlistId] = [];
_retrievePlaylist(playlistId);
}
return UnmodifiableListView(_playlistItems[playlistId]!);
}
Future<void> _retrievePlaylist(String playlistId) async {
String? nextPageToken;
do {
var response = await _api!.playlistItems.list( // Add ! to _api
['snippet', 'contentDetails'],
playlistId: playlistId,
maxResults: 25,
pageToken: nextPageToken,
);
var items = response.items;
if (items != null) {
_playlistItems[playlistId]!.addAll(items);
}
notifyListeners();
nextPageToken = response.nextPageToken;
} while (nextPageToken != null);
}
}
// Delete the now unused _ApiKeyClient class
بعد ذلك، عدِّل أداة PlaylistDetails
باستخدام الاسم الجديد لكائن حالة التطبيق المقدَّم:
lib/src/playlist_details.dart
class PlaylistDetails extends StatelessWidget {
const PlaylistDetails({
required this.playlistId,
required this.playlistName,
super.key,
});
final String playlistId;
final String playlistName;
@override
Widget build(BuildContext context) {
return Consumer<AuthedUserPlaylists>( // Update this line
builder: (context, playlists, _) {
final playlistItems = playlists.playlistItems(playlistId: playlistId);
if (playlistItems.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
return _PlaylistDetailsListView(playlistItems: playlistItems);
},
);
}
}
وبالمثل، عدِّل أداة Playlists
باتّباع الخطوات التالية:
lib/src/playlists.dart
class Playlists extends StatelessWidget {
const Playlists({super.key, required this.playlistSelected});
final PlaylistsListSelected playlistSelected;
@override
Widget build(BuildContext context) {
return Consumer<AuthedUserPlaylists>( // Update this line
builder: (context, flutterDev, child) {
final playlists = flutterDev.playlists;
if (playlists.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
return _PlaylistsListView(
items: playlists,
playlistSelected: playlistSelected,
);
},
);
}
}
أخيرًا، عدِّل ملف main.dart
لاستخدام أداة AdaptiveLogin
الجديدة بشكل صحيح:
lib/main.dart
// Drop dart:io import
import 'package:flex_color_scheme/flex_color_scheme.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:googleapis_auth/googleapis_auth.dart'; // Add this line
import 'package:provider/provider.dart';
import 'src/adaptive_login.dart'; // Add this line
import 'src/adaptive_playlists.dart';
import 'src/app_state.dart';
import 'src/playlist_details.dart';
// Drop flutterDevAccountId and youTubeApiKey
// Add from this line
// From https://developers.google.com/youtube/v3/guides/auth/installed-apps#identify-access-scopes
final scopes = ['https://www.googleapis.com/auth/youtube.readonly'];
// TODO: Replace with your Client ID and Client Secret for Desktop configuration
final clientId = ClientId(
'TODO-Client-ID.apps.googleusercontent.com',
'TODO-Client-secret',
);
// To this line
final _router = GoRouter(
routes: <RouteBase>[
GoRoute(
path: '/',
builder: (context, state) {
return const AdaptivePlaylists();
},
// Add redirect configuration
redirect: (context, state) {
if (!context.read<AuthedUserPlaylists>().isLoggedIn) {
return '/login';
} else {
return null;
}
},
// To this line
routes: <RouteBase>[
// Add new login Route
GoRoute(
path: 'login',
builder: (context, state) {
return AdaptiveLogin(
clientId: clientId,
scopes: scopes,
loginButtonChild: const Text('Login to YouTube'),
);
},
),
// To this line
GoRoute(
path: 'playlist/:id',
builder: (context, state) {
final title = state.uri.queryParameters['title']!;
final id = state.pathParameters['id']!;
return Scaffold(
appBar: AppBar(title: Text(title)),
body: PlaylistDetails(playlistId: id, playlistName: title),
);
},
),
],
),
],
);
void main() {
runApp(
ChangeNotifierProvider<AuthedUserPlaylists>( // Modify this line
create: (context) => AuthedUserPlaylists(), // Modify this line
child: const PlaylistsApp(),
),
);
}
class PlaylistsApp extends StatelessWidget {
const PlaylistsApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'Your Playlists', // Change FlutterDev to Your
theme: FlexColorScheme.light(scheme: FlexScheme.red).toTheme,
darkTheme: FlexColorScheme.dark(scheme: FlexScheme.red).toTheme,
themeMode: ThemeMode.dark, // Or ThemeMode.System
debugShowCheckedModeBanner: false,
routerConfig: _router,
);
}
}
تعكس التغييرات في هذا الملف التغيير من عرض قوائم تشغيل YouTube الخاصة بـ Flutter فقط إلى عرض قوائم تشغيل المستخدم الذي تمّت مصادقته. على الرغم من أنّ الرمز البرمجي قد اكتمل الآن، لا يزال هناك سلسلة من التعديلات المطلوبة على هذا الملف والملفات ضِمن تطبيقات Runner المعنية لإعداد حزمتَي google_sign_in
وgoogleapis_auth
بشكل صحيح للمصادقة.
يعرض التطبيق الآن قوائم تشغيل YouTube الخاصة بالمستخدم الذي تمّت المصادقة عليه. بعد إكمال الميزات، عليك تفعيل المصادقة. لإجراء ذلك، اضبط حزمتَي google_sign_in
وgoogleapis_auth
. لضبط الحِزم، عليك تغيير الملف main.dart
وملفات تطبيقات Runner.
ضبط googleapis_auth
تتمثّل الخطوة الأولى في إعداد المصادقة في إزالة مفتاح واجهة برمجة التطبيقات الذي سبق لك إعداده واستخدامه. انتقِل إلى صفحة بيانات الاعتماد الخاصة بمشروع واجهة برمجة التطبيقات، واحذف مفتاح واجهة برمجة التطبيقات:
سيؤدي ذلك إلى إنشاء مربّع حوار تقرّ فيه بالحذف من خلال النقر على الزر "حذف":
بعد ذلك، أنشئ معرّف عميل OAuth:
بالنسبة إلى "نوع التطبيق"، اختَر "تطبيق على الكمبيوتر".
اقبل الاسم وانقر على إنشاء.
يؤدي ذلك إلى إنشاء معرّف العميل وسرّ العميل اللذين يجب إضافتهما إلى lib/main.dart
لإعداد مسار googleapis_auth
. من التفاصيل المهمة في عملية التنفيذ أنّ مسار googleapis_auth يستخدم خادم ويب مؤقتًا يعمل على المضيف المحلي لالتقاط رمز OAuth المميز الذي تم إنشاؤه، وهو ما يتطلّب تعديل الملف macos/Runner/Release.entitlements
على نظام التشغيل macOS:
macos/Runner/Release.entitlements
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<!-- add the following two lines -->
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>
لست بحاجة إلى إجراء هذا التعديل على ملف macos/Runner/DebugProfile.entitlements
لأنّه يتضمّن بالفعل إذنًا لـ com.apple.security.network.server
من أجل تفعيل ميزة "إعادة التحميل السريع" وأدوات تصحيح الأخطاء في "الجهاز الظاهري لـ Dart".
من المفترض أن تتمكّن الآن من تشغيل تطبيقك على Windows أو macOS أو Linux (إذا تم تجميع التطبيق على هذه الأنظمة).
ضبط google_sign_in
على Android
ارجع إلى صفحة بيانات الاعتماد الخاصة بمشروع واجهة برمجة التطبيقات، وأنشئ معرّف عميل OAuth آخر، ولكن اختَر Android: هذه المرة.
بالنسبة إلى بقية النموذج، املأ حقل "اسم الحزمة" بالحزمة المحدّدة في android/app/src/main/AndroidManifest.xml
. إذا اتّبعت التعليمات بدقة، يجب أن تكون النتيجة com.example.adaptive_app
. استخرِج الملف المرجعي لشهادة SHA-1 باتّباع التعليمات الواردة في صفحة مساعدة Google Cloud Console:
وهذا يكفي لتشغيل التطبيق على Android. بناءً على اختيارك لواجهات Google APIs التي تستخدمها، قد تحتاج إلى إضافة ملف JSON الذي تم إنشاؤه إلى حِزمة تطبيقك.
ضبط google_sign_in
على أجهزة iOS
ارجع إلى صفحة بيانات الاعتماد الخاصة بمشروع واجهة برمجة التطبيقات، وأنشئ معرّف عميل OAuth آخر، ولكن اختَر iOS هذه المرة.
بالنسبة إلى بقية النموذج، املأ رقم تعريف الحزمة من خلال فتح ios/Runner.xcworkspace
في Xcode. انتقِل إلى "مستكشف المشروع" (Project Navigator)، واختَر Runner في المستكشف، ثم انقر على علامة التبويب "عام" (General)، وانسخ معرّف الحزمة (Bundle Identifier). إذا اتّبعت خطوات هذا الدرس التعليمي البرمجي بالتفصيل، من المفترض أن تكون القيمة com.example.adaptiveApp
.
بالنسبة إلى بقية النموذج، املأ حقل "معرّف الحزمة". افتح ios/Runner.xcworkspace
في Xcode. انتقِل إلى "مستكشف المشروع". انتقِل إلى Runner > علامة التبويب "عام". انسخ معرّف الحزمة. إذا اتّبعت هذا الدرس التطبيقي خطوة بخطوة، من المفترض أن تكون القيمة com.example.adaptiveApp
.
تجاهَل رقم تعريف App Store ورقم تعريف الفريق في الوقت الحالي، لأنّهما غير مطلوبَين للتطوير المحلي:
نزِّل ملف .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 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. الخطوات التالية
تهانينا!
لقد أكملت تجربة البرمجة وأنشأت تطبيق Flutter متكيّفًا يعمل على جميع الأنظمة الأساسية الستة التي يتيحها Flutter. لقد عدّلت الرمز البرمجي للتعامل مع الاختلافات في طريقة عرض الشاشات، وطريقة التفاعل مع النصوص، وطريقة تحميل الصور، وطريقة عمل المصادقة.
هناك العديد من العناصر الأخرى التي يمكنك تعديلها في تطبيقاتك. للتعرّف على طرق إضافية لتكييف الرمز البرمجي مع البيئات المختلفة التي سيتم تشغيله فيها، اطّلِع على إنشاء تطبيقات متكيّفة.