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

แอปที่ปรับขนาดได้บน Flutter

เกี่ยวกับ Codelab นี้

subjectอัปเดตล่าสุดเมื่อ มิ.ย. 3, 2025
account_circleเขียนโดย Brett Morgan

1 บทนำ

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

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

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

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

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

คุณต้องใช้ซอฟต์แวร์ 2 อย่างในการฝึกอบรมนี้ ได้แก่ Flutter SDK และเครื่องมือแก้ไข

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

  • อุปกรณ์ 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 ของ IDE นั้นๆ

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

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

In order to run your application, type:

  $ cd adaptive_app
  $ flutter run

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

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

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

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

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

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

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

  • หากคุณเรียกใช้แอปโดยใช้บรรทัดคำสั่ง ให้พิมพ์ r ในคอนโซลเพื่อโหลดซ้ำขณะทำงาน
  • หากคุณเรียกใช้แอปโดยใช้ IDE แอปจะโหลดซ้ำเมื่อคุณบันทึกไฟล์

lib/main.dart

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

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

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

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

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

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

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

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

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

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

การแสดงคุณสมบัติหน้าต่างในโปรแกรมจำลอง Android

การแสดงพร็อพเพอร์ตี้หน้าต่างในโปรแกรมจำลอง iOS

และนี่คือโค้ดเดียวกันที่ทำงานบน macOS โดยค่าเริ่มต้นและภายใน Chrome ซึ่งทำงานบน macOS อีกครั้ง

การแสดงพร็อพเพอร์ตี้หน้าต่างใน macOS

การแสดงพร็อพเพอร์ตี้หน้าต่างในเบราว์เซอร์ Chrome

สิ่งที่สำคัญที่ต้องสังเกตคือเมื่อดูครั้งแรก Flutter พยายามปรับเนื้อหาให้เหมาะกับจอแสดงผลที่ใช้อยู่ แล็ปท็อปที่ใช้ถ่ายภาพหน้าจอเหล่านี้มีจอแสดงผล Mac ความละเอียดสูง ด้วยเหตุนี้ทั้งแอปเวอร์ชัน macOS และเว็บจึงแสดงผลที่อัตราส่วนพิกเซลของอุปกรณ์ 2 ส่วนใน iPhone 12 คุณจะเห็นอัตราส่วน 3 และ 2.63 ใน Pixel 2 ในทุกกรณี ข้อความที่แสดงจะคล้ายกันโดยประมาณ ซึ่งช่วยให้งานของเราในฐานะนักพัฒนาซอฟต์แวร์ง่ายขึ้นมาก

สิ่งที่ควรทราบอีกอย่างคือ 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
+ flex_color_scheme 8.2.0
+ flex_seed_scheme 3.5.1
+ flutter_web_plugins 0.0.0 from sdk flutter
+ go_router 15.1.2
+ googleapis 14.0.0
+ http 1.4.0
+ http_parser 4.1.2
  leak_tracker 10.0.9 (11.0.1 available)
  leak_tracker_flutter_testing 3.0.9 (3.0.10 available)
  leak_tracker_testing 3.0.1 (3.0.2 available)
  lints 5.1.1 (6.0.0 available)
+ logging 1.3.0
  material_color_utilities 0.11.1 (0.12.0 available)
  meta 1.16.0 (1.17.0 available)
+ nested 1.0.0
+ plugin_platform_interface 2.1.8
+ provider 6.1.5
  test_api 0.7.4 (0.7.6 available)
+ typed_data 1.4.0
+ url_launcher 6.3.1
+ url_launcher_android 6.3.16
+ url_launcher_ios 6.3.3
+ url_launcher_linux 3.2.1
+ url_launcher_macos 3.2.2
+ url_launcher_platform_interface 2.3.2
+ url_launcher_web 2.4.1
+ url_launcher_windows 3.1.4
  vector_math 2.1.4 (2.1.5 available)
+ web 1.1.1
Changed 22 dependencies!
8 packages have newer versions incompatible with dependency constraints.
Try `flutter pub outdated` for more information.

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

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

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

ปลั๊กอิน url_launcher กำหนดให้ต้องกำหนดค่าแอปพลิเคชันรันไทม์ Android และ iOS ในโปรแกรมรันไทม์ 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

เข้าถึง YouTube Data API

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

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

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

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

การเลือก YouTube Data API เวอร์ชัน 3 ในคอนโซล GCP

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

5a877ea82b83ae42.png

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

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

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

ป๊อปอัป &quot;สร้างคีย์ API แล้ว&quot; ที่แสดงคีย์ API ที่สร้างขึ้น

เพิ่มโค้ด

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

เพิ่มไฟล์ต่อไปนี้ โดยเริ่มจากออบเจ็กต์สถานะสำหรับแอป

lib/src/app_state.dart

import 'dart:collection';

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

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

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

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

 
final String _flutterDevAccountId;
 
late final YouTubeApi _api;

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

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

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

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

 
final String key;
 
final http.Client client;

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

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

ถัดไป ให้เพิ่มหน้ารายละเอียดเพลย์ลิสต์แต่ละหน้า

lib/src/playlist_details.dart

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

