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

1. บทนำ

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

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

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

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

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

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

แอปที่ทำงานเสร็จแล้วบนโปรแกรมจำลองของ Android

แอปที่ทำงานเสร็จแล้วในโปรแกรมจำลอง iOS

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

แอปที่เสร็จสิ้นซึ่งทำงานใน macOS

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

คุณต้องการเรียนรู้อะไรจาก Codelab นี้

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

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 แล้วใช้เครื่องมือของโปรเจ็กต์เพื่อเรียกใช้แอปพลิเคชัน จากขั้นตอนก่อนหน้านี้ การเรียกใช้แอปพลิเคชันบนเดสก์ท็อปควรเป็นตัวเลือกเดียวที่สามารถใช้งานได้

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

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

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

ตอนนี้คุณควรจะเห็นว่าแอปทำงานอยู่ เนื้อหาจำเป็นต้องอัปเดต

หากต้องการอัปเดตเนื้อหา ให้อัปเดตโค้ดใน lib/main.dart ด้วยรหัสต่อไปนี้ หากต้องการเปลี่ยนสิ่งที่แอปแสดง ให้โหลดซ้ำแบบ Hot

  • หากคุณเรียกใช้แอปโดยใช้บรรทัดคำสั่ง ให้พิมพ์ r ในคอนโซลเพื่อ Hot Reload
  • ถ้าคุณเรียกใช้แอปโดยใช้ 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 ขณะที่ตัวเลือกที่ 2 (ใช้ได้เฉพาะในเมธอด build ของวิดเจ็ต) จะดึงออบเจ็กต์ Theme จากอาร์กิวเมนต์ BuildContext

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

การดึงข้อมูล Theme จาก BuildContext มีวัตถุประสงค์เพื่อการตัดสินใจติดตั้งใช้งานโดยมีธีมเป็นศูนย์กลาง ตัวอย่างสําคัญของการตัดสินใจว่าจะใช้แถบเลื่อนวัสดุหรือแถบเลื่อนคูเปอร์ติโนตามที่อธิบายไว้ใน 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: ระบุวิธีการข้ามไปยังวิดีโอจากเพลย์ลิสต์ ตามที่แสดงในทรัพยากร Dependency ที่ได้รับการแก้ไขแล้ว url_launcher มีการติดตั้งใช้งานสำหรับ Windows, macOS, Linux และเว็บ นอกเหนือจาก Android และ iOS เริ่มต้น การใช้แพ็กเกจนี้หมายความว่าคุณไม่จำเป็นต้องสร้างแพลตฟอร์มเฉพาะสำหรับฟังก์ชันการทำงานนี้
  • flex_color_scheme: ให้รูปแบบสีเริ่มต้นที่ดีแก่แอป ดูข้อมูลเพิ่มเติมได้ในเอกสารประกอบเกี่ยวกับ flex_color_scheme API
  • go_router: ใช้การนำทางระหว่างหน้าจอต่างๆ แพ็กเกจนี้มี API ตาม URL เพื่อความสะดวกสำหรับการนำทางโดยใช้เราเตอร์ของ 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>

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

ไปที่ Developer Console เพื่อสร้างโปรเจ็กต์ API โดยทำดังนี้

การแสดงคอนโซล GCP ในระหว่างขั้นตอนการสร้างโปรเจ็กต์

เมื่อมีโปรเจ็กต์แล้ว ให้ไปที่หน้าไลบรารี API ในช่องค้นหา ให้ป้อน "youtube" แล้วเลือก youtube data api v3

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

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

5a877ea82b83ae42.png

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

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

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

ป๊อปอัปคีย์ API ที่สร้างขึ้นซึ่งแสดงคีย์ API ที่สร้างขึ้น

เพิ่มโค้ด

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

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

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

วิดีโอที่เลือกซึ่งเล่นอยู่ในโปรแกรมเล่น 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 หรือจะแสดงผลแบบแคบโดยไม่มีวิดเจ็ตดังกล่าว

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

จากนั้นแก้ไขไฟล์ src/lib/playlists.dart ดังนี้

lib/src/playlists.dart

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

import 'app_state.dart';

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

  final PlaylistsListSelected playlistSelected;

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

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

typedef PlaylistsListSelected = void Function(Playlist playlist);

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

  final List<Playlist> items;
  final PlaylistsListSelected playlistSelected;

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

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

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

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

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

มีการเปลี่ยนแปลงมากมายในไฟล์นี้ นอกจากการแนะนำ Callback ที่เลือกจากรายการที่กล่าวไปแล้ว และการเลิกใช้วิดเจ็ต 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 รายการดังนี้

$ 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.

มีทรัพยากร Dependency บางอย่างที่ไม่จำเป็นอีกต่อไป ตัดข้อความเหล่านี้ออก

$ 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 ที่ได้โดยทำดังนี้

$ 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 นี้ แต่เฉพาะเมื่อทำงานภายในเว็บเบราว์เซอร์เท่านั้น

วิดเจ็ตแบบปรับเปลี่ยนได้

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

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

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

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

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

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

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

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

อัปเดตโค้ด

เริ่มการอัปเดตด้วยการสร้างวิดเจ็ต 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,
    );
  }
}

การเปลี่ยนแปลงในไฟล์นี้แสดงถึงการเปลี่ยนแปลงจากการแสดงเพียงเพลย์ลิสต์ YouTube ของ Flutter ไปเป็นการแสดงเพลย์ลิสต์ของผู้ใช้ที่ตรวจสอบสิทธิ์แล้ว แม้ว่าโค้ดจะเสร็จสมบูรณ์ แต่ก็ยังมีการแก้ไขที่ต้องทำในไฟล์นี้อีกมากมาย รวมถึงไฟล์ภายใต้แอป Runner ที่เกี่ยวข้องเพื่อกำหนดค่าแพ็กเกจ google_sign_in และ googleapis_auth สำหรับการตรวจสอบสิทธิ์อย่างถูกต้อง

ตอนนี้แอปจะแสดงเพลย์ลิสต์ YouTube จากผู้ใช้ที่ตรวจสอบสิทธิ์แล้ว เมื่อมีฟีเจอร์ครบถ้วนแล้ว คุณต้องเปิดใช้การตรวจสอบสิทธิ์ โดยกำหนดค่าแพ็กเกจ google_sign_in และ googleapis_auth หากต้องการกำหนดค่าแพ็กเกจ คุณต้องเปลี่ยนไฟล์ main.dart และไฟล์สำหรับแอป Runner

กำลังกำหนดค่า googleapis_auth

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

หน้าข้อมูลเข้าสู่ระบบของโปรเจ็กต์ API ในคอนโซล GCP

การดำเนินการนี้จะสร้างป๊อปอัปที่คุณรับทราบด้วยการกดปุ่มลบ:

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

จากนั้นสร้างรหัสไคลเอ็นต์ 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 Platform ดังนี้

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

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

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

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

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

ที่ใช้เวลาเพียง 2 นาที การเลือกประเภทแอปพลิเคชัน iOS

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

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

วิธีค้นหาตัวระบุ Bundle ใน Xcode

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

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

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

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

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

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

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