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

1. מבוא

‫Flutter היא ערכת הכלים של Google לפיתוח ממשקי משתמש. היא מאפשרת לפתח אפליקציות נהדרות עם תהליך קומפילציה מובנה למכשירים ניידים, לאתרים ולמחשבים – מ-codebase יחיד. בסדנת הקוד הזו תלמדו איך ליצור אפליקציית Flutter שמותאמת לפלטפורמה שבה היא פועלת, בין אם מדובר ב-Android, ב-iOS, באינטרנט, ב-Windows, ב-macOS או ב-Linux.

מה תלמדו?

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

מה תפַתחו

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

צילום מסך של אפליקציית Flutter ב-Android וב-iOS:

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

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

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

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

ב-codelab הזה נתמקד בהפיכת אפליקציית Flutter לנייד לאפליקציה אדפטיבית שפועלת בכל שש הפלטפורמות של Flutter. מושגים וקטעי קוד לא רלוונטיים מוצגים בקצרה, וניתנים להעתקה והדבקה.

מה היית רוצה ללמוד ב-codelab הזה?

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

2. הגדרת סביבת הפיתוח של Flutter

כדי להשלים את שיעור ה-Lab הזה, תצטרכו שני סוגי תוכנה: Flutter SDK ועורך.

אפשר להריץ את ה-codelab באמצעות כל אחד מהמכשירים הבאים:

  • מכשיר Android או iOS פיזי שמחובר למחשב ומוגדר למצב פיתוח.
  • הסימולטור של iOS (נדרשת התקנה של כלי Xcode).
  • Android Emulator (נדרשת הגדרה ב-Android Studio).
  • דפדפן (חובה להשתמש ב-Chrome לצורך ניפוי באגים).
  • כאפליקציה למחשב Windows,‏ Linux או macOS. אתם צריכים לפתח בפלטפורמה שבה אתם מתכננים לבצע פריסה. לכן, אם רוצים לפתח אפליקציה למחשב שולחני עם Windows, צריך לפתח אותה ב-Windows כדי לגשת לשרשרת הבנייה המתאימה. יש דרישות ספציפיות למערכות הפעלה שמוסברות בפירוט בכתובת docs.flutter.dev/desktop.

‫3. שנתחיל?

אישור סביבת הפיתוח

הדרך הקלה ביותר לוודא שהכל מוכן לפיתוח היא להריץ את הפקודה הבאה:

flutter doctor

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

flutter doctor -v

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

יצירת פרויקט Flutter

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

$ flutter create adaptive_app
Creating project adaptive_app...
Resolving dependencies in adaptive_app... (1.8s)
Got dependencies in adaptive_app.
Wrote 129 files.

All done!
You can find general documentation for Flutter at: https://docs.flutter.dev/
Detailed API documentation is available at: https://api.flutter.dev/
If you prefer video documentation, consider: https://www.youtube.com/c/flutterdev

In order to run your application, type:

  $ cd adaptive_app
  $ flutter run

Your application code is in adaptive_app/lib/main.dart.

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

$ flutter run
Launching lib/main.dart on iPhone 15 in debug mode...
Running Xcode build...
 └─Compiling, linking and signing...                         6.5s
Xcode build done.                                           24.6s
Syncing files to device iPhone 15...                                46ms

Flutter run key commands.
r Hot reload. 🔥🔥🔥
R Hot restart.
h List all available interactive commands.
d Detach (terminate "flutter run" but leave application running).
c Clear the screen
q Quit (terminate the application on the device).

A Dart VM Service on iPhone 15 is available at: http://127.0.0.1:50501/JHGBwC_hFJo=/
The Flutter DevTools debugger and profiler on iPhone 15 is available at: http://127.0.0.1:9102?uri=http://127.0.0.1:50501/JHGBwC_hFJo=/

עכשיו האפליקציה אמורה לפעול. צריך לעדכן את התוכן.

כדי לעדכן את התוכן, מעדכנים את הקוד ב-lib/main.dart עם הקוד הבא. כדי לשנות את מה שמוצג באפליקציה, מבצעים טעינה מהירה.

  • אם מריצים את האפליקציה באמצעות שורת הפקודה, מקלידים r במסוף כדי לבצע טעינה מחדש מהירה.
  • אם מריצים את האפליקציה באמצעות IDE, האפליקציה נטענת מחדש כששומרים את הקובץ.

lib/main.dart

