אפליקציות מותאמות ב-Flutter

‫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:

האפליקציה המוגמרת שפועלת באמולטור Android

האפליקציה המוגמרת שפועלת בסימולטור של iOS

האפליקציה הזו שפועלת במסך רחב ב-macOS אמורה להיראות כמו בצילום המסך הבא.

האפליקציה המוגמרת שפועלת ב-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:

הצגת מאפייני חלון במהנת Android

הצגת מאפייני חלון בסימולטור iOS

זהו אותו קוד שפועל באופן מקורי ב-macOS ובתוך Chrome, שוב ב-macOS.

הצגת מאפייני חלון ב-macOS

הצגת מאפייני החלון בדפדפן Chrome

חשוב לציין שלכאורה, 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:

הצגת מסוף GCP במהלך תהליך יצירת הפרויקט

אחרי שיוצרים פרויקט, עוברים אל הדף API Library. בתיבה לחיפוש, מזינים 'youtube' ובוחרים ב-youtube data api v3.

בחירת YouTube Data API v3 במסוף GCP

בדף הפרטים של YouTube Data API גרסה 3, מפעילים את ה-API.

5a877ea82b83ae42.png

אחרי שמפעילים את ה-API, עוברים אל דף פרטי הכניסה ויוצרים מפתח API.

יצירת פרטי כניסה במסוף GCP

אחרי כמה שניות, אמורה להופיע תיבת דו-שיח עם מפתח ה-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 כדי לצפות בסרטון.

האפליקציה שבה מוצגים הפלייליסטים של חשבון YouTube של FlutterDev

הצגת הסרטונים בפלייליסט ספציפי

סרטון שנבחר שפועל בנגן YouTube

עם זאת, אם תנסו להריץ את האפליקציה הזו במחשב, תבחינו שהפריסה לא נראית טוב כשהיא מורחבת לחלון רגיל בגודל מחשב. בשלב הבא נבחן דרכים להתאמה לכך.

5. התאמה למחשב

הבעיה במחשב

אם תפעילו את האפליקציה באחת מפלטפורמות המחשב המקומיות, Windows,‏ macOS או Linux, תבחינו בבעיה מעניינת. הוא עובד, אבל נראה… מוזר.

האפליקציה פועלת ב-macOS ומציגה רשימה של פלייליסטים, ביחסים מוזרים

הסרטונים בפלייליסט, ב-macOS

כדי לפתור את הבעיה, אפשר להוסיף תצוגה מפוצלת שבה הפלייליסטים מופיעים בצד ימין והסרטונים בצד ימין. עם זאת, כדאי שהפריסה הזו תפעל רק כשהקוד לא פועל ב-Android או ב-iOS, והחלון רחב מספיק. בהוראות הבאות מוסבר איך מטמיעים את היכולת הזו.

קודם כול, מוסיפים את החבילה split_view כדי לעזור ביצירת הפריסה.

$ flutter pub add split_view
Resolving dependencies...
Downloading packages...
  leak_tracker 10.0.9 (11.0.1 available)
  leak_tracker_flutter_testing 3.0.9 (3.0.10 available)
  leak_tracker_testing 3.0.1 (3.0.2 available)
  lints 5.1.1 (6.0.0 available)
  material_color_utilities 0.11.1 (0.12.0 available)
  meta 1.16.0 (1.17.0 available)
+ split_view 3.2.1
  test_api 0.7.4 (0.7.6 available)
  vector_math 2.1.4 (2.1.5 available)
Changed 1 dependency!
8 packages have newer versions incompatible with dependency constraints.
Try `flutter pub outdated` for more information.

ווידג'טים דינמיים

התבנית שבה נשתמש בקודלאב הזה היא להציג ווידג'טים מותאמים שמבצעים בחירות הטמעה על סמך מאפיינים כמו רוחב המסך, עיצוב הפלטפורמה וכו'. במקרה כזה, תצטרכו להוסיף ווידג'ט AdaptivePlaylists שישנה את האופן שבו Playlists ו-PlaylistDetails מקיימים אינטראקציה. עורכים את הקובץ lib/main.dart באופן הבא:

lib/main.dart

import 'dart:io';

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

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

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

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

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

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

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

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

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

בשלב הבא יוצרים את הקובץ של הווידג'ט AdaptivePlaylist:

lib/src/adaptive_playlists.dart

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

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

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

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

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

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

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

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

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

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

