แอปแบบปรับอัตโนมัติใน Flutter

1. บทนำ

Flutter เป็นชุดเครื่องมือ UI ของ Google สำหรับการสร้างแอปพลิเคชันที่สวยงามและคอมไพล์มาพร้อมเครื่องสำหรับอุปกรณ์เคลื่อนที่ เว็บ และเดสก์ท็อปจากฐานของโค้ดรายการเดียว ใน Codelab นี้ คุณจะได้เรียนรู้วิธีสร้างแอป Flutter ที่ปรับให้เข้ากับแพลตฟอร์มที่แอปทำงาน ไม่ว่าจะเป็น Android, iOS, เว็บ, Windows, macOS หรือ Linux

สิ่งที่คุณจะได้เรียนรู้

  • วิธีขยายแอป Flutter ที่ออกแบบมาสำหรับอุปกรณ์เคลื่อนที่ให้ทำงานได้ในทั้ง 6 แพลตฟอร์มที่ Flutter รองรับ
  • API ของ Flutter ที่แตกต่างกันสำหรับการตรวจหาแพลตฟอร์มและเวลาที่ควรใช้ API แต่ละรายการ
  • การปรับตัวให้เข้ากับข้อจำกัดและความคาดหวังในการเรียกใช้แอปบนเว็บ
  • วิธีใช้แพ็กเกจต่างๆ ร่วมกันเพื่อรองรับแพลตฟอร์มทั้งหมดของ Flutter

สิ่งที่คุณจะสร้าง

ในโค้ดแล็บนี้ คุณจะได้สร้างแอป Flutter สำหรับ Android และ iOS ในตอนแรก ซึ่งจะสำรวจเพลย์ลิสต์ YouTube ของ Flutter จากนั้นคุณจะปรับแอปพลิเคชันนี้ให้ทำงานบนแพลตฟอร์มเดสก์ท็อปทั้ง 3 แพลตฟอร์ม (Windows, macOS และ Linux) ได้โดยการแก้ไขวิธีแสดงข้อมูลตามขนาดของหน้าต่างแอปพลิเคชัน จากนั้นคุณจะปรับแอปพลิเคชันสำหรับเว็บโดยทำให้ข้อความที่แสดงในแอปเลือกได้ตามที่ผู้ใช้เว็บคาดหวัง สุดท้าย คุณจะเพิ่มการตรวจสอบสิทธิ์ลงในแอปเพื่อให้สามารถสำรวจเพลย์ลิสต์ของคุณเองได้ ซึ่งแตกต่างจากเพลย์ลิสต์ที่ทีม Flutter สร้างขึ้น ซึ่งต้องใช้วิธีการตรวจสอบสิทธิ์ที่แตกต่างกันสำหรับ Android, iOS และเว็บ เทียบกับแพลตฟอร์มเดสก์ท็อป 3 แพลตฟอร์ม ได้แก่ Windows, macOS และ Linux

นี่คือภาพหน้าจอของแอป Flutter ใน Android และ iOS

แอปที่เสร็จสมบูรณ์แล้วซึ่งทำงานบนโปรแกรมจำลอง Android

แอปที่เสร็จสมบูรณ์แล้วซึ่งทำงานบนโปรแกรมจำลอง iOS

แอปนี้ที่ทำงานในโหมดจอกว้างบน macOS ควรมีลักษณะคล้ายกับภาพหน้าจอด้านล่าง

แอปที่เสร็จสมบูรณ์แล้วซึ่งทำงานบน macOS

โค้ดแล็บนี้มุ่งเน้นการเปลี่ยนแอป Flutter บนอุปกรณ์เคลื่อนที่ให้เป็นแอปที่ปรับเปลี่ยนตามอุปกรณ์ได้ซึ่งทำงานได้ในทั้ง 6 แพลตฟอร์มของ Flutter เราจะข้ามแนวคิดและบล็อกโค้ดที่ไม่เกี่ยวข้อง และจะให้คุณคัดลอกและวางได้

คุณต้องการเรียนรู้อะไรจากโค้ดแล็บนี้

