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

1. מבוא

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

מה תלמדו

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

מה תפַתחו

ב-Codelab הזה, בשלב הראשון תיצרו אפליקציה של Flutter ל-Android ול-iOS שסוקרת את הפלייליסטים של Flutter ב-YouTube. לאחר מכן, ניתן להתאים את האפליקציה כך שתפעל בשלוש פלטפורמות המחשב (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 (נדרשת הגדרה ב-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 עם הקוד הבא. כדי לשנות את התוכן שמוצג באפליקציה, צריך לבצע טעינה מהירה מחדש.

  • אם מריצים את האפליקציה באמצעות שורת הפקודה, צריך להקליד 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 ו-2.63 ב-Pixel 2. בכל המקרים, הטקסט המוצג דומה יחסית, מה שמקל מאוד את העבודה שלנו כמפתחים.

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

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

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

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

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

הוספת חבילות

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

$ flutter pub add googleapis http provider url_launcher flex_color_scheme go_router
Resolving dependencies... 
Downloading packages... 
+ _discoveryapis_commons 1.0.6
+ flex_color_scheme 7.3.1
+ flex_seed_scheme 1.5.0
+ flutter_web_plugins 0.0.0 from sdk flutter
+ go_router 14.0.1
+ googleapis 13.1.0
+ http 1.2.1
+ http_parser 4.0.2
  leak_tracker 10.0.4 (10.0.5 available)
  leak_tracker_flutter_testing 3.0.3 (3.0.5 available)
+ logging 1.2.0
  material_color_utilities 0.8.0 (0.11.1 available)
  meta 1.12.0 (1.14.0 available)
+ nested 1.0.0
+ plugin_platform_interface 2.1.8
+ provider 6.1.2
  test_api 0.7.0 (0.7.1 available)
+ typed_data 1.3.2
+ url_launcher 6.2.6
+ url_launcher_android 6.3.1
+ url_launcher_ios 6.2.5
+ url_launcher_linux 3.1.1
+ url_launcher_macos 3.1.0
+ url_launcher_platform_interface 2.3.2
+ url_launcher_web 2.3.1
+ url_launcher_windows 3.1.1
+ web 0.5.1
Changed 22 dependencies!
5 packages have newer versions incompatible with dependency constraints.
Try `flutter pub outdated` for more information.

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

  • googleapis: ספרייה של Dart שנוצרה שמספקת גישה ל-Google APIs.
  • http: ספרייה ליצירת בקשות HTTP שמסתירה את ההבדלים בין דפדפני אינטרנט לבין דפדפני אינטרנט.
  • provider: מאפשר ניהול מדינה.
  • url_launcher: מספק את האמצעים לעבור מפלייליסט לסרטון. כפי שאפשר לראות לגבי יחסי התלות שטופלו, ב-url_launcher יש הטמעות ל-Windows, ל-macOS, ל-Linux ולאינטרנט, בנוסף לברירת המחדל ל-Android ול-iOS. אם משתמשים בחבילה הזו, לא צריך ליצור פלטפורמה ספציפית לפונקציונליות הזו.
  • flex_color_scheme: הוספת ערכת צבעים נחמדה כברירת מחדל לאפליקציה. מידע נוסף זמין במאמרי העזרה של API flex_color_scheme.
  • go_router: הטמעת ניווט בין המסכים השונים. החבילה הזו מספקת API נוח המבוסס על כתובות URL לניווט באמצעות ה-Router של Flutter.

הגדרת האפליקציות לנייד עבור url_launcher

לפלאגין url_launcher נדרשת הגדרה של אפליקציות ההרצה ל-Android ול-iOS. ב-iOS Flutter runer, מוסיפים את השורות הבאות למילון plist.

ios/Runner/Info.plist

<key>LSApplicationQueriesSchemes</key>
<array>
        <string>https</string>
        <string>http</string>
        <string>tel</string>
        <string>mailto</string>
</array>

ב-Android Flutter, מוסיפים את השורות הבאות ל-Manifest.xml. מוסיפים את הצומת queries הזה כצאצא ישיר של הצומת manifest ועמית של הצומת application.

android/app/src/main/AndroidManifest.xml

<queries>
    <intent>
        <action android:name="android.intent.action.VIEW" />
        <data android:scheme="https" />
    </intent>
    <intent>
        <action android:name="android.intent.action.DIAL" />
        <data android:scheme="tel" />
    </intent>
    <intent>
        <action android:name="android.intent.action.SEND" />
        <data android:mimeType="*/*" />
    </intent>
</queries>

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

גישה לממשק ה-API של נתונים של YouTube

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

מנווטים אל Developer Console כדי ליצור פרויקט API:

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

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

בחירת YouTube Data API גרסה 3 במסוף GCP

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

5a877ea82b83ae42.png

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

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

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

החלון הקופץ של מפתח ה-API שנוצר, שבו מוצג מפתח ה-API שנוצר

להוספת קוד

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

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

lib/src/app_state.dart

import 'dart:collection';

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

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

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

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

  final String _flutterDevAccountId;
  late final YouTubeApi _api;

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

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

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

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

  final String key;
  final http.Client client;

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

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

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

lib/src/playlist_details.dart

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

import 'app_state.dart';

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

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

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

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

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

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

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

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

בשלב הבא, מוסיפים את רשימת הפלייליסטים.

lib/src/playlists.dart

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

import 'app_state.dart';

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

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

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

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

  final List<Playlist> items;

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

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

lib/main.dart

import 'dart:io';

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

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

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

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

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

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

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

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

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

הקוד הזה כמעט מוכן להרצת הקוד ב-Android וב-iOS. רק עוד דבר אחד – צריך לשנות את הקבוע youTubeApiKey בשורה 14 באמצעות מפתח ה-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 לצפייה בסרטון.

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

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

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

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

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

הבעיה בשולחן העבודה

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

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

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

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

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

$ flutter pub add split_view
Resolving dependencies...
+ split_view 3.1.0
  test_api 0.4.3 (0.4.8 available)
Changed 1 dependency!

חדש: ווידג'טים מותאמים

הדפוס שבו תשתמשו ב-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,
        useMaterial3: true,
      ).toTheme,
      darkTheme: FlexColorScheme.dark(
        scheme: FlexScheme.red,
        useMaterial3: true,
      ).toTheme,
      themeMode: ThemeMode.dark, // Or ThemeMode.System if you'd prefer
      debugShowCheckedModeBanner: false,
      routerConfig: _router,
    );
  }
}

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