import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
      ),
      home: const ResizeablePage(),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    final mediaQuery = MediaQuery.of(context);
    final themePlatform = Theme.of(context).platform;

    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'Window properties',
              style: Theme.of(context).textTheme.headlineSmall,
            ),
            const SizedBox(height: 8),
            SizedBox(
              width: 350,
              child: Table(
                textBaseline: TextBaseline.alphabetic,
                children: <TableRow>[
                  _fillTableRow(
                    context: context,
                    property: 'Window Size',
                    value:
                        '${mediaQuery.size.width.toStringAsFixed(1)} x '
                        '${mediaQuery.size.height.toStringAsFixed(1)}',
                  ),
                  _fillTableRow(
                    context: context,
                    property: 'Device Pixel Ratio',
                    value: mediaQuery.devicePixelRatio.toStringAsFixed(2),
                  ),
                  _fillTableRow(
                    context: context,
                    property: 'Platform.isXXX',
                    value: platformDescription(),
                  ),
                  _fillTableRow(
                    context: context,
                    property: 'Theme.of(ctx).platform',
                    value: themePlatform.toString(),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }

  TableRow _fillTableRow({
    required BuildContext context,
    required String property,
    required String value,
  }) {
    return TableRow(
      children: [
        TableCell(
          verticalAlignment: TableCellVerticalAlignment.baseline,
          child: Padding(
            padding: const EdgeInsets.all(8.0),
            child: Text(property),
          ),
        ),
        TableCell(
          verticalAlignment: TableCellVerticalAlignment.baseline,
          child: Padding(
            padding: const EdgeInsets.all(8.0),
            child: Text(value),
          ),
        ),
      ],
    );
  }

  String platformDescription() {
    if (kIsWeb) {
      return 'Web';
    } else if (Platform.isAndroid) {
      return 'Android';
    } else if (Platform.isIOS) {
      return 'iOS';
    } else if (Platform.isWindows) {
      return 'Windows';
    } else if (Platform.isMacOS) {
      return 'macOS';
    } else if (Platform.isLinux) {
      return 'Linux';
    } else if (Platform.isFuchsia) {
      return 'Fuchsia';
    } else {
      return 'Unknown';
    }
  }
}

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

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

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

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

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

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

הנקודה החשובה כאן היא שבמבט ראשון, Flutter עושה את מה שהיא יכולה כדי להתאים את התוכן לתצוגה שבה היא פועלת. במחשב הנייד שבו צולמו צילומי המסך האלה יש מסך Mac ברזולוציה גבוהה, ולכן גם גרסת macOS וגם גרסת האינטרנט של האפליקציה מוצגות ביחס פיקסלים של המכשיר (DPR) של 2. בינתיים, באייפון 12, היחס הוא 3, וב-Pixel 2 הוא 2.63. בכל המקרים, הטקסט שמוצג דומה בערך, ולכן העבודה שלנו כמפתחים הרבה יותר קלה.

הנקודה השנייה שחשוב לשים לב אליה היא ששתי האפשרויות לבדיקה של הפלטפורמה שבה הקוד פועל מניבות ערכים שונים. באפשרות הראשונה מתבצעת בדיקה של האובייקט Platform שיובא מ-dart:io, ואילו באפשרות השנייה (שזמינה רק בתוך השיטה build של הווידג'ט) מתבצעת אחזור של האובייקט Theme מהארגומנט BuildContext.

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

הפקת Theme מתוך BuildContext מיועדת להחלטות הטמעה שמתמקדות בעיצוב. דוגמה מצוינת לכך היא ההחלטה אם להשתמש ב-Material slider או ב-Cupertino slider, כפי שמוסבר במאמר Slider.adaptive.

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

4. פיתוח אפליקציה לנייד

הוספת חבילות

באפליקציה הזו תשתמשו במגוון חבילות Flutter כדי לקבל גישה אל YouTube Data API, לניהול מצב ולעיצוב ערכת נושא.

$ flutter pub add googleapis http provider url_launcher flex_color_scheme go_router
Resolving dependencies...
Downloading packages...
+ _discoveryapis_commons 1.0.7
  characters 1.4.0 (1.4.1 available)
+ flex_color_scheme 8.3.0
+ flex_seed_scheme 3.5.1
> flutter_lints 6.0.0 (was 5.0.0)
+ flutter_web_plugins 0.0.0 from sdk flutter
+ go_router 16.2.0
+ googleapis 14.0.0
+ http 1.5.0
+ http_parser 4.1.2
> lints 6.0.0 (was 5.1.1)
+ logging 1.3.0
  material_color_utilities 0.11.1 (0.13.0 available)
  meta 1.16.0 (1.17.0 available)
+ nested 1.0.0
+ plugin_platform_interface 2.1.8
+ provider 6.1.5
  test_api 0.7.6 (0.7.7 available)
+ typed_data 1.4.0
+ url_launcher 6.3.2
+ url_launcher_android 6.3.17
+ url_launcher_ios 6.3.4
+ url_launcher_linux 3.2.1
+ url_launcher_macos 3.2.3
+ url_launcher_platform_interface 2.3.2
+ url_launcher_web 2.4.1
+ url_launcher_windows 3.1.4
+ web 1.1.1
Changed 24 dependencies!
4 packages have newer versions incompatible with dependency constraints.
Try `flutter pub outdated` for more information.

הפקודה הזו מוסיפה מספר חבילות לאפליקציה:

  • googleapis: ספריית Dart שנוצרה ומספקת גישה אל ממשקי API של Google.
  • 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, צריך להגדיר את אפליקציות ההרצה של 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 גרסה 3 במסוף GCP

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

5a877ea82b83ae42.png

אחרי שמפעילים את ה-API, עוברים אל Credentials page ויוצרים מפתח API.

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

אחרי כמה שניות, אמורה להופיע תיבת דו-שיח עם מפתח ה-API החדש. תשתמשו במפתח הזה בקרוב.

חלון קופץ עם מפתח ה-API שנוצר

הוספת קוד

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

מוסיפים את הקבצים הבאים, קודם את אובייקט המצב של האפליקציה.

lib/src/app_state.dart

import 'dart:collection';

import 'package:flutter/foundation.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:http/http.dart' as http;

class FlutterDevPlaylists extends ChangeNotifier {
  FlutterDevPlaylists({
    required String flutterDevAccountId,
    required String youTubeApiKey,
  }) : _flutterDevAccountId = flutterDevAccountId {
    _api = YouTubeApi(_ApiKeyClient(client: http.Client(), key: youTubeApiKey));
    _loadPlaylists();
  }

  Future<void> _loadPlaylists() async {
    String? nextPageToken;
    _playlists.clear();

    do {
      final response = await _api.playlists.list(
        ['snippet', 'contentDetails', 'id'],
        channelId: _flutterDevAccountId,
        maxResults: 50,
        pageToken: nextPageToken,
      );
      _playlists.addAll(response.items!);
      _playlists.sort(
        (a, b) => a.snippet!.title!.toLowerCase().compareTo(
          b.snippet!.title!.toLowerCase(),
        ),
      );
      notifyListeners();
      nextPageToken = response.nextPageToken;
    } while (nextPageToken != null);
  }

  final String _flutterDevAccountId;
  late final YouTubeApi _api;

  final List<Playlist> _playlists = [];
  List<Playlist> get playlists => UnmodifiableListView(_playlists);

  final Map<String, List<PlaylistItem>> _playlistItems = {};
  List<PlaylistItem> playlistItems({required String playlistId}) {
    if (!_playlistItems.containsKey(playlistId)) {
      _playlistItems[playlistId] = [];
      _retrievePlaylist(playlistId);
    }
    return UnmodifiableListView(_playlistItems[playlistId]!);
  }

  Future<void> _retrievePlaylist(String playlistId) async {
    String? nextPageToken;
    do {
      var response = await _api.playlistItems.list(
        ['snippet', 'contentDetails'],
        playlistId: playlistId,
        maxResults: 25,
        pageToken: nextPageToken,
      );
      var items = response.items;
      if (items != null) {
        _playlistItems[playlistId]!.addAll(items);
      }
      notifyListeners();
      nextPageToken = response.nextPageToken;
    } while (nextPageToken != null);
  }
}

class _ApiKeyClient extends http.BaseClient {
  _ApiKeyClient({required this.key, required this.client});

  final String key;
  final http.Client client;

  @override
  Future<http.StreamedResponse> send(http.BaseRequest request) {
    final url = request.url.replace(
      queryParameters: <String, List<String>>{
        ...request.url.queryParametersAll,
        'key': [key],
      },
    );

    return client.send(http.Request(request.method, url));
  }
}

לאחר מכן מוסיפים את דף הפרטים של הפלייליסט.

lib/src/playlist_details.dart

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

import 'app_state.dart';

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

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

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

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

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

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

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

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

לאחר מכן, מוסיפים את רשימת הפלייליסטים.

lib/src/playlists.dart

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

import 'app_state.dart';

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('FlutterDev Playlists')),
      body: Consumer<FlutterDevPlaylists>(
        builder: (context, flutterDev, child) {
          final playlists = flutterDev.playlists;
          if (playlists.isEmpty) {
            return const Center(child: CircularProgressIndicator());
          }

          return _PlaylistsListView(items: playlists);
        },
      ),
    );
  }
}