ฉันเพิ่งเริ่มศึกษาหัวข้อนี้และต้องการภาพรวมที่ดี ฉันมีความรู้เกี่ยวกับหัวข้อนี้อยู่บ้าง แต่ต้องการทบทวน ฉันกำลังมองหาโค้ดตัวอย่างที่จะใช้ในโปรเจ็กต์ ฉันกำลังมองหาคำอธิบายเกี่ยวกับเรื่องที่เฉพาะเจาะจง

2. ตั้งค่าสภาพแวดล้อมในการพัฒนา Flutter

คุณต้องใช้ซอฟต์แวร์ 2 อย่างเพื่อทำแล็บนี้ให้เสร็จสมบูรณ์ ได้แก่ Flutter SDK และโปรแกรมแก้ไข

คุณเรียกใช้ Codelab ได้โดยใช้อุปกรณ์ต่อไปนี้

  • อุปกรณ์ Android หรือ iOS จริงที่เชื่อมต่อกับคอมพิวเตอร์และตั้งค่าเป็นโหมดนักพัฒนาแอป
  • โปรแกรมจำลอง iOS (ต้องติดตั้งเครื่องมือ Xcode)
  • โปรแกรมจำลอง Android (ต้องตั้งค่าใน 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 ผ่าน UI

$ 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 แบบ Boilerplate เป็นแอปบนอุปกรณ์เคลื่อนที่ตามที่แสดงด้านล่าง หรือจะเปิดโปรเจ็กต์นี้ใน IDE แล้วใช้เครื่องมือของ 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 ในขณะเดียวกัน คุณจะเห็นอัตราส่วน 3 ใน iPhone 12 และ 2.63 ใน Pixel 2 ในทุกกรณี ข้อความที่แสดงจะคล้ายกันโดยประมาณ ซึ่งช่วยให้เราในฐานะนักพัฒนาแอปทำงานได้ง่ายขึ้นมาก

ประเด็นที่ 2 ที่ควรทราบคือตัวเลือก 2 รายการสำหรับการตรวจสอบว่าโค้ดทำงานบนแพลตฟอร์มใดจะส่งผลให้มีค่าที่แตกต่างกัน ตัวเลือกแรกจะตรวจสอบออบเจ็กต์ Platform ที่นำเข้าจาก dart:io ส่วนตัวเลือกที่ 2 (ใช้ได้เฉพาะภายในเมธอด build ของวิดเจ็ต) จะดึงออบเจ็กต์ Theme จากอาร์กิวเมนต์ BuildContext

เหตุผลที่ 2 วิธีนี้แสดงผลลัพธ์ต่างกันก็คือความตั้งใจของทั้ง 2 วิธีนั้นแตกต่างกัน ออบเจ็กต์ Platform ที่นำเข้าจาก dart:io มีไว้เพื่อใช้ในการตัดสินใจที่ไม่ขึ้นอยู่กับตัวเลือกการแสดงผล ตัวอย่างที่ชัดเจนที่สุดคือการตัดสินใจว่าจะใช้ปลั๊กอินใด ซึ่งอาจมีหรือไม่มีการจับคู่การติดตั้งใช้งานดั้งเดิมสำหรับแพลตฟอร์มจริงที่เฉพาะเจาะจง

การดึงThemeจากBuildContextมีไว้สำหรับการตัดสินใจในการติดตั้งใช้งานที่เน้นธีมเป็นหลัก ตัวอย่างที่ชัดเจนคือการตัดสินใจว่าจะใช้แถบเลื่อน Material หรือแถบเลื่อน Cupertino ตามที่อธิบายไว้ใน Slider.adaptive

ในส่วนถัดไป คุณจะได้สร้างแอปสำรวจเพลย์ลิสต์ YouTube พื้นฐานที่ได้รับการเพิ่มประสิทธิภาพสำหรับ Android และ iOS โดยเฉพาะ ในส่วนต่อไปนี้ คุณจะเพิ่มการดัดแปลงต่างๆ เพื่อให้แอปทำงานได้ดีขึ้นบนเดสก์ท็อปและเว็บ

4. สร้างแอปบนมือถือ

เพิ่มแพ็กเกจ

ในแอปนี้ คุณจะได้ใช้แพ็กเกจ Flutter ที่หลากหลายเพื่อรับสิทธิ์เข้าถึง YouTube Data API, การจัดการสถานะ และการปรับแต่งธีม

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

คำสั่งนี้จะเพิ่มแพ็กเกจจำนวนหนึ่งลงในแอปพลิเคชัน

  • googleapis: ไลบรารี Dart ที่สร้างขึ้นซึ่งให้สิทธิ์เข้าถึง Google APIs
  • http: ไลบรารีสำหรับสร้างคำขอ HTTP ที่ซ่อนความแตกต่างระหว่างเบราว์เซอร์เนทีฟกับเว็บเบราว์เซอร์
  • provider: จัดการสถานะ
  • url_launcher: เป็นวิธีข้ามไปยังวิดีโอจากเพลย์ลิสต์ ดังที่แสดงจาก Dependencies ที่แก้ไขแล้ว url_launcher มีการติดตั้งใช้งานสำหรับ Windows, macOS, Linux และเว็บ นอกเหนือจาก Android และ iOS เริ่มต้น การใช้แพ็กเกจนี้หมายความว่าคุณไม่จำเป็นต้องสร้างแพลตฟอร์มเฉพาะสำหรับฟังก์ชันการทำงานนี้
  • flex_color_scheme: ทำให้แอปมีรูปแบบสีเริ่มต้นที่สวยงาม ดูข้อมูลเพิ่มเติมได้ที่เอกสารประกอบเกี่ยวกับ flex_color_schemeAPI
  • go_router: ใช้การไปยังส่วนต่างๆ ระหว่างหน้าจอต่างๆ แพ็กเกจนี้มี API ที่สะดวกและอิงตาม URL สำหรับการไปยังส่วนต่างๆ โดยใช้ Router ของ Flutter

การกำหนดค่าแอปบนอุปกรณ์เคลื่อนที่สำหรับ url_launcher

ปลั๊กอิน url_launcher ต้องมีการกำหนดค่าแอปพลิเคชัน Runner ของ Android และ iOS ในโปรแกรมเรียกใช้ Flutter ของ iOS ให้เพิ่มบรรทัดต่อไปนี้ลงใน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 ในช่องค้นหา ให้ป้อน "youtube" แล้วเลือก youtube data api v3

การเลือก YouTube Data API v3 ในคอนโซล GCP

เปิดใช้ API ในหน้ารายละเอียดของ YouTube Data API v3

5a877ea82b83ae42.png

เมื่อเปิดใช้ API แล้ว ให้ไปที่หน้าข้อมูลเข้าสู่ระบบ แล้วสร้างคีย์ API

สร้างข้อมูลเข้าสู่ระบบในคอนโซล GCP

หลังจากผ่านไป 2-3 วินาที คุณควรเห็นกล่องโต้ตอบที่มีคีย์ API ใหม่ คุณจะได้ใช้คีย์นี้ในไม่ช้า

ป๊อปอัป &quot;สร้างคีย์ API แล้ว&quot; ที่แสดงคีย์ 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 ด้วยคีย์ API ของ YouTube ที่สร้างขึ้นในขั้นตอนก่อนหน้า

lib/main.dart

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

หากต้องการเรียกใช้แอปนี้ใน macOS คุณต้องเปิดใช้แอปเพื่อส่งคำขอ HTTP ดังนี้ แก้ไขทั้งไฟล์ DebugProfile.entitlements และ Release.entitilements ดังนี้

macos/Runner/DebugProfile.entitlements

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

macos/Runner/Release.entitlements

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

เรียกใช้แอป

ตอนนี้คุณมีแอปพลิเคชันที่สมบูรณ์แล้ว คุณควรจะเรียกใช้แอปพลิเคชันในโปรแกรมจำลอง Android หรือโปรแกรมจำลอง iPhone ได้สำเร็จ คุณจะเห็นรายการเพลย์ลิสต์ของ Flutter เมื่อเลือกเพลย์ลิสต์ คุณจะเห็นวิดีโอในเพลย์ลิสต์นั้น และสุดท้ายหากคลิกปุ่มเล่น ระบบจะเปิดประสบการณ์การใช้งาน YouTube ให้คุณดูวิดีโอ

แอปที่แสดงเพลย์ลิสต์สำหรับบัญชี YouTube ของ FlutterDev

การแสดงวิดีโอในเพลย์ลิสต์ที่เฉพาะเจาะจง

วิดีโอที่เลือกเล่นในเพลเยอร์ของ YouTube

อย่างไรก็ตาม หากพยายามเรียกใช้แอปนี้บนเดสก์ท็อป คุณจะเห็นว่าเลย์เอาต์ดูไม่ถูกต้องเมื่อขยายเป็นหน้าต่างขนาดเดสก์ท็อปปกติ คุณจะดูวิธีปรับตัวให้เข้ากับเรื่องนี้ได้ในขั้นตอนถัดไป

5. ปรับให้เหมาะกับเดสก์ท็อป

ปัญหาเกี่ยวกับเดสก์ท็อป

หากคุณเรียกใช้แอปบนแพลตฟอร์มเดสก์ท็อปดั้งเดิมอย่าง Windows, macOS หรือ Linux คุณจะพบปัญหาที่น่าสนใจ วิธีนี้ได้ผล แต่ดู...แปลกๆ

แอปที่ทำงานบน macOS แสดงรายการเพลย์ลิสต์ที่มีสัดส่วนผิดปกติ

วิดีโอในเพลย์ลิสต์บน macOS

วิธีแก้ไขปัญหานี้คือการเพิ่มมุมมองแบบแยก โดยแสดงเพลย์ลิสต์ทางด้านซ้ายและวิดีโอทางด้านขวา อย่างไรก็ตาม คุณต้องการให้เลย์เอาต์นี้ทำงานเมื่อโค้ดไม่ได้ทำงานบน Android หรือ iOS และหน้าต่างกว้างพอ วิธีการต่อไปนี้แสดงวิธีใช้ความสามารถนี้

ก่อนอื่น ให้เพิ่มแพ็กเกจ split_view เพื่อช่วยในการสร้างเลย์เอาต์

$ flutter pub add split_view
Resolving dependencies...
Downloading packages...
  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.

เปิดตัววิดเจ็ตแบบปรับอัตโนมัติ

รูปแบบที่คุณจะใช้ในโค้ดแล็บนี้คือการแนะนำวิดเจ็ตแบบปรับเปลี่ยนได้ซึ่งจะเลือกการใช้งานตามแอตทริบิวต์ต่างๆ เช่น ความกว้างของหน้าจอ ธีมของแพลตฟอร์ม และอื่นๆ ในกรณีนี้ คุณจะแนะนำวิดเจ็ต 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 หรือการแสดงผลแบบแคบที่ไม่มีวิดเจ็ต

ประการที่ 2 ส่วนนี้จะจัดการกับการจัดการการนำทางที่ฮาร์ดโค้ด โดยจะแสดงอาร์กิวเมนต์การเรียกกลับในวิดเจ็ต Playlists โดยการเรียกกลับดังกล่าวจะแจ้งให้โค้ดโดยรอบทราบว่าผู้ใช้ได้เลือกเพลย์ลิสต์แล้ว จากนั้นโค้ดจะต้องทำงานเพื่อแสดงเพลย์ลิสต์นั้น ซึ่งจะเปลี่ยนความจำเป็นในการใช้ Scaffold ในวิดเจ็ต Playlists และ PlaylistDetails เนื่องจากไม่ได้เป็นระดับบนสุดแล้ว คุณจึงต้องนำ Scaffold ออกจากวิดเจ็ตเหล่านั้น

จากนั้นแก้ไขไฟล์ src/lib/playlists.dart ให้ตรงกับโค้ดต่อไปนี้

lib/src/playlists.dart

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

import 'app_state.dart';

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

  final PlaylistsListSelected playlistSelected;

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

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

typedef PlaylistsListSelected = void Function(Playlist playlist);

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

  final List<Playlist> items;
  final PlaylistsListSelected playlistSelected;

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

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

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

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

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

ไฟล์นี้มีการเปลี่ยนแปลงหลายอย่าง นอกเหนือจากการเปิดตัวplaylistSelectedการโทรกลับที่กล่าวถึงข้างต้นและการยกเลิกวิดเจ็ต Scaffold แล้ว วิดเจ็ต _PlaylistsListView จะเปลี่ยนจากแบบไม่มีสถานะเป็นแบบมีสถานะ การเปลี่ยนแปลงนี้จำเป็นเนื่องจากการเปิดตัว ScrollController ที่เป็นของตัวเองซึ่งต้องสร้างและทำลาย

การเปิดตัว ScrollController เป็นเรื่องที่น่าสนใจเนื่องจากจำเป็นต้องมีเพราะในเลย์เอาต์แบบกว้าง คุณจะมีวิดเจ็ต ListView 2 รายการอยู่ข้างกัน ในโทรศัพท์มือถือ โดยทั่วไปจะมี 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)
════════════════════════════════════════════════════════════════════════════════════════════════════