lib/src/adaptive_playlists.dart

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

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

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

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

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

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

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

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

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

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

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

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

לאחר מכן, עורכים את קובץ src/lib/playlists.dart באופן הבא:

lib/src/playlists.dart

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

import 'app_state.dart';

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

  final PlaylistsListSelected playlistSelected;

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

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

typedef PlaylistsListSelected = void Function(Playlist playlist);

class _PlaylistsListView extends StatefulWidget {
  const _PlaylistsListView({
    required this.items,
    required this.playlistSelected,
  });

  final List<Playlist> items;
  final PlaylistsListSelected playlistSelected;

  @override
  State<_PlaylistsListView> createState() => _PlaylistsListViewState();
}

class _PlaylistsListViewState extends State<_PlaylistsListView> {
  late ScrollController _scrollController;

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

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

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      controller: _scrollController,
      itemCount: widget.items.length,
      itemBuilder: (context, index) {
        var playlist = widget.items[index];
        return Padding(
          padding: const EdgeInsets.all(8.0),
          child: ListTile(
            leading: Image.network(
              playlist.snippet!.thumbnails!.default_!.url!,
            ),
            title: Text(playlist.snippet!.title!),
            subtitle: Text(
              playlist.snippet!.description!,
            ),
            onTap: () {
              widget.playlistSelected(playlist);
            },
          ),
        );
      },
    );
  }
}

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

ההשקה של ScrollController מעניינת כי היא נדרשת, מפני שבפריסה רחבה יש לכם שני ווידג'טים של ListView זה לצד זה. בטלפון נייד, מקובל להשתמש ב-ListView אחד, ולכן יכול להיות מקש 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 כדי להוסיף את הכותרות הנדרשות של שיתוף משאבים בין מקורות. מציגים טרמינל ויוצרים שרת אינטרנט של Drt באופן הבא:

$ 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)
    };
  }
}

עכשיו תוכלו לפזר את ההתאמות האלה ברחבי ה-codebase:

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) {
          if (authClient != null) {
            context.read<AuthedUserPlaylists>().authClient = authClient;
            context.go('/');
          }
        });
      }
    });
  }

  late final GoogleSignIn _googleSignIn;

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

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

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

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

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

  Uri? _authUrl;

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

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

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

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

lib/src/app_state.dart

import 'dart:collection';

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

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

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

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

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

  YouTubeApi? _api;                                     // Convert to optional

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

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

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

// Delete the now unused _ApiKeyClient class

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

lib/src/playlist_details.dart

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

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

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

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

lib/src/playlists.dart

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

  final PlaylistsListSelected playlistSelected;

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

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

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

lib/main.dart

// Drop dart:io import

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

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

// Drop flutterDevAccountId and youTubeApiKey

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

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

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

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

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

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

השינויים בקובץ הזה משקפים את המעבר מהצגת הפלייליסטים של Flutter ב-YouTube בלבד, ועד להצגת הפלייליסטים המאומתים של המשתמשים המאומתים. הקוד הושלם, אבל עדיין נדרשת סדרה של שינויים לקובץ הזה ולקבצים שמתחת לאפליקציות 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 (אפליקציה לשולחן העבודה).

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

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

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

הפעולה הזו יוצרת את Client-ID ואת סוד הלקוח שצריך להוסיף ל-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 Platform Console:

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

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

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

הגדרה של google_sign_in ל-iOS

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

הקצר הזה. התשובות שלך יעזרו לנו להשתפר. בחירת סוג האפליקציה ל-iOS

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

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

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

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

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

מורידים את הקובץ .plist שנוצר. השם שלו מבוסס על מזהה הלקוח שנוצר. משנים את השם של הקובץ שהורד ל-GoogleService-Info.plist, ואז גוררים אותו לעורך ה-Xcode שפועל לצד הקובץ Info.plist שנמצא בקטע Runner/Runner בסרגל הניווט השמאלי. בתיבת הדו-שיח של האפשרויות ב-Xcode, בוחרים באפשרות העתקת פריטים במידת הצורך, יצירת הפניות לתיקייה והוספה אל היעד .

הוספת קובץ ה-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 באופן הבא:

מתן שם למזהה הלקוח של אפליקציית האינטרנט

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

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

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