class _PlaylistsListView extends StatelessWidget {
  const _PlaylistsListView({required this.items});

  final List<Playlist> items;

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: items.length,
      itemBuilder: (context, index) {
        var playlist = items[index];
        return Padding(
          padding: const EdgeInsets.all(8.0),
          child: ListTile(
            leading: Image.network(
              playlist.snippet!.thumbnails!.default_!.url!,
            ),
            title: Text(playlist.snippet!.title!),
            subtitle: Text(playlist.snippet!.description!),
            onTap: () {
              context.go(
                Uri(
                  path: '/playlist/${playlist.id}',
                  queryParameters: <String, String>{
                    'title': playlist.snippet!.title!,
                  },
                ).toString(),
              );
            },
          ),
        );
      },
    );
  }
}

מחליפים את התוכן של הקובץ main.dart באופן הבא:

lib/main.dart

import 'dart:io';

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

import 'src/app_state.dart';
import 'src/playlist_details.dart';
import 'src/playlists.dart';

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

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

final _router = GoRouter(
  routes: <RouteBase>[
    GoRoute(
      path: '/',
      builder: (context, state) {
        return const Playlists();
      },
      routes: <RouteBase>[
        GoRoute(
          path: 'playlist/:id',
          builder: (context, state) {
            final title = state.uri.queryParameters['title']!;
            final id = state.pathParameters['id']!;
            return PlaylistDetails(playlistId: id, playlistName: title);
          },
        ),
      ],
    ),
  ],
);

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

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

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

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