สร้างพร็อกซี 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 แล้วเพิ่มทรัพยากร Dependency ที่จำเป็น 2-3 รายการ

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

มีการอ้างอิงปัจจุบันที่ไม่จำเป็นอีกต่อไป โดยตัดดังนี้

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

จากนั้นแก้ไขเนื้อหาของไฟล์ server.dart ให้ตรงกับเนื้อหาต่อไปนี้

yt_cors_proxy/bin/server.dart

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

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

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

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

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

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

คุณเรียกใช้เซิร์ฟเวอร์นี้ได้โดยทำดังนี้

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

หรือจะสร้างเป็นอิมเมจ Docker แล้วเรียกใช้อิมเมจ Docker ที่ได้ดังนี้ก็ได้

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

จากนั้นแก้ไขโค้ด Flutter เพื่อใช้ประโยชน์จากพร็อกซี CORS นี้ แต่เฉพาะเมื่อเรียกใช้ภายในเว็บเบราว์เซอร์เท่านั้น

วิดเจ็ต 2 รายการที่ปรับเปลี่ยนได้

วิดเจ็ตคู่แรกคือวิธีที่แอปจะใช้พร็อกซี CORS

lib/src/adaptive_image.dart

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';

class AdaptiveImage extends StatelessWidget {
  AdaptiveImage.network(String url, {super.key}) {
    if (kIsWeb) {
      _url = Uri.parse(
        url,
      ).replace(host: 'localhost', port: 8080, scheme: 'http').toString();
    } else {
      _url = url;
    }
  }