import 'app_state.dart';

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

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

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

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

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

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

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

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

ถัดไป ให้เพิ่มรายการเพลย์ลิสต์

lib/src/playlists.dart

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

import 'app_state.dart';

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

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

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

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

 
final List<Playlist> items;

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

และแทนที่เนื้อหาของไฟล์ main.dart ดังนี้

lib/main.dart

import 'dart:io';

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

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

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

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

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

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

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

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

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

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

lib/main.dart

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

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

macos/Runner/DebugProfile.entitlements

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

macos/Runner/Release.entitlements

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

เรียกใช้แอป

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

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

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

วิดีโอที่เลือกกำลังเล่นอยู่ในโปรแกรมเล่น YouTube

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

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

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

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

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

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

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

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

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

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

รูปแบบที่คุณจะใช้ในโค้ดแล็บนี้คือการแสดงวิดเจ็ตแบบปรับเปลี่ยนได้ซึ่งจะเลือกตัวเลือกการติดตั้งใช้งานตามแอตทริบิวต์ต่างๆ เช่น ความกว้างของหน้าจอ ธีมของแพลตฟอร์ม และอื่นๆ ในกรณีนี้ คุณจะต้องเปิดตัววิดเจ็ต AdaptivePlaylists ที่ทําให้ Playlists และ PlaylistDetails โต้ตอบกันใหม่ แก้ไขไฟล์ lib/main.dart ดังนี้

lib/main.dart

import 'dart:io';

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

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

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

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

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

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

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

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

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

ถัดไป ให้สร้างไฟล์สําหรับวิดเจ็ต AdaptivePlaylist โดยทําดังนี้

lib/src/adaptive_playlists.dart

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

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

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

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

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

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

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

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

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

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

ไฟล์นี้น่าสนใจเนื่องจากเหตุผลหลายประการ ประการแรกคือการใช้ทั้งความกว้างของหน้าต่าง (โดยใช้ MediaQuery.of(context).size.width) และคุณกำลังตรวจสอบธีม (โดยใช้ Theme.of(context).platform) เพื่อตัดสินใจว่าจะแสดงเลย์เอาต์แบบกว้างที่มีวิดเจ็ต SplitView หรือแสดงแบบแคบโดยไม่มีวิดเจ็ต

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

ขณะนี้มีบางรายการที่ต้องพึ่งพาซึ่งไม่จำเป็นอีกต่อไป โดยตัดวิดีโอตามขั้นตอนต่อไปนี้

$ dart pub remove args shelf_router
Resolving dependencies...
  args 2.4.2 (from direct dependency to transitive dependency)
  js 0.6.7 (0.7.0 available)
  lints 2.1.1 (3.0.0 available)
These packages are no longer being depended on:
- http_methods 1.1.1
- shelf_router 1.1.4
Changed 3 dependencies!
2 packages have newer versions incompatible with dependency constraints.
Try `dart pub outdated` for more information.

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

yt_cors_proxy/bin/server.dart

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

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

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

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

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

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

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

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

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

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

ถัดไป ให้แก้ไขโค้ด Flutter เพื่อใช้ประโยชน์จากพร็อกซี 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 รายการไว้ตามเดิม การดำเนินการนี้เกิดขึ้นโดยเจตนา เนื่องจากหากคุณปรับวิดเจ็ตข้อความ ฟังก์ชัน 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) {
         
final context = this.context;
         
if (authClient != null && context.mounted) {
           
context.read<AuthedUserPlaylists>().authClient = authClient;
           
context.go('/');
         
}
       
});
     
}
   
});
 
}

 
late final GoogleSignIn _googleSignIn;

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

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

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

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

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

 
Uri? _authUrl;

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

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

ไฟล์นี้ทํางานได้หลายอย่าง วิธี build ของ AdaptiveLogin จะทํางานหนัก การเรียกใช้ Platform.isXXX ของทั้ง kIsWeb และ dart:io จะเป็นการตรวจสอบแพลตฟอร์มรันไทม์ สำหรับ Android, iOS และเว็บ ระบบจะสร้างอินสแตนซ์ของวิดเจ็ต _GoogleSignInLogin ที่มีสถานะ สำหรับ Windows, macOS และ Linux ระบบจะสร้างอินสแตนซ์วิดเจ็ต _GoogleApisAuthLogin ที่มีสถานะ

ต้องใช้การกําหนดค่าเพิ่มเติมเพื่อใช้คลาสเหล่านี้ ซึ่งจะทําภายหลังการอัปเดตโค้ดเบสที่เหลือเพื่อใช้วิดเจ็ตใหม่นี้ เริ่มต้นด้วยการเปลี่ยนชื่อ FlutterDevPlaylists เป็น AuthedUserPlaylists เพื่อให้สอดคล้องกับวัตถุประสงค์ใหม่ และอัปเดตโค้ดเพื่อระบุว่า http.Client ผ่านการตรวจสอบแล้วหลังจากการสร้าง สุดท้ายนี้ คลาส _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 เพื่อเปิดใช้การโหลดซ้ำขณะทำงานและเครื่องมือแก้ไขข้อบกพร่องของ Dart VM อยู่แล้ว

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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