1. מבוא
Flutter היא ערכת הכלים של Google לבניית ממשק משתמש, שמאפשרת ליצור אפליקציות יפות ומתורגמות באופן מקורי לנייד, לאינטרנט ולמחשבים ממקור קוד יחיד. בסדנת הקוד הזו תלמדו איך ליצור אפליקציית Flutter שתתאים לפלטפורמה שבה היא פועלת, בין אם מדובר ב-Android, ב-iOS, באינטרנט, ב-Windows, ב-macOS או ב-Linux.
מה תלמדו
- איך להרחיב אפליקציית Flutter שמיועדת לנייד כך שתפעול בכל שש הפלטפורמות הנתמכות על ידי Flutter.
- ממשקי ה-API השונים של Flutter לזיהוי פלטפורמות ומתי משתמשים בכל API.
- התאמה להגבלות ולציפיות של הפעלת אפליקציה באינטרנט.
- איך משתמשים בחבילות שונות זו לצד זו כדי לתמוך במגוון המלא של הפלטפורמות של Flutter.
מה תפַתחו
בסדנת הקוד הזו, נתחיל בפיתוח אפליקציית Flutter ל-Android ול-iOS שמציגה את הפלייליסטים של YouTube ב-Flutter. לאחר מכן, תצטרכו להתאים את האפליקציה כך שתפעול בשלוש הפלטפורמות למחשב (Windows, macOS ו-Linux). לשם כך, תצטרכו לשנות את אופן הצגת המידע בהתאם לגודל חלון האפליקציה. לאחר מכן, תצטרכו להתאים את האפליקציה לאינטרנט על ידי הפיכת הטקסט שמוצג באפליקציה לניתן לבחירה, כפי שמשתמשי אינטרנט מצפים. לבסוף, תוסיפו לאפליקציה אימות כדי שתוכלו לעיין בפלייליסטים שלכם, בניגוד לאלה שנוצרו על ידי צוות Flutter. לשם כך, נדרשות גישות שונות לאימות ל-Android, ל-iOS ולאינטרנט, לעומת שלוש פלטפורמות המחשב – Windows, macOS ו-Linux.
זהו צילום מסך של אפליקציית Flutter ב-Android וב-iOS:
האפליקציה הזו שפועלת במסך רחב ב-macOS אמורה להיראות כמו בצילום המסך הבא.
בקודלאב הזה נסביר איך להפוך אפליקציית Flutter לנייד לאפליקציה גמישה שפועלת בכל שש פלטפורמות Flutter. מושגים וקטעי קוד לא רלוונטיים מוצגים בקצרה, וניתן להעתיק ולהדביק אותם.
מה היית רוצה ללמוד מהקודלאב הזה?
2. הגדרת סביבת הפיתוח ב-Flutter
כדי להשלים את שיעור ה-Lab הזה, תצטרכו שני תוכנות – Flutter SDK ועורך.
אפשר להריץ את הקודלאב בכל אחד מהמכשירים הבאים:
- מכשיר Android או iOS פיזי שמחובר למחשב ומוגדר למצב פיתוח.
- סימולטור iOS (נדרשת התקנה של כלי Xcode).
- Android Emulator (נדרשת הגדרה ב-Android Studio).
- דפדפן (נדרש דפדפן Chrome לניפוי באגים).
- כאפליקציית מחשב ל-Windows, ל-Linux או ל-macOS. עליכם לפתח בפלטפורמה שבה אתם מתכננים לפרוס. לכן, אם רוצים לפתח אפליקציה למחשב עם Windows, צריך לפתח ב-Windows כדי לגשת לרשת ה-build המתאימה. יש דרישות ספציפיות למערכות הפעלה שפורטו באתר 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
באמצעות הקוד הבא. כדי לשנות את התוכן שמוצג באפליקציה, מבצעים טעינה מחדש בזמן ריצה (hot reload).
- אם מריצים את האפליקציה באמצעות שורת הפקודה, מקלידים
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. לעומת זאת, ב-iPhone 12 היחס הוא 3, וב-Pixel 2 הוא 2.63. בכל המקרים, הטקסט המוצג דומה למדי, כך שהעבודה שלנו כמפתחים קלה הרבה יותר.
הנקודה השנייה שחשוב לשים לב אליה היא ששתי האפשרויות לבדוק באיזו פלטפורמה פועל הקוד מניבות ערכים שונים. האפשרות הראשונה בודקת את האובייקט 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 + flex_color_scheme 8.2.0 + flex_seed_scheme 3.5.1 + flutter_web_plugins 0.0.0 from sdk flutter + go_router 15.1.2 + googleapis 14.0.0 + http 1.4.0 + http_parser 4.1.2 leak_tracker 10.0.9 (11.0.1 available) leak_tracker_flutter_testing 3.0.9 (3.0.10 available) leak_tracker_testing 3.0.1 (3.0.2 available) lints 5.1.1 (6.0.0 available) + logging 1.3.0 material_color_utilities 0.11.1 (0.12.0 available) meta 1.16.0 (1.17.0 available) + nested 1.0.0 + plugin_platform_interface 2.1.8 + provider 6.1.5 test_api 0.7.4 (0.7.6 available) + typed_data 1.4.0 + url_launcher 6.3.1 + url_launcher_android 6.3.16 + url_launcher_ios 6.3.3 + url_launcher_linux 3.2.1 + url_launcher_macos 3.2.2 + url_launcher_platform_interface 2.3.2 + url_launcher_web 2.4.1 + url_launcher_windows 3.1.4 vector_math 2.1.4 (2.1.5 available) + web 1.1.1 Changed 22 dependencies! 8 packages have newer versions incompatible with dependency constraints. Try `flutter pub outdated` for more information.
הפקודה הזו מוסיפה מספר חבילות לאפליקציה:
googleapis
: ספריית Dart שנוצרה ומספקת גישה ל-Google APIs.http
: ספרייה ליצירת בקשות HTTP שמסתירה את ההבדלים בין דפדפנים מקומיים לדפדפני אינטרנט.provider
: ניהול המצב.url_launcher
: מאפשרת לעבור לסרטון מפלייליסט. כפי שמוצג ביחסי התלות שהוגדרו, ל-url_launcher
יש הטמעות ל-Windows, ל-macOS, ל-Linux ולאינטרנט, בנוסף להטמעות שמוגדרות כברירת מחדל ל-Android ול-iOS. השימוש בחבילה הזו מאפשר לכם לא ליצור פונקציונליות ספציפית לפלטפורמה.flex_color_scheme
: נותן לאפליקציה ערכת צבעים נעימה כברירת מחדל. מידע נוסף זמין במסמכי העזרה שלflex_color_scheme
API.go_router
: הטמעת ניווט בין המסכים השונים. החבילה הזו מספקת ממשק API נוח שמבוסס על כתובת URL לניווט באמצעות ה-Router של Flutter.
הגדרת האפליקציות לנייד עבור url_launcher
כדי להשתמש בפלאגין url_launcher
, צריך להגדיר את אפליקציות ה-Runner ל-Android ול-iOS. ב-iOS Flutter Runner, מוסיפים את השורות הבאות למילון plist
.
ios/Runner/Info.plist
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
<string>http</string>
<string>tel</string>
<string>mailto</string>
</array>
ב-Android Flutter runner, מוסיפים את השורות הבאות ל-Manifest.xml
. מוסיפים את הצומת queries
כצאצא ישיר של הצומת manifest
וכצינור מקביל לצומת application
.
android/app/src/main/AndroidManifest.xml
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
<intent>
<action android:name="android.intent.action.DIAL" />
<data android:scheme="tel" />
</intent>
<intent>
<action android:name="android.intent.action.SEND" />
<data android:mimeType="*/*" />
</intent>
</queries>
פרטים נוספים על שינויי התצורה הנדרשים מופיעים במסמכי העזרה של url_launcher
.
גישה ל-YouTube Data API
כדי לגשת ל-YouTube Data API ולקבל רשימה של פלייליסטים, צריך ליצור פרויקט API כדי ליצור את מפתחות ה-API הנדרשים. בשלבים הבאים אנחנו יוצאים מנקודת הנחה שכבר יש לכם חשבון Google, לכן אם עדיין אין לכם חשבון כזה, עליכם ליצור אותו.
עוברים אל Developer Console כדי ליצור פרויקט API:
אחרי שיוצרים פרויקט, עוברים אל הדף API Library. בתיבה לחיפוש, מזינים 'youtube' ובוחרים ב-youtube data api v3.
בדף הפרטים של YouTube Data API גרסה 3, מפעילים את ה-API.
אחרי שמפעילים את ה-API, עוברים אל דף פרטי הכניסה ויוצרים מפתח API.
אחרי כמה שניות, אמורה להופיע תיבת דו-שיח עם מפתח ה-API החדש והמבריק. בקרוב תצטרכו להשתמש במפתח הזה.
הוספת קוד
בשארית השלב הזה תצטרכו לחתוך ולהדביק הרבה קוד כדי ליצור אפליקציה לנייד, בלי תגובות על הקוד. מטרת הקודלאב הזה היא להפוך את האפליקציה לנייד למתאימה גם למחשבים וגם לאינטרנט. מבוא מפורט יותר לפיתוח אפליקציות לנייד ב-Flutter זמין במאמר האפליקציה הראשונה שלכם ב-Flutter.
מוסיפים את הקבצים הבאים, קודם את אובייקט המצב של האפליקציה.
lib/src/app_state.dart
import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:http/http.dart' as http;
class FlutterDevPlaylists extends ChangeNotifier {
FlutterDevPlaylists({
required String flutterDevAccountId,
required String youTubeApiKey,
}) : _flutterDevAccountId = flutterDevAccountId {
_api = YouTubeApi(_ApiKeyClient(client: http.Client(), key: youTubeApiKey));
_loadPlaylists();
}
Future<void> _loadPlaylists() async {
String? nextPageToken;
_playlists.clear();
do {
final response = await _api.playlists.list(
['snippet', 'contentDetails', 'id'],
channelId: _flutterDevAccountId,
maxResults: 50,
pageToken: nextPageToken,
);
_playlists.addAll(response.items!);
_playlists.sort(
(a, b) => a.snippet!.title!.toLowerCase().compareTo(
b.snippet!.title!.toLowerCase(),
),
);
notifyListeners();
nextPageToken = response.nextPageToken;
} while (nextPageToken != null);
}
final String _flutterDevAccountId;
late final YouTubeApi _api;
final List<Playlist> _playlists = [];
List<Playlist> get playlists => UnmodifiableListView(_playlists);
final Map<String, List<PlaylistItem>> _playlistItems = {};
List<PlaylistItem> playlistItems({required String playlistId}) {
if (!_playlistItems.containsKey(playlistId)) {
_playlistItems[playlistId] = [];
_retrievePlaylist(playlistId);
}
return UnmodifiableListView(_playlistItems[playlistId]!);
}
Future<void> _retrievePlaylist(String playlistId) async {
String? nextPageToken;
do {
var response = await _api.playlistItems.list(
['snippet', 'contentDetails'],
playlistId: playlistId,
maxResults: 25,
pageToken: nextPageToken,
);
var items = response.items;
if (items != null) {
_playlistItems[playlistId]!.addAll(items);
}
notifyListeners();
nextPageToken = response.nextPageToken;
} while (nextPageToken != null);
}
}
class _ApiKeyClient extends http.BaseClient {
_ApiKeyClient({required this.key, required this.client});
final String key;
final http.Client client;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) {
final url = request.url.replace(
queryParameters: <String, List<String>>{
...request.url.queryParametersAll,
'key': [key],
},
);
return client.send(http.Request(request.method, url));
}
}
בשלב הבא מוסיפים את דף הפרטים של הפלייליסט הספציפי.
lib/src/playlist_details.dart
import 'package:flutter/material.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/link.dart';
import 'app_state.dart';
class PlaylistDetails extends StatelessWidget {
const PlaylistDetails({
required this.playlistId,
required this.playlistName,
super.key,
});
final String playlistId;
final String playlistName;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(playlistName)),
body: Consumer<FlutterDevPlaylists>(
builder: (context, playlists, _) {
final playlistItems = playlists.playlistItems(playlistId: playlistId);
if (playlistItems.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
return _PlaylistDetailsListView(playlistItems: playlistItems);
},
),
);
}
}
class _PlaylistDetailsListView extends StatelessWidget {
const _PlaylistDetailsListView({required this.playlistItems});
final List<PlaylistItem> playlistItems;
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: playlistItems.length,
itemBuilder: (context, index) {
final playlistItem = playlistItems[index];
return Padding(
padding: const EdgeInsets.all(8.0),
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Stack(
alignment: Alignment.center,
children: [
if (playlistItem.snippet!.thumbnails!.high != null)
Image.network(playlistItem.snippet!.thumbnails!.high!.url!),
_buildGradient(context),
_buildTitleAndSubtitle(context, playlistItem),
_buildPlayButton(context, playlistItem),
],
),
),
);
},
);
}
Widget _buildGradient(BuildContext context) {
return Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.transparent, Theme.of(context).colorScheme.surface],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: const [0.5, 0.95],
),
),
),
);
}
Widget _buildTitleAndSubtitle(
BuildContext context,
PlaylistItem playlistItem,
) {
return Positioned(
left: 20,
right: 0,
bottom: 20,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
playlistItem.snippet!.title!,
style: Theme.of(context).textTheme.bodyLarge!.copyWith(
fontSize: 18,
// fontWeight: FontWeight.bold,
),
),
if (playlistItem.snippet!.videoOwnerChannelTitle != null)
Text(
playlistItem.snippet!.videoOwnerChannelTitle!,
style: Theme.of(
context,
).textTheme.bodyMedium!.copyWith(fontSize: 12),
),
],
),
);
}
Widget _buildPlayButton(BuildContext context, PlaylistItem playlistItem) {
return Stack(
alignment: AlignmentDirectional.center,
children: [
Container(
width: 42,
height: 42,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(Radius.circular(21)),
),
),
Link(
uri: Uri.parse(
'https://www.youtube.com/watch?v=${playlistItem.snippet!.resourceId!.videoId}',
),
builder: (context, followLink) => IconButton(
onPressed: followLink,
color: Colors.red,
icon: const Icon(Icons.play_circle_fill),
iconSize: 45,
),
),
],
);
}
}
בשלב הבא מוסיפים את רשימת הפלייליסטים.
lib/src/playlists.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:provider/provider.dart';
import 'app_state.dart';
class Playlists extends StatelessWidget {
const Playlists({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('FlutterDev Playlists')),
body: Consumer<FlutterDevPlaylists>(
builder: (context, flutterDev, child) {
final playlists = flutterDev.playlists;
if (playlists.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
return _PlaylistsListView(items: playlists);
},
),
);
}
}
class _PlaylistsListView extends StatelessWidget {
const _PlaylistsListView({required this.items});
final List<Playlist> items;
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
var playlist = items[index];
return Padding(
padding: const EdgeInsets.all(8.0),
child: ListTile(
leading: Image.network(
playlist.snippet!.thumbnails!.default_!.url!,
),
title: Text(playlist.snippet!.title!),
subtitle: Text(playlist.snippet!.description!),
onTap: () {
context.go(
Uri(
path: '/playlist/${playlist.id}',
queryParameters: <String, String>{
'title': playlist.snippet!.title!,
},
).toString(),
);
},
),
);
},
);
}
}
מחליפים את התוכן של הקובץ main.dart
באופן הבא:
lib/main.dart
import 'dart:io';
import 'package:flex_color_scheme/flex_color_scheme.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'src/app_state.dart';
import 'src/playlist_details.dart';
import 'src/playlists.dart';
// From https://www.youtube.com/channel/UCwXdFgeE9KYzlDdR7TG9cMw
const flutterDevAccountId = 'UCwXdFgeE9KYzlDdR7TG9cMw';
// TODO: Replace with your YouTube API Key
const youTubeApiKey = 'AIzaNotAnApiKey';
final _router = GoRouter(
routes: <RouteBase>[
GoRoute(
path: '/',
builder: (context, state) {
return const Playlists();
},
routes: <RouteBase>[
GoRoute(
path: 'playlist/:id',
builder: (context, state) {
final title = state.uri.queryParameters['title']!;
final id = state.pathParameters['id']!;
return PlaylistDetails(playlistId: id, playlistName: title);
},
),
],
),
],
);
void main() {
if (youTubeApiKey == 'AIzaNotAnApiKey') {
print('youTubeApiKey has not been configured.');
exit(1);
}
runApp(
ChangeNotifierProvider<FlutterDevPlaylists>(
create: (context) => FlutterDevPlaylists(
flutterDevAccountId: flutterDevAccountId,
youTubeApiKey: youTubeApiKey,
),
child: const PlaylistsApp(),
),
);
}
class PlaylistsApp extends StatelessWidget {
const PlaylistsApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'FlutterDev Playlists',
theme: FlexColorScheme.light(scheme: FlexScheme.red).toTheme,
darkTheme: FlexColorScheme.dark(scheme: FlexScheme.red).toTheme,
themeMode: ThemeMode.dark, // Or ThemeMode.System if you'd prefer
debugShowCheckedModeBanner: false,
routerConfig: _router,
);
}
}
כמעט סיימתם להריץ את הקוד הזה ב-Android וב-iOS. צריך לשנות עוד דבר אחד: משנים את הקבוע youTubeApiKey
במפתח ה-API של 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... Downloading packages... leak_tracker 10.0.9 (11.0.1 available) leak_tracker_flutter_testing 3.0.9 (3.0.10 available) leak_tracker_testing 3.0.1 (3.0.2 available) lints 5.1.1 (6.0.0 available) material_color_utilities 0.11.1 (0.12.0 available) meta 1.16.0 (1.17.0 available) + split_view 3.2.1 test_api 0.7.4 (0.7.6 available) vector_math 2.1.4 (2.1.5 available) Changed 1 dependency! 8 packages have newer versions incompatible with dependency constraints. Try `flutter pub outdated` for more information.
ווידג'טים דינמיים
התבנית שבה נשתמש בקודלאב הזה היא להציג ווידג'טים מותאמים שמבצעים בחירות הטמעה על סמך מאפיינים כמו רוחב המסך, עיצוב הפלטפורמה וכו'. במקרה כזה, תצטרכו להוסיף ווידג'ט AdaptivePlaylists
שישנה את האופן שבו Playlists
ו-PlaylistDetails
מקיימים אינטראקציה. עורכים את הקובץ lib/main.dart
באופן הבא:
lib/main.dart
import 'dart:io';
import 'package:flex_color_scheme/flex_color_scheme.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'src/adaptive_playlists.dart'; // Add this import
import 'src/app_state.dart';
import 'src/playlist_details.dart';
// Remove the src/playlists.dart import
// From https://www.youtube.com/channel/UCwXdFgeE9KYzlDdR7TG9cMw
const flutterDevAccountId = 'UCwXdFgeE9KYzlDdR7TG9cMw';
// TODO: Replace with your YouTube API Key
const youTubeApiKey = 'AIzaNotAnApiKey';
final _router = GoRouter(
routes: <RouteBase>[
GoRoute(
path: '/',
builder: (context, state) {
return const AdaptivePlaylists(); // Modify this line
},
routes: <RouteBase>[
GoRoute(
path: 'playlist/:id',
builder: (context, state) {
final title = state.uri.queryParameters['title']!;
final id = state.pathParameters['id']!;
return Scaffold( // Modify from here
appBar: AppBar(title: Text(title)),
body: PlaylistDetails(playlistId: id, playlistName: title),
); // To here.
},
),
],
),
],
);
void main() {
if (youTubeApiKey == 'AIzaNotAnApiKey') {
print('youTubeApiKey has not been configured.');
exit(1);
}
runApp(
ChangeNotifierProvider<FlutterDevPlaylists>(
create: (context) => FlutterDevPlaylists(
flutterDevAccountId: flutterDevAccountId,
youTubeApiKey: youTubeApiKey,
),
child: const PlaylistsApp(),
),
);
}
class PlaylistsApp extends StatelessWidget {
const PlaylistsApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'FlutterDev Playlists',
theme: FlexColorScheme.light(scheme: FlexScheme.red).toTheme,
darkTheme: FlexColorScheme.dark(scheme: FlexScheme.red).toTheme,
themeMode: ThemeMode.dark, // Or ThemeMode.System if you'd prefer
debugShowCheckedModeBanner: false,
routerConfig: _router,
);
}
}
בשלב הבא יוצרים את הקובץ של הווידג'ט AdaptivePlaylist:
lib/src/adaptive_playlists.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:split_view/split_view.dart';
import 'playlist_details.dart';
import 'playlists.dart';
class AdaptivePlaylists extends StatelessWidget {
const AdaptivePlaylists({super.key});
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final targetPlatform = Theme.of(context).platform;
if (targetPlatform == TargetPlatform.android ||
targetPlatform == TargetPlatform.iOS ||
screenWidth <= 600) {
return const NarrowDisplayPlaylists();
} else {
return const WideDisplayPlaylists();
}
}
}
class NarrowDisplayPlaylists extends StatelessWidget {
const NarrowDisplayPlaylists({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('FlutterDev Playlists')),
body: Playlists(
playlistSelected: (playlist) {
context.go(
Uri(
path: '/playlist/${playlist.id}',
queryParameters: <String, String>{
'title': playlist.snippet!.title!,
},
).toString(),
);
},
),
);
}
}
class WideDisplayPlaylists extends StatefulWidget {
const WideDisplayPlaylists({super.key});
@override
State<WideDisplayPlaylists> createState() => _WideDisplayPlaylistsState();
}
class _WideDisplayPlaylistsState extends State<WideDisplayPlaylists> {
Playlist? selectedPlaylist;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: switch (selectedPlaylist?.snippet?.title) {
String title => Text('FlutterDev Playlist: $title'),
_ => const Text('FlutterDev Playlists'),
},
),
body: SplitView(
viewMode: SplitViewMode.Horizontal,
children: [
Playlists(
playlistSelected: (playlist) {
setState(() {
selectedPlaylist = playlist;
});
},
),
switch ((selectedPlaylist?.id, selectedPlaylist?.snippet?.title)) {
(String id, String title) => PlaylistDetails(
playlistId: id,
playlistName: title,
),
_ => const Center(child: Text('Select a playlist')),
},
],
),
);
}
}
הקובץ הזה מעניין מכמה סיבות. קודם כול, המערכת משתמשת גם ברוחב החלון (באמצעות MediaQuery.of(context).size.width
) וגם בבדיקה של העיצוב (באמצעות Theme.of(context).platform
) כדי להחליט אם להציג פריסה רחבה עם הווידג'ט SplitView
או תצוגה צרה בלי הווידג'ט.
שנית, הקטע הזה עוסק בטיפול הקבוע מראש בניווט. הוא מציג ארגומנט של קריאה חוזרת בווידג'ט Playlists
. קריאת החזרה (callback) הזו מעדכנת את הקוד שמקיף אותה שהמשתמש בחר פלייליסט. לאחר מכן, הקוד צריך לבצע את הפעולות הנדרשות כדי להציג את הפלייליסט הזה. כתוצאה מכך, אין צורך ב-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);
},
),
);
},
);
}
}
יש הרבה שינויים בקובץ הזה. מלבד ההוספה של קריאה חוזרת (callback) מסוג 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) ════════════════════════════════════════════════════════════════════════════════════════════════════
יצירת שרת proxy של CORS
אחת מהדרכים לטפל בבעיות של עיבוד התמונות היא להוסיף שירות אינטרנט proxy כדי להוסיף את הכותרות הנדרשות של שיתוף משאבים בין מקורות (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 כדי לנצל את שרת ה-proxy של CORS, אבל רק כשהאפליקציה פועלת בדפדפן אינטרנט.
זוג ווידג'טים מותאמים
הווידג'ט הראשון מבין השניים קובע איך האפליקציה תשתמש בשרת ה-proxy של 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
תיחסם כשהמשתמש ילחץ על הטקסט.
להפעיל את האפליקציה באינטרנט בצורה תקינה
כששרת ה-proxy של 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
. החבילה השנייה משמשת כממשק ביניים (shim) לפעולה הדדית בין שתי החבילות.
עדכון הקוד
כדי להתחיל את העדכון, יוצרים רכיב חדש לשימוש חוזר – הווידג'ט AdaptiveLogin. הווידג'ט הזה מיועד לשימוש חוזר, ולכן צריך לבצע כמה הגדרות:
lib/src/adaptive_login.dart
import 'dart:io' show Platform;
import 'package:extension_google_sign_in_as_googleapis_auth/extension_google_sign_in_as_googleapis_auth.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:googleapis_auth/auth_io.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/link.dart';
import 'app_state.dart';
typedef _AdaptiveLoginButtonWidget =
Widget Function({required VoidCallback? onPressed});
class AdaptiveLogin extends StatelessWidget {
const AdaptiveLogin({
super.key,
required this.clientId,
required this.scopes,
required this.loginButtonChild,
});
final ClientId clientId;
final List<String> scopes;
final Widget loginButtonChild;
@override
Widget build(BuildContext context) {
if (kIsWeb || Platform.isAndroid || Platform.isIOS) {
return _GoogleSignInLogin(button: _loginButton, scopes: scopes);
} else {
return _GoogleApisAuthLogin(
button: _loginButton,
scopes: scopes,
clientId: clientId,
);
}
}
Widget _loginButton({required VoidCallback? onPressed}) =>
ElevatedButton(onPressed: onPressed, child: loginButtonChild);
}
class _GoogleSignInLogin extends StatefulWidget {
const _GoogleSignInLogin({required this.button, required this.scopes});
final _AdaptiveLoginButtonWidget button;
final List<String> scopes;
@override
State<_GoogleSignInLogin> createState() => _GoogleSignInLoginState();
}
class _GoogleSignInLoginState extends State<_GoogleSignInLogin> {
@override
initState() {
super.initState();
_googleSignIn = GoogleSignIn(scopes: widget.scopes);
_googleSignIn.onCurrentUserChanged.listen((account) {
if (account != null) {
_googleSignIn.authenticatedClient().then((authClient) {
final context = this.context;
if (authClient != null && context.mounted) {
context.read<AuthedUserPlaylists>().authClient = authClient;
context.go('/');
}
});
}
});
}
late final GoogleSignIn _googleSignIn;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: widget.button(
onPressed: () {
_googleSignIn.signIn();
},
),
),
);
}
}
class _GoogleApisAuthLogin extends StatefulWidget {
const _GoogleApisAuthLogin({
required this.button,
required this.scopes,
required this.clientId,
});
final _AdaptiveLoginButtonWidget button;
final List<String> scopes;
final ClientId clientId;
@override
State<_GoogleApisAuthLogin> createState() => _GoogleApisAuthLoginState();
}
class _GoogleApisAuthLoginState extends State<_GoogleApisAuthLogin> {
@override
initState() {
super.initState();
clientViaUserConsent(widget.clientId, widget.scopes, (url) {
setState(() {
_authUrl = Uri.parse(url);
});
}).then((authClient) {
final context = this.context;
if (context.mounted) {
context.read<AuthedUserPlaylists>().authClient = authClient;
context.go('/');
}
});
}
Uri? _authUrl;
@override
Widget build(BuildContext context) {
final authUrl = _authUrl;
if (authUrl != null) {
return Scaffold(
body: Center(
child: Link(
uri: authUrl,
builder: (context, followLink) =>
widget.button(onPressed: followLink),
),
),
);
}
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
}
הקובץ הזה עושה הרבה דברים. העבודה הקשה מתבצעת על ידי השיטה build
של AdaptiveLogin
. כשמפעילים את Platform.isXXX
של kIsWeb
ושל dart:io
, השיטה הזו בודקת את פלטפורמת סביבת זמן הריצה. ב-Android, ב-iOS ובאינטרנט, הקוד יוצר מופע של הווידג'ט _GoogleSignInLogin
עם שמירת מצב. ב-Windows, ב-macOS וב-Linux, הקוד יוצר מופע של ווידג'ט עם מצב _GoogleApisAuthLogin
.
כדי להשתמש בכיתות האלה, נדרשת הגדרה נוספת שתתבצע בהמשך, אחרי העדכון של שאר קוד הבסיס כך שישתמש בווידג'ט החדש. מתחילים בשינוי השם של FlutterDevPlaylists
ל-AuthedUserPlaylists
כדי לשקף טוב יותר את המטרה החדשה שלו, ומעדכנים את הקוד כך שישקף את העובדה ש-http.Client
מועבר עכשיו אחרי ה-construction. לסיום, אין יותר צורך בכיתה _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
השלב הראשון בהגדרת האימות הוא לבטל את מפתח ה-API שהגדרתם והשתמשתם בו בעבר. עוברים אל דף פרטי הכניסה של פרויקט ה-API ומוחקים את מפתח ה-API:
תוצג תיבת דו-שיח, שתצטרכו לאשר על ידי לחיצה על לחצן המחיקה:
לאחר מכן, יוצרים מזהה לקוח OAuth:
בקטע Application type (סוג האפליקציה), בוחרים באפשרות Desktop app (אפליקציה למחשב).
מאשרים את השם ולוחצים על יצירה.
הפעולה הזו יוצרת את מזהה הלקוח ואת סוד הלקוח שצריך להוסיף ל-lib/main.dart
כדי להגדיר את התהליך googleapis_auth
. פרט חשוב לגבי ההטמעה הוא שבתהליך googleapis_auth נעשה שימוש בשרת אינטרנט זמני שפועל ב-localhost כדי לתעד את אסימון ה-OAuth שנוצר. ב-macOS, כדי לעשות זאת צריך לשנות את הקובץ macos/Runner/Release.entitlements
:
macos/Runner/Release.entitlements
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<!-- add the following two lines -->
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>
אין צורך לבצע את העריכה הזו בקובץ macos/Runner/DebugProfile.entitlements
כי כבר יש לו הרשאה ל-com.apple.security.network.server
כדי להפעיל את Hot Reload ואת הכלים לניפוי באגים של Dart VM.
עכשיו אמורה להיות לך אפשרות להריץ את האפליקציה ב-Windows, ב-macOS או ב-Linux (אם האפליקציה קומפלרה ליעדים האלה).
הגדרת google_sign_in
ל-Android
חוזרים אל דף פרטי הכניסה של פרויקט ה-API, ויוצרים מזהה לקוח OAuth נוסף, אלא שהפעם בוחרים באפשרות Android:
בשאר הטופס, ממלאים את השם של החבילה בשם החבילה שצוינה בקובץ android/app/src/main/AndroidManifest.xml
. אם פעלתם לפי ההוראות במדויק, הערך צריך להיות com.example.adaptive_app
. מחלצים את טביעת האצבע של אישור SHA-1 לפי ההוראות בדף העזרה של מסוף Google Cloud:
זה מספיק כדי שהאפליקציה תפעל ב-Android. בהתאם לבחירה של ממשקי Google API שבהם אתם משתמשים, יכול להיות שתצטרכו להוסיף את קובץ ה-JSON שנוצר לחבילת האפליקציה.
הגדרת google_sign_in
ל-iOS
חוזרים אל דף פרטי הכניסה של פרויקט ה-API ויוצרים מזהה לקוח OAuth נוסף, אלא שהפעם בוחרים באפשרות iOS:
בשאר הטופס, ממלאים את מזהה החבילה על ידי פתיחת ios/Runner.xcworkspace
ב-Xcode. עוברים אל Project Navigator, בוחרים את Runner בניווט, בוחרים בכרטיסייה General ומעתיקים את מזהה החבילה. אם פעלתם לפי השלבים במדריך הקוד הזה, הערך אמור להיות com.example.adaptiveApp
.
בשאר הטופס, ממלאים את מזהה החבילה. פותחים את ios/Runner.xcworkspace
ב-Xcode. עוברים אל Project Navigator. עוברים אל Runner > הכרטיסייה General. מעתיקים את מזהה החבילה. אם פעלתם לפי השלבים בקודלאב, הערך שלו אמור להיות com.example.adaptiveApp
.
בשלב הזה, אפשר להתעלם ממזהה App Store וממזהה הצוות, כי הם לא נדרשים לפיתוח מקומי:
מורידים את קובץ ה-.plist
שנוצר. השם שלו מבוסס על מזהה הלקוח שנוצר. משנים את שם הקובץ שהורדתם ל-GoogleService-Info.plist
וגוררים אותו לתוך עורך Xcode שפועל, לצד הקובץ Info.plist
בקטע Runner/Runner
בתפריט הניווט הימני. בתיבת הדו-שיח של האפשרויות ב-Xcode, בוחרים באפשרות Copy items (העתקת פריטים) אם צריך, באפשרות Create folder references (יצירת הפניות לתיקיות) וביעד Add to the Runner (הוספה ל-Runner).
יוצאים מ-Xcode, ובסביבת הפיתוח המשולבת (IDE) שבחרתם מוסיפים את הקוד הבא לקובץ 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
לאינטרנט
חוזרים אל דף פרטי הכניסה של פרויקט ה-API ויוצרים מזהה לקוח 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">
כדי להריץ את הדוגמה הזו צריך עזרה קטנה. צריך להריץ את שרת ה-proxy של CORS שיצרתם בשלב הקודם, ולהריץ את אפליקציית האינטרנט של Flutter ביציאה שצוינה בטופס של מספר הלקוח ב-OAuth לאפליקציית אינטרנט לפי ההוראות הבאות.
בטרמינל אחד, מריצים את שרת ה-proxy של 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 תומכת. התאמתם את הקוד כדי לטפל בהבדלים בפריסה של המסכים, באינטראקציה עם הטקסט, בחיוב התמונות ובאופן שבו מתבצע האימות.
יש עוד הרבה דברים שאפשר להתאים אישית באפליקציות. במאמר פיתוח אפליקציות עם יכולת הסתגלות מוסבר איך אפשר להתאים את הקוד לסביבות שונות שבהן הוא יפעל.