  late final String _url;

  @override
  Widget build(BuildContext context) {
    return Image.network(_url);
  }
}

แอปนี้ใช้ค่าคงที่ kIsWeb เนื่องจากแพลตฟอร์มรันไทม์มีความแตกต่างกัน ส่วนวิดเจ็ตที่ปรับเปลี่ยนได้อีกอันจะเปลี่ยนแอปให้ทำงานเหมือนกับหน้าเว็บอื่นๆ ผู้ใช้เบราว์เซอร์คาดหวังให้เลือกข้อความได้

lib/src/adaptive_text.dart

import 'package:flutter/material.dart';

class AdaptiveText extends StatelessWidget {
  const AdaptiveText(this.data, {super.key, this.style});
  final String data;
  final TextStyle? style;

  @override
  Widget build(BuildContext context) {
    return switch (Theme.of(context).platform) {
      TargetPlatform.android || TargetPlatform.iOS => Text(data, style: style),
      _ => SelectableText(data, style: style),
    };
  }
}

ตอนนี้ ให้กระจายการดัดแปลงเหล่านี้ไปทั่วทั้งโค้ดเบส

lib/src/playlist_details.dart

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

import 'adaptive_image.dart';                                 // Add this line,
import 'adaptive_text.dart';                                  // And this line
import 'app_state.dart';

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

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

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

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

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

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

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

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

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

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

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

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