הקובץ הזה מעניין מכמה סיבות. קודם כול, המערכת משתמשת גם ברוחב החלון (באמצעות MediaQuery.of(context).size.width) וגם בבדיקה של העיצוב (באמצעות Theme.of(context).platform) כדי להחליט אם להציג פריסה רחבה עם הווידג'ט SplitView או תצוגה צרה בלי הווידג'ט.

שנית, הקטע הזה עוסק בטיפול הקבוע מראש בניווט. הוא מציג ארגומנט של קריאה חוזרת בווידג'ט Playlists. קריאת החזרה (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 יחיד לטווח ארוך שכל ה-ListViews מצורפים אליו ומנותקים ממנו במהלך מחזורי החיים שלהם. במחשב, המצב שונה, בעולם שבו יש צורך להציג כמה ListView זה לצד זה.

ולבסוף, עורכים את הקובץ lib/src/playlist_details.dart באופן הבא:

lib/src/playlist_details.dart

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

import 'app_state.dart';

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

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

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

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

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

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

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

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

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

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

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

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

בדומה לווידג'ט Playlists שלמעלה, גם בקובץ הזה יש שינויים שמבטלים את הווידג'ט Scaffold ומציגים ScrollController בבעלות.

מפעילים את האפליקציה שוב.

הפעלת האפליקציה במחשב שלכם, בין אם מדובר ב-Windows, ב-macOS או ב-Linux. עכשיו הוא אמור לפעול כצפוי.

האפליקציה פועלת ב-macOS בתצוגה מחולקת

‫6. התאמה לאינטרנט

מה קרה לתמונות האלה?

ניסיון להפעיל את האפליקציה הזו באינטרנט מראה שדרושה עבודה נוספת כדי להתאים אותה לדפדפני אינטרנט.

האפליקציה פועלת בדפדפן Chrome, ללא תמונות ממוזערות של YouTube

אם תעיינו במסוף ניפוי הבאגים, תראו רמז עדין לגבי הפעולה הבאה שצריך לבצע.

══╡ EXCEPTION CAUGHT BY IMAGE RESOURCE SERVICE ╞════════════════════════════════════════════════════
The following ProgressEvent$ object was thrown resolving an image codec:
  [object ProgressEvent]

When the exception was thrown, this was the stack

Image provider: NetworkImage("https://i.ytimg.com/vi/4AoFA19gbLo/default.jpg", scale: 1.0)
Image key: NetworkImage("https://i.ytimg.com/vi/4AoFA19gbLo/default.jpg", scale: 1.0)
════════════════════════════════════════════════════════════════════════════════════════════════════

יצירת שרת 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 פועל, אמורה להיות לכם אפשרות להפעיל את גרסת האינטרנט של האפליקציה, והיא אמורה להיראות בערך כך:

האפליקציה פועלת בדפדפן Chrome, עם תמונות ממוזערות של YouTube

‫7. אימות דינמי

בשלב הזה תרחיבו את האפליקציה על ידי מתן היכולת לאמת את המשתמש, ואז להציג את הפלייליסטים של המשתמש הזה. תצטרכו להשתמש בכמה יישומי פלאגין כדי לכסות את הפלטפורמות השונות שבהן האפליקציה יכולה לפעול, כי הטיפול ב-OAuth שונה מאוד בין Android,‏ iOS, האינטרנט, Windows,‏ macOS ו-Linux.

הוספת יישומי פלאגין כדי להפעיל אימות באמצעות Google

עליכם להתקין שלוש חבילות כדי לטפל באימות באמצעות Google.

$ flutter pub add googleapis_auth google_sign_in \
    extension_google_sign_in_as_googleapis_auth
Resolving dependencies...
+ args 2.4.2
+ crypto 3.0.3
+ extension_google_sign_in_as_googleapis_auth 2.0.12
+ google_identity_services_web 0.3.0+2
+ google_sign_in 6.2.1
+ google_sign_in_android 6.1.21
+ google_sign_in_ios 5.7.2
+ google_sign_in_platform_interface 2.4.4
+ google_sign_in_web 0.12.3+2
+ googleapis_auth 1.4.1
+ js 0.6.7 (0.7.0 available)
  matcher 0.12.16 (0.12.16+1 available)
  material_color_utilities 0.5.0 (0.8.0 available)
  meta 1.10.0 (1.11.0 available)
  path 1.8.3 (1.9.0 available)
  test_api 0.6.1 (0.7.0 available)
  web 0.3.0 (0.4.0 available)
Changed 11 dependencies!
7 packages have newer versions incompatible with dependency constraints.
Try `flutter pub outdated` for more information.