כמעט סיימתם את ההכנות להפעלת הקוד הזה ב-Android וב-iOS. נשאר רק עוד דבר אחד לשנות: מחליפים את הקבוע youTubeApiKey במפתח YouTube API שנוצר בשלב הקודם.

lib/main.dart

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

כדי להריץ את האפליקציה הזו ב-macOS, צריך לאפשר לאפליקציה לשלוח בקשות HTTP באופן הבא. עורכים את הקבצים DebugProfile.entitlements ו-Release.entitilements באופן הבא:

macos/Runner/DebugProfile.entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
        <dict>
                <key>com.apple.security.app-sandbox</key>
                <true/>
                <key>com.apple.security.cs.allow-jit</key>
                <true/>
                <key>com.apple.security.network.server</key>
                <true/>
                <!-- add the following two lines -->
                <key>com.apple.security.network.client</key>
                <true/>
        </dict>
</plist>

macos/Runner/Release.entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
        <dict>
                <key>com.apple.security.app-sandbox</key>
                <true/>
                <!-- add the following two lines -->
                <key>com.apple.security.network.client</key>
                <true/>
        </dict>
</plist>

הפעלת האפליקציה

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

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

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

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

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

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

הבעיה במחשב

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

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

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

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

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

$ flutter pub add split_view
Resolving dependencies...
Downloading packages...
  characters 1.4.0 (1.4.1 available)
  material_color_utilities 0.11.1 (0.13.0 available)
  meta 1.16.0 (1.17.0 available)
+ split_view 3.2.1
  test_api 0.7.6 (0.7.7 available)
Changed 1 dependency!
4 packages have newer versions incompatible with dependency constraints.
Try `flutter pub outdated` for more information.

הצגת ווידג'טים דינמיים

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

יש הרבה שינויים בקובץ הזה. בנוסף להוספת הקריאה החוזרת playlistSelected ולהסרת הווידג'ט Scaffold, הווידג'ט _PlaylistsListView הופך מווידג'ט חסר מצב (stateless) לווידג'ט עם מצב (stateful). השינוי הזה נדרש בגלל הוספה של ScrollController בבעלות, שצריך ליצור ולהרוס.

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

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

lib/src/playlist_details.dart

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

import 'app_state.dart';

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

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

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

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

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

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

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

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

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

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

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

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

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

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

להריץ את האפליקציה במחשב שולחני עם Windows,‏ macOS או Linux. עכשיו היא אמורה לפעול כמו שציפיתם.

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