ในโค้ดด้านบน คุณได้ปรับวิดเจ็ต Image.network และ Text จากนั้นปรับPlaylistsวิดเจ็ต

lib/src/playlists.dart

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

import 'adaptive_image.dart';                                 // Add this line
import 'app_state.dart';

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

  final PlaylistsListSelected playlistSelected;

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

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

typedef PlaylistsListSelected = void Function(Playlist playlist);

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

  final List<Playlist> items;
  final PlaylistsListSelected playlistSelected;

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

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

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

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

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

คราวนี้คุณปรับเฉพาะวิดเจ็ต Image.network แต่ปล่อยให้วิดเจ็ต Text อีก 2 รายการเป็นเหมือนเดิม เราตั้งใจให้เป็นเช่นนี้เนื่องจากหากคุณปรับวิดเจ็ตข้อความ ฟังก์ชันการทำงานของ ListTile onTap จะถูกบล็อกเมื่อผู้ใช้แตะข้อความ

เรียกใช้แอปบนเว็บอย่างถูกต้อง

เมื่อพร็อกซี CORS ทำงาน คุณควรจะเรียกใช้แอปเวอร์ชันเว็บและมีลักษณะดังต่อไปนี้ได้

แอปที่ทำงานในเบราว์เซอร์ Chrome โดยมีภาพปกของ YouTube