כדי לבצע אימות ב-Windows, ב-macOS וב-Linux, משתמשים בחבילה googleapis_auth. בפלטפורמות למחשבים האלה מתבצע אימות באמצעות דפדפן אינטרנט. כדי לבצע אימות ב-Android, ב-iOS ובאינטרנט, משתמשים בחבילות google_sign_in ו-extension_google_sign_in_as_googleapis_auth. החבילה השנייה משמשת כממשק ביניים (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:

דף פרטי הכניסה של פרויקט ה-API במסוף GCP

תוצג תיבת דו-שיח, שתצטרכו לאשר על ידי לחיצה על לחצן המחיקה:

חלון הקופץ &#39;מחיקת פרטי כניסה&#39;

לאחר מכן, יוצרים מזהה לקוח OAuth:

יצירת מזהה לקוח ב-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

בשאר הטופס, ממלאים את השם של החבילה בשם החבילה שצוינה בקובץ android/app/src/main/AndroidManifest.xml. אם פעלתם לפי ההוראות במדויק, הערך צריך להיות com.example.adaptive_app. מחלצים את טביעת האצבע של אישור SHA-1 לפי ההוראות בדף העזרה של מסוף Google Cloud:

מתן שם למזהה הלקוח ב-Android

זה מספיק כדי שהאפליקציה תפעל ב-Android. בהתאם לבחירה של ממשקי Google API שבהם אתם משתמשים, יכול להיות שתצטרכו להוסיף את קובץ ה-JSON שנוצר לחבילת האפליקציה.

הפעלת האפליקציה ב-Android

הגדרת google_sign_in ל-iOS

חוזרים אל דף פרטי הכניסה של פרויקט ה-API ויוצרים מזהה לקוח OAuth נוסף, אלא שהפעם בוחרים באפשרות iOS:

בחירת סוג האפליקציה ל-iOS

בשאר הטופס, ממלאים את מזהה החבילה על ידי פתיחת ios/Runner.xcworkspace ב-Xcode. עוברים אל Project Navigator, בוחרים את Runner בניווט, בוחרים בכרטיסייה General ומעתיקים את מזהה החבילה. אם פעלתם לפי השלבים במדריך הקוד הזה, הערך אמור להיות com.example.adaptiveApp.

בשאר הטופס, ממלאים את מזהה החבילה. פותחים את ios/Runner.xcworkspace ב-Xcode. עוברים אל Project Navigator. עוברים אל Runner > הכרטיסייה General. מעתיקים את מזהה החבילה. אם פעלתם לפי השלבים בקודלאב, הערך שלו אמור להיות com.example.adaptiveApp.

איפה נמצא מזהה החבילה ב-Xcode

בשלב הזה, אפשר להתעלם ממזהה App Store וממזהה הצוות, כי הם לא נדרשים לפיתוח מקומי:

מתן שם למזהה הלקוח ב-iOS

מורידים את קובץ ה-.plist שנוצר. השם שלו מבוסס על מזהה הלקוח שנוצר. משנים את שם הקובץ שהורדתם ל-GoogleService-Info.plist וגוררים אותו לתוך עורך Xcode שפועל, לצד הקובץ Info.plist בקטע Runner/Runner בתפריט הניווט הימני. בתיבת הדו-שיח של האפשרויות ב-Xcode, בוחרים באפשרות Copy items (העתקת פריטים) אם צריך, באפשרות Create folder references (יצירת הפניות לתיקיות) וביעד Add to the Runner (הוספה ל-Runner).

הוספת קובץ ה-plist שנוצר לאפליקציית iOS ב-Xcode

יוצאים מ-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 שנוצר. מריצים את האפליקציה, ונכנסים לחשבון. הפלייליסטים אמורים להופיע.

האפליקציה שפועלת ב-iOS

הגדרת 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".

אחרי הכניסה לחשבון שוב, הפלייליסטים אמורים להופיע:

האפליקציה שפועלת בדפדפן Chrome

8. השלבים הבאים

מזל טוב!

השלמת את הקודלאב ובנית אפליקציית Flutter אדפטיבית שפועלת בכל שש הפלטפורמות שבהן Flutter תומכת. התאמתם את הקוד כדי לטפל בהבדלים בפריסה של המסכים, באינטראקציה עם הטקסט, בחיוב התמונות ובאופן שבו מתבצע האימות.

יש עוד הרבה דברים שאפשר להתאים אישית באפליקציות. במאמר פיתוח אפליקציות עם יכולת הסתגלות מוסבר איך אפשר להתאים את הקוד לסביבות שונות שבהן הוא יפעל.