$ cd yt_cors_proxy
$ dart pub add shelf_cors_headers http
"http" was found in dev_dependencies. Removing "http" and adding it to dependencies instead.
Resolving dependencies...
Downloading packages...
  http 1.5.0 (from dev dependency to direct dependency)
+ shelf_cors_headers 0.1.5
Changed 2 dependencies!

יש תלות נוכחית שכבר לא נדרשת. צריך לחתוך את הסרטון כך:

$ dart pub remove shelf_router
Resolving dependencies...
Downloading packages...
These packages are no longer being depended on:
- http_methods 1.1.1
- shelf_router 1.1.4
Changed 2 dependencies!

לאחר מכן, משנים את התוכן של הקובץ server.dart כך שיתאים לתוכן הבא:

yt_cors_proxy/bin/server.dart

import 'dart:async';
import 'dart:io';

import 'package:http/http.dart' as http;
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart';
import 'package:shelf_cors_headers/shelf_cors_headers.dart';

Future<Response> _requestHandler(Request req) async {
  final target = req.url.replace(scheme: 'https', host: 'i.ytimg.com');
  final response = await http.get(target);
  return Response.ok(response.bodyBytes, headers: response.headers);
}

void main(List<String> args) async {
  // Use any available host or container IP (usually `0.0.0.0`).
  final ip = InternetAddress.anyIPv4;

  // Configure a pipeline that adds CORS headers and proxies requests.
  final handler = Pipeline()
      .addMiddleware(logRequests())
      .addMiddleware(corsHeaders(headers: {ACCESS_CONTROL_ALLOW_ORIGIN: '*'}))
      .addHandler(_requestHandler);

  // For running in containers, we respect the PORT environment variable.
  final port = int.parse(Platform.environment['PORT'] ?? '8080');
  final server = await serve(handler, ip, port);
  print('Server listening on port ${server.port}');
}

אפשר להריץ את השרת הזה באופן הבא:

$ dart run bin/server.dart
Server listening on port 8080

לחלופין, אפשר ליצור אותו כקובץ אימג' של Docker ולהריץ את קובץ האימג' של Docker שנוצר באופן הבא:

$ docker build . -t yt-cors-proxy
[+] Building 2.7s (14/14) FINISHED
$ docker run -p 8080:8080 yt-cors-proxy
Server listening on port 8080

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

זוג ווידג'טים שניתן להתאמה

הווידג'ט הראשון מבין השניים הוא האופן שבו האפליקציה תשתמש ב-CORS proxy.

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 logging
Resolving dependencies...
Downloading packages...
+ args 2.7.0
  characters 1.4.0 (1.4.1 available)
+ crypto 3.0.6
+ extension_google_sign_in_as_googleapis_auth 3.0.0
+ google_identity_services_web 0.3.3+1
+ google_sign_in 7.1.1
+ google_sign_in_android 7.0.3
+ google_sign_in_ios 6.1.0
+ google_sign_in_platform_interface 3.0.0
+ google_sign_in_web 1.0.0
+ googleapis_auth 2.0.0
  logging 1.3.0 (from transitive dependency to direct dependency)
  material_color_utilities 0.11.1 (0.13.0 available)
  meta 1.16.0 (1.17.0 available)
  test_api 0.7.6 (0.7.7 available)
Changed 11 dependencies!
4 packages have newer versions incompatible with dependency constraints.
Try `flutter pub outdated` for more information.

כדי לבצע אימות ב-Windows, ב-macOS וב-Linux, משתמשים בחבילה googleapis_auth. בפלטפורמות האלה למחשב, האימות מתבצע באמצעות דפדפן אינטרנט. כדי לבצע אימות ב-Android, ב-iOS ובאינטרנט, צריך להשתמש בחבילות google_sign_in ו-extension_google_sign_in_as_googleapis_auth. החבילה השנייה משמשת כ-shim של יכולת פעולה הדדית בין שתי החבילות.

עדכון הקוד

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

lib/src/adaptive_login.dart

import 'dart:async';
import 'dart:io' show Platform;

import 'package:extension_google_sign_in_as_googleapis_auth/extension_google_sign_in_as_googleapis_auth.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:googleapis_auth/auth_io.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/link.dart';

import 'app_state.dart';

final _log = Logger('AdaptiveLogin');

typedef _AdaptiveLoginButtonWidget =
    Widget Function({required VoidCallback? onPressed});

class AdaptiveLogin extends StatelessWidget {
  const AdaptiveLogin({
    super.key,
    required this.clientId,
    required this.scopes,
    required this.loginButtonChild,
  });