7. การตรวจสอบสิทธิ์แบบปรับอัตโนมัติ

ในขั้นตอนนี้ คุณจะขยายแอปโดยให้แอปมีความสามารถในการตรวจสอบสิทธิ์ผู้ใช้ แล้วแสดงเพลย์ลิสต์ของผู้ใช้รายนั้น คุณจะต้องใช้ปลั๊กอินหลายตัวเพื่อครอบคลุมแพลตฟอร์มต่างๆ ที่แอปสามารถทำงานได้ เนื่องจาก Android, iOS, เว็บ, Windows, macOS และ Linux มีการจัดการ OAuth ที่แตกต่างกันมาก

เพิ่มปลั๊กอินเพื่อเปิดใช้การตรวจสอบสิทธิ์ด้วย Google

คุณจะติดตั้ง 3 แพ็กเกจเพื่อจัดการการตรวจสอบสิทธิ์ของ 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 แพ็กเกจที่ 2 จะทำหน้าที่เป็น Interop Shim ระหว่าง 2 แพ็กเกจ

อัปเดตโค้ด

เริ่มการอัปเดตโดยสร้างการแยกข้อมูลที่นำกลับมาใช้ใหม่ได้ใหม่ ซึ่งก็คือวิดเจ็ต 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 จะช่วยจัดการภาระหนัก การเรียกใช้ทั้ง kIsWeb และ dart:io ของ Platform.isXXX วิธีนี้จะตรวจสอบแพลตฟอร์มรันไทม์ สำหรับ 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

ซึ่งจะสร้างกล่องโต้ตอบที่คุณยอมรับโดยกดปุ่มลบ

ป๊อปอัป &quot;ลบข้อมูลเข้าสู่ระบบ&quot;

จากนั้นสร้างรหัสไคลเอ็นต์ OAuth โดยทำดังนี้

การสร้างรหัสไคลเอ็นต์ OAuth

เลือกแอปเดสก์ท็อปเป็นประเภทแอปพลิเคชัน

การเลือกประเภทแอปพลิเคชันของแอปเดสก์ท็อป

ยอมรับชื่อ แล้วคลิกสร้าง

การตั้งชื่อรหัสไคลเอ็นต์

ซึ่งจะสร้างรหัสไคลเอ็นต์และรหัสลับไคลเอ็นต์ที่คุณต้องเพิ่มลงใน lib/main.dart เพื่อกำหนดค่าโฟลว์ googleapis_auth รายละเอียดการติดตั้งใช้งานที่สำคัญคือโฟลว์ googleapis_auth ใช้เว็บเซิร์ฟเวอร์ชั่วคราวที่ทำงานบน localhost เพื่อบันทึกโทเค็น OAuth ที่สร้างขึ้น ซึ่งใน macOS จะต้องมีการแก้ไขไฟล์ macos/Runner/Release.entitlements ดังนี้

