เกี่ยวกับ Codelab นี้
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 มีดังนี้
แอปนี้ที่ทำงานในโหมดหน้าจอกว้างบน 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 โดยตรงมีดังนี้
และนี่คือโค้ดเดียวกันที่ทำงานบน macOS โดยค่าเริ่มต้นและภายใน Chrome ซึ่งทำงานบน macOS อีกครั้ง
สิ่งที่สำคัญที่ต้องสังเกตคือเมื่อดูครั้งแรก 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 APIshttp
: ไลบรารีสําหรับสร้างคําขอ HTTP ที่จะซ่อนความแตกต่างระหว่างเบราว์เซอร์เนทีฟกับเว็บเบราว์เซอร์provider
: จัดการสถานะurl_launcher
: ระบุวิธีข้ามไปยังวิดีโอจากเพลย์ลิสต์ ดังที่แสดงจาก Dependency ที่แก้ไขแล้วurl_launcher
มีการใช้งานสำหรับ Windows, macOS, Linux และเว็บ นอกเหนือจาก Android และ iOS เริ่มต้น การใช้แพ็กเกจนี้หมายความว่าคุณไม่จําเป็นต้องสร้างแพลตฟอร์มเฉพาะสําหรับฟังก์ชันการทํางานนี้flex_color_scheme
: ทำให้แอปมีชุดสีเริ่มต้นที่สวยงาม ดูข้อมูลเพิ่มเติมได้ที่เอกสารประกอบของflex_color_scheme
APIgo_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
เมื่อสร้างโปรเจ็กต์แล้ว ให้ไปที่หน้าไลบรารี API ในช่องค้นหา ให้ป้อน "youtube" แล้วเลือก youtube data api v3
เปิดใช้ API ในหน้ารายละเอียดของ YouTube Data API เวอร์ชัน 3
เมื่อเปิดใช้ API แล้ว ให้ไปที่หน้าข้อมูลเข้าสู่ระบบ แล้วสร้างคีย์ API
หลังจากผ่านไป 2-3 วินาที คุณควรเห็นกล่องโต้ตอบที่มีคีย์ 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 เพื่อดูวิดีโอ
อย่างไรก็ตาม หากคุณพยายามเรียกใช้แอปนี้บนเดสก์ท็อป คุณจะเห็นเลย์เอาต์ดูไม่ถูกต้องเมื่อขยายเป็นหน้าต่างขนาดปกติของเดสก์ท็อป คุณจะดูวิธีปรับตัวได้ในขั้นตอนถัดไป
5 ปรับให้เหมาะกับเดสก์ท็อป
ปัญหาเกี่ยวกับเดสก์ท็อป
หากเรียกใช้แอปบนแพลตฟอร์มเดสก์ท็อปดั้งเดิมอย่าง Windows, macOS หรือ Linux คุณจะเห็นปัญหาที่น่าสนใจ ใช้งานได้ แต่ดูแปลกๆ
วิธีแก้ปัญหานี้คือการเพิ่มมุมมองแยก ซึ่งจะแสดงเพลย์ลิสต์ทางด้านซ้ายและวิดีโอทางด้านขวา อย่างไรก็ตาม คุณต้องการให้เลย์เอาต์นี้ทำงานเฉพาะเมื่อโค้ดไม่ทํางานบน 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 ตอนนี้ทุกอย่างควรทำงานได้ตามที่คาดไว้
6 ปรับให้เหมาะกับเว็บ
รูปภาพเหล่านั้นเป็นยังไง
การพยายามเรียกใช้แอปนี้บนเว็บตอนนี้แสดงให้เห็นว่าจำเป็นต้องทํางานเพิ่มเติมเพื่อปรับให้เข้ากับเว็บเบราว์เซอร์
หากเข้าไปดูในคอนโซลแก้ไขข้อบกพร่อง คุณจะเห็นคำแนะนำเล็กๆ น้อยๆ เกี่ยวกับสิ่งที่ต้องทำต่อไป
══╡ 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 ทำงานอยู่ คุณควรเรียกใช้แอปเวอร์ชันเว็บได้และมีลักษณะดังต่อไปนี้
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 โดยทำดังนี้
ซึ่งจะสร้างกล่องโต้ตอบที่คุณยอมรับโดยการกดปุ่ม "ลบ"
จากนั้นสร้างรหัสไคลเอ็นต์ 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/app/src/main/AndroidManifest.xml
หากทำตามวิธีการอย่างละเอียดแล้ว สถานะควรเป็น com.example.adaptive_app
ดึงข้อมูลลายนิ้วมือใบรับรอง SHA-1 โดยใช้วิธีการต่อไปนี้จากหน้าความช่วยเหลือของคอนโซล Google Cloud
การดำเนินการนี้เพียงพอที่จะทำให้แอปทำงานบน Android ได้ คุณอาจต้องเพิ่มไฟล์ JSON ที่สร้างขึ้นลงใน App Bundle ทั้งนี้ขึ้นอยู่กับ Google API ที่คุณใช้
กำหนดค่า google_sign_in
สำหรับ iOS
กลับไปที่หน้าข้อมูลเข้าสู่ระบบของโปรเจ็กต์ API แล้วสร้างรหัสไคลเอ็นต์ OAuth อื่น แต่คราวนี้ให้เลือก iOS:
กรอกรหัสแพ็กเกจในส่วนที่เหลือของแบบฟอร์มโดยเปิด ios/Runner.xcworkspace
ใน Xcode ไปที่ Project Navigator เลือก Runner ใน Navigator แล้วเลือกแท็บทั่วไป จากนั้นคัดลอกตัวระบุแพ็กเกจ หากทำตามขั้นตอนใน Codelab นี้ทีละขั้นตอน สถานะควรเป็น com.example.adaptiveApp
กรอกรหัสแพ็กเกจในส่วนที่เหลือของแบบฟอร์ม เปิด ios/Runner.xcworkspace
ใน Xcode ไปที่ Project Navigator ไปที่ Runner > แท็บทั่วไป คัดลอกรหัสชุดซอฟต์แวร์ หากคุณทําตามโค้ดแล็บนี้ทีละขั้นตอน ค่าควรเป็น com.example.adaptiveApp
โปรดละเว้นรหัส App Store และรหัสทีมในตอนนี้ เนื่องจากไม่จำเป็นสำหรับการพัฒนาในเครื่อง
ดาวน์โหลดไฟล์ .plist
ที่สร้างขึ้น โดยชื่อของไฟล์จะอิงตามรหัสไคลเอ็นต์ที่คุณสร้างขึ้น เปลี่ยนชื่อไฟล์ที่ดาวน์โหลดเป็น GoogleService-Info.plist
แล้วลากไฟล์นั้นลงในเครื่องมือแก้ไข Xcode ที่ทำงานอยู่ข้างไฟล์ Info.plist
ใต้ Runner/Runner
ในแถบนําทางด้านซ้าย สำหรับกล่องโต้ตอบตัวเลือกใน Xcode ให้เลือกคัดลอกรายการ หากจำเป็น สร้างการอ้างอิงโฟลเดอร์ และเพิ่มลงในเป้าหมาย Runner
ออกจาก 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
ที่สร้างขึ้น เรียกใช้แอปและหลังจากเข้าสู่ระบบแล้ว คุณควรเห็นเพลย์ลิสต์
กำหนดค่า 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".
หลังจากเข้าสู่ระบบอีกครั้ง คุณควรเห็นเพลย์ลิสต์ดังต่อไปนี้
8 ขั้นตอนถัดไป
ยินดีด้วย
คุณได้ทํา Codelab จนเสร็จสมบูรณ์และสร้างแอป Flutter แบบปรับเปลี่ยนได้ซึ่งทํางานบนแพลตฟอร์มทั้ง 6 แพลตฟอร์มที่ Flutter รองรับ คุณได้ปรับโค้ดให้จัดการกับความแตกต่างของเลย์เอาต์หน้าจอ วิธีที่ผู้ใช้โต้ตอบกับข้อความ วิธีที่ระบบโหลดรูปภาพ และวิธีที่การตรวจสอบสิทธิ์ทำงาน
ยังมีอีกหลายอย่างที่คุณสามารถปรับใช้ในแอปพลิเคชันได้ ดูวิธีอื่นๆ ในการปรับโค้ดให้เข้ากับสภาพแวดล้อมต่างๆ ที่โค้ดจะทํางานได้ที่การสร้างแอปที่ปรับเปลี่ยนได้