  final ClientId clientId;
  final List<String> scopes;
  final Widget loginButtonChild;

  @override
  Widget build(BuildContext context) {
    if (kIsWeb || Platform.isAndroid || Platform.isIOS) {
      return _GoogleSignInLogin(button: _loginButton, scopes: scopes);
    } else {
      return _GoogleApisAuthLogin(
        button: _loginButton,
        scopes: scopes,
        clientId: clientId,
      );
    }
  }

  Widget _loginButton({required VoidCallback? onPressed}) =>
      ElevatedButton(onPressed: onPressed, child: loginButtonChild);
}

class _GoogleSignInLogin extends StatefulWidget {
  const _GoogleSignInLogin({required this.button, required this.scopes});

  final _AdaptiveLoginButtonWidget button;
  final List<String> scopes;

  @override
  State<_GoogleSignInLogin> createState() => _GoogleSignInLoginState();
}

class _GoogleSignInLoginState extends State<_GoogleSignInLogin> {
  @override
  initState() {
    super.initState();
    _googleSignIn = GoogleSignIn.instance;
    _googleSignIn.initialize();
    _authEventsSubscription = _googleSignIn.authenticationEvents.listen((
      event,
    ) async {
      _log.fine('Google Sign-In authentication event: $event');
      if (event is GoogleSignInAuthenticationEventSignIn) {
        final googleSignInClientAuthorization = await event
            .user
            .authorizationClient
            .authorizationForScopes(widget.scopes);
        if (googleSignInClientAuthorization == null) {
          _log.warning('Google Sign-In authenticated client creation failed');
          return;
        }
        _log.fine('Google Sign-In authenticated client created');
        final context = this.context;
        if (context.mounted) {
          context.read<AuthedUserPlaylists>().authClient =
              googleSignInClientAuthorization.authClient(scopes: widget.scopes);
          context.go('/');
        }
      }
    });

    // Check if user is already authenticated
    _log.fine('Attempting lightweight authentication');
    _googleSignIn.attemptLightweightAuthentication();
  }

  @override
  dispose() {
    _authEventsSubscription.cancel();
    super.dispose();
  }

  late final GoogleSignIn _googleSignIn;
  late final StreamSubscription _authEventsSubscription;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: widget.button(
          onPressed: () {
            _googleSignIn.authenticate();
          },
        ),
      ),
    );
  }
}

class _GoogleApisAuthLogin extends StatefulWidget {
  const _GoogleApisAuthLogin({
    required this.button,
    required this.scopes,
    required this.clientId,
  });

  final _AdaptiveLoginButtonWidget button;
  final List<String> scopes;
  final ClientId clientId;

  @override
  State<_GoogleApisAuthLogin> createState() => _GoogleApisAuthLoginState();
}

class _GoogleApisAuthLoginState extends State<_GoogleApisAuthLogin> {
  @override
  initState() {
    super.initState();
    clientViaUserConsent(widget.clientId, widget.scopes, (url) {
      setState(() {
        _authUrl = Uri.parse(url);
      });
    }).then((authClient) {
      final context = this.context;
      if (context.mounted) {
        context.read<AuthedUserPlaylists>().authClient = authClient;
        context.go('/');
      }
    });
  }

  Uri? _authUrl;

  @override
  Widget build(BuildContext context) {
    final authUrl = _authUrl;
    if (authUrl != null) {
      return Scaffold(
        body: Center(
          child: Link(
            uri: authUrl,
            builder: (context, followLink) =>
                widget.button(onPressed: followLink),
          ),
        ),
      );
    }

    return const Scaffold(body: Center(child: CircularProgressIndicator()));
  }
}

הקובץ הזה עושה הרבה. השיטה AdaptiveLogin של build עושה את העבודה הקשה. השיטה הזו בודקת את פלטפורמת זמן הריצה על ידי קריאה ל-Platform.isXXX של kIsWeb ושל dart:io. ב-Android, ב-iOS ובאינטרנט, הוא יוצר מופע של הווידג'ט _GoogleSignInLogin עם שמירת מצב. ב-Windows,‏ macOS ו-Linux, הוא יוצר מופע של _GoogleApisAuthLogin ווידג'ט עם מצב.