macos/Runner/Release.entitlements

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

คุณไม่จำเป็นต้องแก้ไขไฟล์ macos/Runner/DebugProfile.entitlements เนื่องจากมีสิทธิ์สำหรับ com.apple.security.network.server อยู่แล้วเพื่อเปิดใช้ Hot Reload และเครื่องมือแก้ไขข้อบกพร่องของ Dart VM

ตอนนี้คุณควรจะเรียกใช้แอปใน Windows, macOS หรือ Linux ได้แล้ว (หากคอมไพล์แอปในเป้าหมายเหล่านั้น)

แอปที่แสดงเพลย์ลิสต์สำหรับผู้ใช้ที่เข้าสู่ระบบ

กำหนดค่า google_sign_in สำหรับ Android

กลับไปที่หน้าข้อมูลเข้าสู่ระบบของโปรเจ็กต์ API แล้วสร้างรหัสไคลเอ็นต์ OAuth อีกรายการ แต่ครั้งนี้ให้เลือก Android:

การเลือกประเภทแอปพลิเคชัน Android

สำหรับส่วนที่เหลือของแบบฟอร์ม ให้กรอกชื่อแพ็กเกจด้วยแพ็กเกจที่ประกาศใน android/app/src/main/AndroidManifest.xml หากคุณทำตามวิธีการอย่างเคร่งครัด สถานะควรเป็นcom.example.adaptive_app แยกข้อมูลลายนิ้วมือใบรับรอง SHA-1 โดยใช้วิธีการจากหน้าความช่วยเหลือของ Google Cloud Console ดังนี้

การตั้งชื่อรหัสไคลเอ็นต์ Android

เพียงเท่านี้ก็เพียงพอที่จะทำให้แอปทำงานบน Android ได้ คุณอาจต้องเพิ่มไฟล์ JSON ที่สร้างขึ้นลงในชุดแอปพลิเคชัน ทั้งนี้ขึ้นอยู่กับ API ของ Google ที่คุณเลือกใช้

การเรียกใช้แอปใน Android

กำหนดค่า google_sign_in สำหรับ iOS

กลับไปที่หน้าข้อมูลเข้าสู่ระบบของโปรเจ็กต์ API แล้วสร้างรหัสไคลเอ็นต์ OAuth อีกรายการ แต่ครั้งนี้ให้เลือก iOS:

การเลือกประเภทแอปพลิเคชัน iOS

สำหรับส่วนที่เหลือของแบบฟอร์ม ให้กรอกรหัสชุดโดยเปิด ios/Runner.xcworkspace ใน Xcode ไปที่แถบนำทางของโปรเจ็กต์ เลือก Runner ในแถบนำทาง จากนั้นเลือกแท็บทั่วไป แล้วคัดลอกตัวระบุแพ็กเกจ หากคุณทำตาม Codelab นี้ทีละขั้นตอน ค่าควรเป็น com.example.adaptiveApp

สำหรับส่วนที่เหลือของแบบฟอร์ม ให้กรอกรหัสชุด เปิด ios/Runner.xcworkspace ใน Xcode ไปที่แถบนำทางของโปรเจ็กต์ ไปที่ Runner > แท็บทั่วไป คัดลอกตัวระบุชุด หากคุณทำตาม Codelab นี้ทีละขั้นตอน ค่าของแอตทริบิวต์นี้ควรเป็น com.example.adaptiveApp

วิธีค้นหารหัสชุดซอฟต์แวร์ใน Xcode

ตอนนี้ให้ข้ามรหัส App Store และรหัสทีมไปก่อน เนื่องจากไม่จำเป็นสำหรับการพัฒนาในเครื่อง

การตั้งชื่อรหัสไคลเอ็นต์ iOS