כדי להשתמש במחלקות האלה, צריך לבצע הגדרה נוספת, שתגיע בהמשך, אחרי עדכון שאר בסיס הקוד לשימוש בווידג'ט החדש הזה. מתחילים בשינוי השם של FlutterDevPlaylists ל-AuthedUserPlaylists כדי לשקף טוב יותר את המטרה החדשה שלו, ומעדכנים את הקוד כדי לשקף את העובדה ש-http.Client מועבר עכשיו אחרי הבנייה. לבסוף, אין יותר צורך במחלקת _ApiKeyClient:

lib/src/app_state.dart

import 'dart:collection';

import 'package:flutter/foundation.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:http/http.dart' as http;

class AuthedUserPlaylists extends ChangeNotifier {      // Rename class
  set authClient(http.Client client) {                  // Drop constructor, add setter
    _api = YouTubeApi(client);
    _loadPlaylists();
  }

  bool get isLoggedIn => _api != null;                  // Add property

  Future<void> _loadPlaylists() async {
    String? nextPageToken;
    _playlists.clear();

    do {
      final response = await _api!.playlists.list(      // Add ! to _api
        ['snippet', 'contentDetails', 'id'],
        mine: true,                                     // convert from channelId: to mine:
        maxResults: 50,
        pageToken: nextPageToken,
      );
      _playlists.addAll(response.items!);
      _playlists.sort(
        (a, b) => a.snippet!.title!.toLowerCase().compareTo(
          b.snippet!.title!.toLowerCase(),
        ),
      );
      notifyListeners();
      nextPageToken = response.nextPageToken;
    } while (nextPageToken != null);
  }

  YouTubeApi? _api;                                     // Convert to optional

  final List<Playlist> _playlists = [];
  List<Playlist> get playlists => UnmodifiableListView(_playlists);

  final Map<String, List<PlaylistItem>> _playlistItems = {};
  List<PlaylistItem> playlistItems({required String playlistId}) {
    if (!_playlistItems.containsKey(playlistId)) {
      _playlistItems[playlistId] = [];
      _retrievePlaylist(playlistId);
    }
    return UnmodifiableListView(_playlistItems[playlistId]!);
  }

  Future<void> _retrievePlaylist(String playlistId) async {
    String? nextPageToken;
    do {
      var response = await _api!.playlistItems.list(    // Add ! to _api
        ['snippet', 'contentDetails'],
        playlistId: playlistId,
        maxResults: 25,
        pageToken: nextPageToken,
      );
      var items = response.items;
      if (items != null) {
        _playlistItems[playlistId]!.addAll(items);
      }
      notifyListeners();
      nextPageToken = response.nextPageToken;
    } while (nextPageToken != null);
  }
}

// Delete the now unused _ApiKeyClient class

לאחר מכן, מעדכנים את הווידג'ט PlaylistDetails עם השם החדש של אובייקט מצב האפליקציה שסופק:

lib/src/playlist_details.dart

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

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

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

באופן דומה, מעדכנים את הווידג'ט Playlists:

lib/src/playlists.dart

class Playlists extends StatelessWidget {
  const Playlists({super.key, required this.playlistSelected});

  final PlaylistsListSelected playlistSelected;

  @override
  Widget build(BuildContext context) {
    return Consumer<AuthedUserPlaylists>(               // Update this line
      builder: (context, flutterDev, child) {
        final playlists = flutterDev.playlists;
        if (playlists.isEmpty) {
          return const Center(child: CircularProgressIndicator());
        }

        return _PlaylistsListView(
          items: playlists,
          playlistSelected: playlistSelected,
        );
      },
    );
  }
}

לבסוף, מעדכנים את הקובץ main.dart כדי להשתמש בווידג'ט החדש AdaptiveLogin בצורה נכונה:

lib/main.dart

// Drop dart:io import

import 'package:flex_color_scheme/flex_color_scheme.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:googleapis_auth/googleapis_auth.dart'; // Add this line
import 'package:provider/provider.dart';

import 'src/adaptive_login.dart';                      // Add this line
import 'src/adaptive_playlists.dart';
import 'src/app_state.dart';
import 'src/playlist_details.dart';

// Drop flutterDevAccountId and youTubeApiKey

// Add from this line
// From https://developers.google.com/youtube/v3/guides/auth/installed-apps#identify-access-scopes
final scopes = ['https://www.googleapis.com/auth/youtube.readonly'];

// TODO: Replace with your Client ID and Client Secret for Desktop configuration
final clientId = ClientId(
  'TODO-Client-ID.apps.googleusercontent.com',
  'TODO-Client-secret',
);
// To this line

final _router = GoRouter(
  routes: <RouteBase>[
    GoRoute(
      path: '/',
      builder: (context, state) {
        return const AdaptivePlaylists();
      },
      // Add redirect configuration
      redirect: (context, state) {
        if (!context.read<AuthedUserPlaylists>().isLoggedIn) {
          return '/login';
        } else {
          return null;
        }
      },
      // To this line
      routes: <RouteBase>[
        // Add new login Route
        GoRoute(
          path: 'login',
          builder: (context, state) {
            return AdaptiveLogin(
              clientId: clientId,
              scopes: scopes,
              loginButtonChild: const Text('Login to YouTube'),
            );
          },
        ),
        // To this line
        GoRoute(
          path: 'playlist/:id',
          builder: (context, state) {
            final title = state.uri.queryParameters['title']!;
            final id = state.pathParameters['id']!;
            return Scaffold(
              appBar: AppBar(title: Text(title)),
              body: PlaylistDetails(playlistId: id, playlistName: title),
            );
          },
        ),
      ],
    ),
  ],
);

void main() {
  runApp(
    ChangeNotifierProvider<AuthedUserPlaylists>(       // Modify this line
      create: (context) => AuthedUserPlaylists(),      // Modify this line
      child: const PlaylistsApp(),
    ),
  );
}

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

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

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

האפליקציה מציגה עכשיו פלייליסטים ב-YouTube של המשתמש המאומת. אחרי שמשלימים את התכונות, צריך להפעיל אימות. כדי לעשות זאת, צריך להגדיר את חבילות google_sign_in ו-googleapis_auth. כדי להגדיר את החבילות, צריך לשנות את הקובץ main.dart ואת הקבצים של אפליקציות Runner.

הגדרה של googleapis_auth

השלב הראשון בהגדרת האימות הוא ביטול מפתח ה-API שהגדרתם והשתמשתם בו קודם לכן. עוברים אל דף פרטי הכניסה של פרויקט ה-API ומוחקים את מפתח ה-API:

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

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

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

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

יצירת מזהה לקוח OAuth

בקטע Application type (סוג האפליקציה), בוחרים באפשרות Desktop app (אפליקציה למחשב).

בחירת סוג האפליקציה &#39;אפליקציה למחשב&#39;

מאשרים את השם ולוחצים על יצירה.

מתן שם למזהה הלקוח

כך נוצרים מזהה הלקוח וסוד הלקוח שצריך להוסיף ל-lib/main.dart כדי להגדיר את התהליך של googleapis_auth. פרט חשוב לגבי ההטמעה הוא שתהליך googleapis_auth משתמש בשרת אינטרנט זמני שפועל ב-localhost כדי לתעד את טוקן ה-OAuth שנוצר. ב-macOS, נדרש שינוי בקובץ macos/Runner/Release.entitlements:

macos/Runner/Release.entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
        <dict>
                <key>com.apple.security.app-sandbox</key>
                <true/>
                <!-- add the following two lines -->
                <key>com.apple.security.network.server</key>
                <true/>
                <key>com.apple.security.network.client</key>
                <true/>
        </dict>
</plist>

אין צורך לבצע את העריכה הזו בקובץ macos/Runner/DebugProfile.entitlements כי כבר יש לו הרשאה ל-com.apple.security.network.server כדי להפעיל את הטעינה מחדש ואת כלי הניפוי באגים של Dart VM.

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

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

הגדרת google_sign_in ל-Android

חוזרים אל דף פרטי הכניסה של פרויקט ה-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. עוברים לחלונית הניווט בפרויקט, בוחרים את Runner בחלונית הניווט, בוחרים בכרטיסייה General (כללי) ומעתיקים את Bundle Identifier (מזהה החבילה). אם פעלתם לפי השלבים של ה-codelab הזה, הערך צריך להיות com.example.adaptiveApp.

בשאר הטופס, ממלאים את מזהה החבילה. פותחים את ios/Runner.xcworkspace ב-Xcode. עוברים לחלונית הניווט בפרויקט. עוברים אל Runner (הפעלת הבדיקה) > הכרטיסייה General (כללי). מעתיקים את מזהה החבילה. אם פעלתם לפי ההוראות של ה-codelab הזה, הערך שלו צריך להיות 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, אבל הפעם בוחרים באפשרות Web application:

בחירת סוג אפליקציית האינטרנט

בשאר הטופס, ממלאים את השדות של מקורות 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. השלבים הבאים

מזל טוב!

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

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