ดาวน์โหลดไฟล์ .plist ที่สร้างขึ้น โดยชื่อไฟล์จะอิงตามรหัสไคลเอ็นต์ที่คุณสร้าง เปลี่ยนชื่อไฟล์ที่ดาวน์โหลดเป็น GoogleService-Info.plist แล้วลากไฟล์นั้นลงในตัวแก้ไข Xcode ที่กำลังทำงานอยู่ข้างไฟล์ Info.plist ในส่วน Runner/Runner ในแถบนำทางด้านซ้าย สำหรับกล่องโต้ตอบตัวเลือกใน Xcode ให้เลือกคัดลอกรายการหากจำเป็น สร้างการอ้างอิงโฟลเดอร์ และเพิ่มไปยังเป้าหมาย Runner

การเพิ่มไฟล์ plist ที่สร้างขึ้นไปยังแอป iOS ใน Xcode

ออกจาก Xcode จากนั้นใน IDE ที่เลือก ให้เพิ่มข้อมูลต่อไปนี้ลงใน Info.plist

ios/Runner/Info.plist

<key>CFBundleURLTypes</key>
<array>
        <dict>
                <key>CFBundleTypeRole</key>
                <string>Editor</string>
                <key>CFBundleURLSchemes</key>
                <array>
                        <!-- TODO Replace this value: -->
                        <!-- Copied from GoogleService-Info.plist key REVERSED_CLIENT_ID -->
                        <string>com.googleusercontent.apps.TODO-REPLACE-ME</string>
                </array>
        </dict>
</array>

คุณต้องแก้ไขค่าให้ตรงกับรายการในไฟล์ GoogleService-Info.plist ที่สร้างขึ้น เรียกใช้แอป และหลังจากเข้าสู่ระบบแล้ว คุณควรเห็นเพลย์ลิสต์

แอปที่ทำงานบน iOS

กำหนดค่า google_sign_in สำหรับเว็บ

กลับไปที่หน้าข้อมูลเข้าสู่ระบบของโปรเจ็กต์ API แล้วสร้างรหัสไคลเอ็นต์ OAuth อีกรายการ แต่ครั้งนี้ให้เลือกเว็บแอปพลิเคชัน:

การเลือกประเภทเว็บแอปพลิเคชัน

สำหรับแบบฟอร์มส่วนที่เหลือ ให้กรอกต้นทางของ JavaScript ที่ได้รับอนุญาตดังนี้

การตั้งชื่อรหัสไคลเอ็นต์ของเว็บแอปพลิเคชัน

ซึ่งจะสร้างรหัสไคลเอ็นต์ เพิ่มแท็ก meta ต่อไปนี้ลงใน web/index.html ซึ่งอัปเดตให้รวมรหัสไคลเอ็นต์ที่สร้างขึ้น

web/index.html

<meta
  name="google-signin-client_id"
  content="YOUR_GOOGLE_SIGN_IN_OAUTH_CLIENT_ID.apps.googleusercontent.com"
/>

การเรียกใช้ตัวอย่างนี้ต้องมีการช่วยเหลือเล็กน้อย คุณต้องเรียกใช้พร็อกซี CORS ที่สร้างขึ้นในขั้นตอนก่อนหน้า และต้องเรียกใช้เว็บแอป Flutter ในพอร์ตที่ระบุในแบบฟอร์มรหัสไคลเอ็นต์ OAuth ของเว็บแอปพลิเคชันโดยใช้คำสั่งต่อไปนี้

ในเทอร์มินัล ให้เรียกใช้พร็อกซีเซิร์ฟเวอร์ 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 แบบปรับได้ซึ่งทำงานบนทั้ง 6 แพลตฟอร์มที่ Flutter รองรับ คุณปรับโค้ดเพื่อจัดการความแตกต่างในวิธีจัดวางหน้าจอ วิธีโต้ตอบกับข้อความ วิธีโหลดรูปภาพ และวิธีทำงานของการตรวจสอบสิทธิ์

คุณยังปรับเปลี่ยนสิ่งอื่นๆ ในแอปพลิเคชันได้อีกมากมาย ดูวิธีอื่นๆ ในการปรับโค้ดให้เข้ากับสภาพแวดล้อมต่างๆ ที่จะเรียกใช้ได้ที่การสร้างแอปที่ปรับเปลี่ยนได้