Aplikasi Adaptif di Flutter

1. Pengantar

Flutter adalah toolkit UI Google untuk membangun aplikasi yang menarik dan dikompilasi secara native dari satu codebase untuk perangkat seluler, web, dan desktop. Dalam codelab ini, Anda akan mempelajari cara membangun aplikasi Flutter yang dapat beradaptasi dengan platform yang menjalankannya, baik itu Android, iOS, web, Windows, macOS, ataupun Linux.

Yang akan Anda pelajari

  • Cara mengembangkan aplikasi Flutter yang didesain untuk perangkat seluler agar berfungsi di keenam platform yang didukung Flutter.
  • Berbagai Flutter API untuk mendeteksi platform dan kapan harus menggunakan setiap API.
  • Cara beradaptasi dengan batasan serta ekspektasi saat menjalankan aplikasi di web.
  • Cara menggunakan berbagai paket secara bersamaan untuk mendukung semua platform Flutter.

Yang akan Anda bangun

Dalam codelab ini, pertama-tama Anda akan membangun aplikasi Flutter untuk Android dan iOS yang menjelajahi playlist YouTube Flutter. Selanjutnya, Anda akan mengadaptasikan aplikasi ini agar berfungsi di tiga platform desktop (Windows, macOS, dan Linux) dengan mengubah cara informasi ditampilkan berdasarkan ukuran jendela aplikasi. Kemudian, Anda akan mengadaptasikan aplikasi untuk web dengan membuat teks yang ditampilkan di aplikasi dapat dipilih, seperti yang diharapkan pengguna web. Terakhir, Anda akan menambahkan autentikasi ke aplikasi sehingga Anda dapat menjelajahi Playlist Anda sendiri, bukan playlist yang dibuat tim Flutter. Hal ini memerlukan pendekatan autentikasi yang berbeda untuk Android, iOS, dan web, versus tiga platform desktop (Windows, macOS, dan Linux).

Berikut screenshot aplikasi Flutter di Android dan iOS:

Berikut screenshot aplikasi yang berjalan di macOS dalam tata letak layar lebar:

b424266e6fd4b3c3.png

Codelab ini berfokus untuk mentransformasi aplikasi Flutter seluler menjadi aplikasi adaptif yang berfungsi di keenam platform Flutter. Konsep dan blok kode yang tidak relevan akan dibahas sekilas, dan disediakan sehingga Anda cukup menyalin dan menempelkannya.

Apa yang ingin Anda pelajari dari codelab ini?

Saya baru mengenal topik ini, jadi saya ingin mendapatkan ringkasan yang jelas. Saya sedikit paham soal topik ini, tetapi saya perlu mengingat kembali. Saya sedang mencari kode contoh untuk digunakan dalam project saya. Saya sedang mencari penjelasan tentang sesuatu yang spesifik.

2. Menyiapkan lingkungan pengembangan Flutter Anda

Anda memerlukan dua software untuk menyelesaikan lab ini—Flutter SDK dan editor.

Anda dapat menjalankan codelab menggunakan perangkat berikut:

  • Perangkat Android atau iOS fisik yang terhubung ke komputer dan disetel ke Mode developer.
  • Simulator iOS (perlu menginstal alat Xcode).
  • Android Emulator (perlu penyiapan di Android Studio).
  • Browser (perlu Chrome untuk proses debug).
  • Aplikasi desktop Windows, Linux, atau macOS. Anda harus mengembangkan aplikasi di platform tempat Anda berencana untuk men-deploy-nya. Jadi, jika ingin mengembangkan aplikasi desktop Windows, Anda harus mengembangkannya di Windows untuk mengakses rantai build yang sesuai. Ada persyaratan spesifik per sistem operasi yang dibahas secara mendetail di docs.flutter.dev/desktop.

3. Memulai

Mengonfirmasi lingkungan pengembangan Anda

Cara termudah untuk memastikan semuanya siap untuk pengembangan adalah dengan menjalankan perintah berikut:

$ flutter doctor

Jika ada yang muncul tanpa tanda centang, jalankan perintah berikut untuk mendapatkan detail selengkapnya terkait masalah tersebut.

$ flutter doctor -v

Anda mungkin perlu menginstal alat developer untuk pengembangan perangkat seluler atau desktop. Untuk detail selengkapnya terkait cara mengonfigurasi alat berdasarkan sistem operasi host, lihat dokumentasi penginstalan Flutter.

Membuat project Flutter

Cara mudah untuk mulai menulis Flutter bagi aplikasi desktop adalah dengan menggunakan alat command line Flutter untuk membuat project Flutter. Atau, IDE Anda dapat menyediakan alur kerja untuk membuat project Flutter melalui UI-nya.

$ flutter create adaptive_app
Creating project adaptive_app
[Eliding listing of created files]
Running "flutter pub get" in adaptive_app...                     2,445ms
Wrote 128 files.

All done!
In order to run your application, type:

  $ cd adaptive_app
  $ flutter run

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

Untuk memastikan semuanya berfungsi, jalankan aplikasi Flutter boilerplate sebagai aplikasi seluler seperti yang ditunjukkan di bawah. Atau, buka project ini di IDE Anda, lalu gunakan alatnya untuk menjalankan aplikasi. Setelah melakukan langkah sebelumnya, satu-satunya opsi yang tersedia adalah menjalankannya sebagai aplikasi desktop.

$ flutter run
Launching lib/main.dart on iPhone 12 in debug mode...
Running Xcode build...
 └─Compiling, linking and signing...                        15.9s
Xcode build done.                                           41.2s
Syncing files to device iPhone 12...                               213ms

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

💪 Running with sound null safety 💪

An Observatory debugger and profiler on iPhone 12 is available at: http://127.0.0.1:60071/t9hy0pnIWgE=/
Activating Dart DevTools...                                         3.3s
The Flutter DevTools debugger and profiler on iPhone 12 is available at:
http://127.0.0.1:9101?uri=http://127.0.0.1:60071/t9hy0pnIWgE=/

Sekarang, Anda dapat melihat aplikasi berjalan. Ubah konten di lib/main.dart sebagai berikut, dan lakukan hot reload untuk mengupdate kontennya. Cara melakukan hot reload berubah bergantung pada apakah Anda menjalankan aplikasi melalui command line (ketik "r" di jendela konsol) atau melalui editor (dalam hal ini, Anda mungkin cukup menyimpan file untuk memicu hot reload).

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(
        primarySwatch: Colors.blue,
      ),
      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.headline5,
            ),
            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';
    }
  }
}

Aplikasi di atas didesain untuk membantu Anda memahami cara mendeteksi dan beradaptasi dengan berbagai platform. Berikut aplikasi yang berjalan secara native di Android dan iOS:

Dan berikut adalah kode yang sama yang berjalan secara native di macOS dan Chrome, sekali lagi berjalan di macOS.

Poin penting yang perlu diperhatikan di sini adalah secara sekilas, Flutter berupaya sebaik mungkin untuk mengadaptasikan konten dengan layar yang menjalankannya. Screenshot ini diambil di laptop yang memiliki layar Mac beresolusi tinggi. Itulah sebabnya aplikasi versi web dan macOS dirender pada Rasio Pixel Perangkat 2. Sementara itu, rasio untuk iPhone adalah 3, sedangkan untuk Pixel 2 adalah 2,63. Dalam semua kasus, teks yang ditampilkan kurang lebih sama, sehingga semakin mempermudah pekerjaan developer.

Poin kedua yang perlu diperhatikan adalah bahwa dua opsi untuk memeriksa di platform mana kode dijalankan menghasilkan nilai yang berbeda. Opsi pertama memeriksa objek Platform yang diimpor dari dart:io, sedangkan opsi kedua (hanya tersedia dalam metode build Widget) mengambil objek Theme dari argumen BuildContext.

Karena tujuan dari kedua metode ini berbeda, hasil yang ditampilkan akan berbeda pula. Objek Platform yang diimpor dari dart:io digunakan untuk membuat keputusan yang tidak bergantung pada pilihan rendering. Contoh terbaiknya adalah memutuskan plugin mana yang akan digunakan, yang mungkin memiliki atau tidak memiliki implementasi native yang cocok untuk platform fisik tertentu.

Pengekstrakan Theme dari BuildContext dimaksudkan untuk membuat keputusan implementasi yang berpusat pada Tema. Contoh terbaiknya adalah memutuskan apakah akan menggunakan penggeser Material, atau penggeser Cupertino, seperti yang dijelaskan dalam Slider.adaptive.

Pada bagian berikutnya, Anda akan membangun aplikasi dasar penjelajah playlist YouTube yang dioptimalkan khusus untuk Android dan iOS. Anda juga akan menambahkan berbagai adaptasi agar aplikasi dapat berfungsi dengan lebih baik di desktop dan web.

4. Membangun aplikasi seluler

Menambahkan paket

Di aplikasi ini, Anda akan menggunakan berbagai paket Flutter untuk mendapatkan akses ke YouTube Data API, pengelolaan status, dan penerapan tema.

$ flutter pub add googleapis
Resolving dependencies...
+ _discoveryapis_commons 1.0.3
  async 2.8.2 (2.9.0 available)
  characters 1.2.0 (1.2.1 available)
  clock 1.1.0 (1.1.1 available)
  fake_async 1.3.0 (1.3.1 available)
+ googleapis 9.1.0
+ http 0.13.4
+ http_parser 4.0.1
  matcher 0.12.11 (0.12.12 available)
  material_color_utilities 0.1.4 (0.1.5 available)
  meta 1.7.0 (1.8.0 available)
  path 1.8.1 (1.8.2 available)
  source_span 1.8.2 (1.9.0 available)
  string_scanner 1.1.0 (1.1.1 available)
  term_glyph 1.2.0 (1.2.1 available)
  test_api 0.4.9 (0.4.12 available)
+ typed_data 1.3.1
Changed 5 dependencies!

Paket pertama, googleapis adalah library Dart yang dihasilkan untuk mengakses Google API.

$ flutter pub add http
Resolving dependencies...
  async 2.8.2 (2.9.0 available)
  characters 1.2.0 (1.2.1 available)
  clock 1.1.0 (1.1.1 available)
  fake_async 1.3.0 (1.3.1 available)
  matcher 0.12.11 (0.12.12 available)
  material_color_utilities 0.1.4 (0.1.5 available)
  meta 1.7.0 (1.8.0 available)
  path 1.8.1 (1.8.2 available)
  source_span 1.8.2 (1.9.0 available)
  string_scanner 1.1.0 (1.1.1 available)
  term_glyph 1.2.0 (1.2.1 available)
  test_api 0.4.9 (0.4.12 available)
Got dependencies!

Paket http akan digunakan untuk membangun kemampuan untuk mengakses YouTube Data API menggunakan kunci API.

$ flutter pub add provider
Resolving dependencies...
  async 2.8.2 (2.9.0 available)
  characters 1.2.0 (1.2.1 available)
  clock 1.1.0 (1.1.1 available)
  fake_async 1.3.0 (1.3.1 available)
  matcher 0.12.11 (0.12.12 available)
  material_color_utilities 0.1.4 (0.1.5 available)
  meta 1.7.0 (1.8.0 available)
+ nested 1.0.0
  path 1.8.1 (1.8.2 available)
+ provider 6.0.3
  source_span 1.8.2 (1.9.0 available)
  string_scanner 1.1.0 (1.1.1 available)
  term_glyph 1.2.0 (1.2.1 available)
  test_api 0.4.9 (0.4.12 available)
Changed 2 dependencies!

Untuk pengelolaan status, gunakan provider.

$ flutter pub add url_launcher
Resolving dependencies...
  async 2.8.2 (2.9.0 available)
  characters 1.2.0 (1.2.1 available)
  clock 1.1.0 (1.1.1 available)
  fake_async 1.3.0 (1.3.1 available)
+ flutter_web_plugins 0.0.0 from sdk flutter
+ js 0.6.4
  matcher 0.12.11 (0.12.12 available)
  material_color_utilities 0.1.4 (0.1.5 available)
  meta 1.7.0 (1.8.0 available)
  path 1.8.1 (1.8.2 available)
+ plugin_platform_interface 2.1.2
  source_span 1.8.2 (1.9.0 available)
  string_scanner 1.1.0 (1.1.1 available)
  term_glyph 1.2.0 (1.2.1 available)
  test_api 0.4.9 (0.4.12 available)
+ url_launcher 6.1.4
+ url_launcher_android 6.0.17
+ url_launcher_ios 6.0.17
+ url_launcher_linux 3.0.1
+ url_launcher_macos 3.0.1
+ url_launcher_platform_interface 2.1.0
+ url_launcher_web 2.0.12
+ url_launcher_windows 3.0.1
Changed 11 dependencies!

Gunakan url_launcher sebagai cara untuk membuka video langsung dari playlist. Seperti yang Anda lihat dari dependensi yang di-resolve, url_launcher tidak hanya memiliki implementasi untuk Android dan iOS default, tetapi juga untuk Windows, macOS, Linux, dan web. Untuk kemampuan ini, Anda tidak perlu membuat kode khusus platform.

$ flutter pub add flex_color_scheme
Resolving dependencies...
  async 2.8.2 (2.9.0 available)
  characters 1.2.0 (1.2.1 available)
  clock 1.1.0 (1.1.1 available)
  fake_async 1.3.0 (1.3.1 available)
+ flex_color_scheme 5.1.0
  matcher 0.12.11 (0.12.12 available)
  material_color_utilities 0.1.4 (0.1.5 available)
  meta 1.7.0 (1.8.0 available)
  path 1.8.1 (1.8.2 available)
  source_span 1.8.2 (1.9.0 available)
  string_scanner 1.1.0 (1.1.1 available)
  term_glyph 1.2.0 (1.2.1 available)
  test_api 0.4.9 (0.4.12 available)
Changed 1 dependency!m

Paket ini ditujukan khusus untuk memberikan skema warna default yang menarik bagi aplikasi. Lihat dokumentasi flex_color_scheme untuk memahami berbagai macam kemampuan.

$ flutter pub add go_router
Resolving dependencies...
  async 2.9.0 (2.10.0 available)
  boolean_selector 2.1.0 (2.1.1 available)
  collection 1.16.0 (1.17.0 available)
+ flutter_web_plugins 0.0.0 from sdk flutter
+ go_router 5.2.0
+ js 0.6.4 (0.6.5 available)
+ logging 1.1.0
  matcher 0.12.12 (0.12.13 available)
  material_color_utilities 0.1.5 (0.2.0 available)
  source_span 1.9.0 (1.9.1 available)
  stack_trace 1.10.0 (1.11.0 available)
  stream_channel 2.1.0 (2.1.1 available)
  string_scanner 1.1.1 (1.2.0 available)
  test_api 0.4.12 (0.4.16 available)
  vector_math 2.1.2 (2.1.4 available)
Changed 4 dependencies!

Untuk mengimplementasikan navigasi di antara berbagai layar, tambahkan go_router ke project.

Paket ini menyediakan API berbasis URL yang mudah digunakan untuk bernavigasi menggunakan Router Flutter.

Mengonfigurasi aplikasi seluler untuk url_launcher

Plugin url_launcher memerlukan konfigurasi aplikasi runner Android dan iOS. Di runner Flutter iOS, tambahkan baris berikut ke kamus plist.

ios/Runner/Info.plist

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

Di runner Flutter Android, tambahkan baris berikut ke Manifest.xml. Tambahkan node queries sebagai turunan langsung dari node manifest dan pembanding dari node 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>

Untuk detail selengkapnya tentang perubahan konfigurasi yang diperlukan ini, lihat dokumentasi url_launcher.

Mengakses YouTube Data API

Untuk mengakses YouTube Data API guna mencantumkan playlist, Anda perlu membuat project API untuk menghasilkan Kunci API yang diperlukan. Langkah-langkah berikut mengasumsikan bahwa Anda telah memiliki Akun Google. Jika belum punya, Anda harus membuatnya terlebih dahulu.

Buka Konsol Developer untuk membuat project API:

7fe39926b91104c3.png

Setelah memiliki project, buka halaman Library API. Di kotak penelusuran, masukkan "youtube", lalu pilih youtube data api v3.

26ac7d6164430ece.png

Di halaman detail YouTube Data API v3, aktifkan API tersebut.

5a877ea82b83ae42.png

Setelah API diaktifkan, buka halaman Kredensial, lalu buat Kunci API.

a75ba6e17bef352.png

Setelah beberapa detik, Anda akan melihat dialog dengan Kunci API baru Anda. Anda akan segera menggunakan kunci ini.

d808e4a25d448ecc.png

Menambahkan kode

Untuk sisa langkah ini, Anda akan memotong dan menempelkan banyak kode untuk membangun aplikasi seluler, tanpa komentar apa pun pada kode tersebut. Tujuan codelab ini adalah untuk mengadaptasikan aplikasi seluler dengan desktop dan web. Untuk pengantar yang lebih mendetail tentang membangun aplikasi Flutter untuk perangkat seluler, lihat Write Your First Flutter App, part 1, part 2, dan Building beautiful UIs with Flutter.

Tambahkan file berikut, dimulai dengan objek status untuk aplikasi.

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

Berikutnya, tambahkan halaman detail playlist satu per satu.

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).backgroundColor],
            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.bodyText1!.copyWith(
                  fontSize: 18,
                  // fontWeight: FontWeight.bold,
                ),
          ),
          if (playlistItem.snippet!.videoOwnerChannelTitle != null)
            Text(
              playlistItem.snippet!.videoOwnerChannelTitle!,
              style: Theme.of(context).textTheme.bodyText2!.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,
          ),
        ),
      ],
    );
  }
}

Berikutnya, tambahkan daftar playlist.

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(),
              );
            },
          ),
        );
      },
    );
  }
}

Kemudian, ganti konten file main.dart sebagai berikut:

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.queryParams['title']!;
            final id = state.params['id']!;
            return PlaylistDetails(
              playlistId: id,
              playlistName: title,
            );
          },
        ),
      ],
    ),
  ],
);

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

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

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

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

Anda hampir siap untuk menjalankan kode ini di Android dan iOS. Satu hal lagi yang perlu dilakukan, yaitu ubah konstan youTubeApiKey di baris 14 dengan Kunci YouTube API yang dihasilkan pada langkah sebelumnya.

lib/main.dart

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

Untuk menjalankan aplikasi ini di macOS, Anda harus memungkinkan aplikasi membuat permintaan HTTP sebagai berikut. Edit file DebugProfile.entitlements dan Release.entitilements sebagai berikut:

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>

Menjalankan aplikasi

Setelah aplikasi selesai, Anda akan dapat menjalankannya di Android emulator atau simulator iPhone. Anda akan melihat daftar playlist Flutter. Saat Anda memilih playlist, video di playlist tersebut akan ditampilkan. Terakhir, jika Anda mengklik tombol Putar, Anda akan diarahkan ke antarmuka YouTube untuk menonton video tersebut.

Namun, jika Anda mencoba menjalankan aplikasi ini di desktop, tata letak akan terlihat janggal saat diperluas ke jendela dengan ukuran desktop normal. Anda akan mempelajari cara mengadaptasikan hal ini di langkah berikutnya.

5. Beradaptasi dengan desktop

Masalah desktop

Jika Anda menjalankan aplikasi di salah satu platform desktop native seperti Windows, macOS, atau Linux, Anda akan mendapati masalah yang menarik. Aplikasi tersebut akan tetap berfungsi, tetapi terlihat janggal.

Anda dapat memperbaikinya dengan menambahkan tampilan terpisah, yang mencantumkan playlist di sebelah kiri dan video di sebelah kanan. Namun, sebaiknya hanya gunakan tata letak semacam ini saat kode tidak berjalan di Android atau iOS, dan saat jendela cukup lebar. Berikut petunjuk cara mengimplementasikan kemampuan ini.

Pertama, tambahkan paket split_view untuk membantu dalam penyusunan tata letak.

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

Memperkenalkan widget adaptif

Pola yang akan Anda gunakan dalam codelab ini adalah memperkenalkan widget Adaptif yang membuat pilihan implementasi berdasarkan atribut seperti lebar layar, tema platform, dan sejenisnya. Dalam hal ini, Anda akan memperkenalkan widget AdaptivePlaylists yang mengubah cara Playlists dan PlaylistDetails berinteraksi. Edit file lib/main.dart sebagai berikut:

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';
import 'src/app_state.dart';
import 'src/playlist_details.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 AdaptivePlaylists();
      },
      routes: <RouteBase>[
        GoRoute(
          path: 'playlist/:id',
          builder: (context, state) {
            final title = state.queryParams['title']!;
            final id = state.params['id']!;
            return Scaffold(
              appBar: AppBar(title: Text(title)),
              body: PlaylistDetails(
                playlistId: id,
                playlistName: title,
              ),
            );
          },
        ),
      ],
    ),
  ],
);

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

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

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

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

Berikutnya, buat file untuk widget 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: selectedPlaylist == null
            ? const Text('FlutterDev Playlists')
            : Text('FlutterDev Playlist: ${selectedPlaylist!.snippet!.title!}'),
      ),
      body: SplitView(
        viewMode: SplitViewMode.Horizontal,
        children: [
          Playlists(playlistSelected: (playlist) {
            setState(() {
              selectedPlaylist = playlist;
            });
          }),
          if (selectedPlaylist != null)
            PlaylistDetails(
                playlistId: selectedPlaylist!.id!,
                playlistName: selectedPlaylist!.snippet!.title!)
          else
            const Center(
              child: Text('Select a playlist'),
            ),
        ],
      ),
    );
  }
}

File ini menarik karena berbagai alasan. Pertama, file ini menggunakan lebar jendela (dengan MediaQuery.of(context).size.width) dan memeriksa tema (dengan Theme.of(context).platform) untuk menentukan apakah akan menampilkan tata letak lebar dengan widget SplitView, atau tata letak sempit tanpa widget tersebut.

Poin kedua yang perlu diperhatikan adalah file ini menangani navigasi yang sebelumnya di-hardcode. Hal ini dilakukan dengan menampilkan argumen callback di widget Playlists yang memberi tahu kode di sekitarnya bahwa pengguna telah memilih playlist, dan perlu melakukan tindakan yang diperlukan untuk menampilkan playlist tersebut. Perlu diperhatikan juga bahwa sekarang Scaffold telah dikecualikan dari widget Playlists dan PlaylistDetails karena widget ini bukan widget level atas.

Berikutnya, edit file src/lib/playlists.dart sebagai berikut:

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

Ada banyak perubahan pada file ini. Selain pengenalan callback playlistSelected serta penghapusan widget Scaffold yang disebutkan sebelumnya, widget _PlaylistsListView dikonversi dari stateless ke stateful. Perubahan ini diperlukan karena pengenalan ScrollController yang dimiliki harus dibuat dan dihancurkan.

Pengenalan ScrollController menarik. Pengenalan ini diperlukan karena pada tata letak lebar, Anda memiliki dua widget ListView yang berdampingan. Di ponsel, biasanya ada satu ListView. Oleh karena itu, mungkin ada satu ScrollController yang aktif dalam waktu lama di mana semua ListView dipasang dan dilepas selama siklus prosesnya masing-masing. Sebaliknya, desktop itu berbeda karena dapat memiliki beberapa ListView yang berdampingan.

Terakhir, edit file lib/src/playlist_details.dart sebagai berikut:

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).backgroundColor],
            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.bodyText1!.copyWith(
                  fontSize: 18,
                  // fontWeight: FontWeight.bold,
                ),
          ),
          if (playlistItem.snippet!.videoOwnerChannelTitle != null)
            Text(
              playlistItem.snippet!.videoOwnerChannelTitle!,
              style: Theme.of(context).textTheme.bodyText2!.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,
          ),
        ),
      ],
    );
  }
}

Serupa dengan widget Playlists di atas, file ini juga memiliki beberapa perubahan terkait penghapusan widget Scaffold, dan pengenalan ScrollController yang dimiliki.

Menjalankan kembali aplikasi

Menjalankan aplikasi di desktop pilihan Anda, baik itu Windows, macOS, atau Linux. Aplikasi tersebut kini seharusnya dapat berfungsi seperti yang diharapkan.

c356b0976c708cdb.png

6. Beradaptasi dengan web

Ada apa dengan gambar ini?

Jika Anda mencoba menjalankan aplikasi ini di web, bahkan dengan perubahan tata letak dari langkah sebelumnya, Anda akan menemukan bahwa masih ada pekerjaan yang harus dilakukan untuk beradaptasi dengan lingkungan browser web:

2413ac49488025b4.png

Jika Anda membuka konsol debug, Anda akan melihat petunjuk tentang tindakan yang harus dilakukan berikutnya.

════════ Exception caught by image resource service ════════════════════════════
Failed to load network image.
Image URL: https://i.ytimg.com/vi/QIW35-vcA2o/default.jpg
Trying to load an image from another domain? Find answers at:
https://flutter.dev/docs/development/platform-integration/web-images
═════════════════════════════════════════════════════════════════════════

Membuat Proxy CORS

Salah satu cara untuk menangani masalah rendering gambar adalah dengan memperkenalkan layanan web proxy untuk menambahkan header Cross-Origin Resource Sharing (CORS) yang diperlukan. Buka terminal dan buat server web Dart sebagai berikut:

$ 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

Ubah direktori ke server yt_cors_proxy, lalu tambahkan beberapa dependensi yang diperlukan:

$ cd yt_cors_proxy
$ dart pub add shelf_cors_headers
Resolving dependencies...
+ shelf_cors_headers 0.1.2
Changed 1 dependency!
$ dart pub add http
"http" was found in dev_dependencies. Removing "http" and adding it to dependencies instead.
Resolving dependencies...
Got dependencies!

Ada beberapa dependensi yang saat ini tidak lagi diperlukan. Pangkas dependensi tersebut sebagai berikut:

$ dart pub remove args
Resolving dependencies...
These packages are no longer being depended on:
- args 2.2.0
Changed 1 dependency!
$ dart pub remove shelf_router
Resolving dependencies...
These packages are no longer being depended on:
- http_methods 1.1.0
- shelf_router 1.1.1
Changed 2 dependencies!

Berikutnya, ubah konten file server.dart agar sesuai dengan berikut ini:

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

Anda dapat menjalankan server ini sebagai berikut:

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

Atau, Anda dapat membangunnya sebagai image Docker, dan menjalankan image Docker yang dihasilkan sebagai berikut:

$ 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

Berikutnya, ubah kode Flutter untuk memanfaatkan proxy CORS ini, tetapi hanya saat kode berjalan di browser web.

Sepasang widget yang dapat diadaptasikan

Widget pertama menentukan cara aplikasi Anda menggunakan proxy CORS.

lib/src/adaptive_image.dart

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

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

  late final String _url;

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

Perlu diperhatikan bahwa Anda menggunakan kIsWeb karena perbedaannya bukan pada tema, melainkan pada platform runtime. Widget lainnya yang dapat diadaptasikan menangani teks agar dapat dipilih, sesuai dengan harapan pengguna browser web.

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) {
    switch (Theme.of(context).platform) {
      case TargetPlatform.android:
      case TargetPlatform.iOS:
        return Text(data, style: style);
      default:
        return SelectableText(data, style: style);
    }
  }
}

Sekarang, terapkan adaptasi ini ke seluruh codebase:

lib/src/playlist_details.dart

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

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

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

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

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

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

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

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

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

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

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

  Widget _buildGradient(BuildContext context) {
    return Positioned.fill(
      child: DecoratedBox(
        decoration: BoxDecoration(
          gradient: LinearGradient(
            colors: [Colors.transparent, Theme.of(context).backgroundColor],
            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(    // This line
            playlistItem.snippet!.title!,
            style: Theme.of(context).textTheme.bodyText1!.copyWith(
                  fontSize: 18,
                  // fontWeight: FontWeight.bold,
                ),
          ),
          if (playlistItem.snippet!.videoOwnerChannelTitle != null)
          AdaptiveText(    // And, this line
            playlistItem.snippet!.videoOwnerChannelTitle!,
            style: Theme.of(context).textTheme.bodyText2!.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,
          ),
        ),
      ],
    );
  }
}

Pada kode di atas, Anda mengadaptasikan widget Image.network dan Text. Berikutnya, adaptasikan widget 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);
            },
          ),
        );
      },
    );
  }
}

Kali ini, Anda hanya mengadaptasikan widget Image.network, dan membiarkan kedua widget Text seperti sebelumnya. Tindakan ini disengaja karena, jika Anda mengadaptasikan widget Text, fungsi onTap ListTile akan diblokir saat pengguna mengetuk teks tersebut.

Menjalankan aplikasi di web dengan benar

Dengan proxy CORS berjalan, Anda seharusnya dapat menjalankan versi web aplikasi dan mendapatkan tampilan seperti ini:

1e4f272524ebedb0.png

7. Autentikasi Adaptif

Pada langkah ini, Anda akan memperluas kemampuan aplikasi agar dapat mengautentikasi pengguna, dan menampilkan playlist mereka. Anda harus menggunakan beberapa plugin untuk mencakup berbagai platform yang dapat menjalankan aplikasi. Hal ini karena cara penanganan OAuth sangat berbeda antara Android, iOS, web, Windows, macOS, dan Linux.

Menambahkan plugin untuk mengaktifkan autentikasi Google

Anda akan menginstal tiga paket untuk menangani autentikasi Google.

$ flutter pub add googleapis_auth
Resolving dependencies...
+ crypto 3.0.1
+ googleapis_auth 1.3.0
  test_api 0.4.3 (0.4.8 available)
Changed 2 dependencies!
$ flutter pub add google_sign_in
Resolving dependencies...
+ google_sign_in 5.2.1
+ google_sign_in_platform_interface 2.1.0
+ google_sign_in_web 0.10.0+3
+ quiver 3.0.1+1
  test_api 0.4.3 (0.4.8 available)
Changed 4 dependencies!
$ flutter pub add extension_google_sign_in_as_googleapis_auth
Resolving dependencies...
+ extension_google_sign_in_as_googleapis_auth 2.0.4
  test_api 0.4.3 (0.4.8 available)
Changed 1 dependency!

Gunakan plugin googleapis_auth untuk melakukan autentikasi di Windows, macOS, dan Linux menggunakan browser web pengguna. Di Android, iOS, dan web, gunakan google_sign_in, dengan extension_google_sign_in_as_googleapis_auth bertindak sebagai shim interop antara dua paket.

Mengupdate kode

Mulai update dengan membuat abstraksi baru yang dapat digunakan kembali, yaitu widget AdaptiveLogin. Widget ini didesain untuk Anda gunakan kembali, sehingga memerlukan beberapa konfigurasi:

lib/src/adaptive_login.dart

import 'dart:io' show Platform;

import 'package:extension_google_sign_in_as_googleapis_auth/extension_google_sign_in_as_googleapis_auth.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:googleapis_auth/auth_io.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/link.dart';

import 'app_state.dart';

typedef _AdaptiveLoginButtonWidget = Widget Function({
  required VoidCallback? onPressed,
});

class AdaptiveLogin extends StatelessWidget {
  const AdaptiveLogin({
    super.key,
    required this.clientId,
    required this.scopes,
    required this.loginButtonChild,
  });

  final ClientId clientId;
  final List<String> scopes;
  final Widget loginButtonChild;

  @override
  Widget build(BuildContext context) {
    if (kIsWeb || Platform.isAndroid || Platform.isIOS) {
      return _GoogleSignInLogin(
        button: _loginButton,
        scopes: scopes,
      );
    } else {
      return _GoogleApisAuthLogin(
        button: _loginButton,
        scopes: scopes,
        clientId: clientId,
      );
    }
  }

  Widget _loginButton({required VoidCallback? onPressed}) => ElevatedButton(
        onPressed: onPressed,
        child: loginButtonChild,
      );
}

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

  final _AdaptiveLoginButtonWidget button;
  final List<String> scopes;

  @override
  State<_GoogleSignInLogin> createState() => _GoogleSignInLoginState();
}

class _GoogleSignInLoginState extends State<_GoogleSignInLogin> {
  @override
  initState() {
    super.initState();
    _googleSignIn = GoogleSignIn(
      scopes: widget.scopes,
    );
    _googleSignIn.onCurrentUserChanged.listen((account) {
      if (account != null) {
        _googleSignIn.authenticatedClient().then((authClient) {
          if (authClient != null) {
            context.read<AuthedUserPlaylists>().authClient = authClient;
            context.go('/');
          }
        });
      }
    });
  }

  late final GoogleSignIn _googleSignIn;

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

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

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

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

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

  Uri? _authUrl;

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

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

Banyak konfigurasi yang dilakukan pada file ini. Bagian pentingnya adalah di metode build AdaptiveLogin, platform runtime diperiksa menggunakan kombinasi panggilan kIsWeb dan Platform.isXXX dart:io, yang membuat instance widget stateful _GoogleSignInLogin untuk Android, iOS, dan web; dan membuat widget stateful _GoogleApisAuthLogin untuk Windows, macOS, dan Linux.

Konfigurasi tambahan diperlukan untuk menggunakan beberapa class ini, yang akan dilakukan nanti setelah mengupdate code base lainnya untuk menggunakan widget baru ini. Mulai dengan mengganti nama FlutterDevPlaylists menjadi AuthedUserPlaylists agar lebih mencerminkan tujuan barunya, serta mengupdate kode untuk mencerminkan bahwa http.Client kini diteruskan setelah dibuat. Terakhir, class _ApiKeyClient tidak lagi diperlukan:

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();
    } while (nextPageToken != null);
  }

  YouTubeApi? _api;    // Convert from late final 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

Berikutnya, update widget PlaylistDetails dengan nama baru untuk objek status aplikasi yang disediakan:

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, flutterDev, _) {
        final playlistItems = flutterDev.playlistItems(playlistId: playlistId);
        if (playlistItems.isEmpty) {
          return const Center(child: CircularProgressIndicator());
        }

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

Demikian juga, update widget Playlists:

lib/src/playlists.dart

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

  final PlaylistsListSelected playlistSelected;

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

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

Terakhir, update file main.dart untuk menggunakan widget AdaptiveLogin baru dengan benar:

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.queryParams['title']!;
            final id = state.params['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(
      title: 'Your Playlists',            // Change FlutterDev to Your
      theme: FlexColorScheme.light(
        scheme: FlexScheme.red,
        useMaterial3: true,
      ).toTheme,
      darkTheme: FlexColorScheme.dark(
        scheme: FlexScheme.red,
        useMaterial3: true,
      ).toTheme,
      themeMode: ThemeMode.dark, // Or ThemeMode.System if you'd prefer
      debugShowCheckedModeBanner: false,
      routerConfig: _router,
    );
  }
}

Perubahan pada file ini mencerminkan perubahan dari hanya menampilkan playlist YouTube Flutter hingga menampilkan playlist pengguna yang telah diautentikasi. Meskipun kode sudah selesai, masih ada serangkaian perubahan yang diperlukan pada file ini, dan file di setiap aplikasi Runner, guna mengonfigurasi paket google_sign_in dan googleapis_auth dengan benar untuk autentikasi.

Mengonfigurasi googleapis_auth

Langkah pertama untuk mengonfigurasi autentikasi adalah menghapus Kunci API yang sebelumnya Anda konfigurasi dan gunakan. Buka halaman kredensial project API Anda, lalu hapus kunci API tersebut:

e7bf4977a5dcf985.png

Tindakan ini akan memunculkan pop-up yang Anda konfirmasi dengan menekan tombol Delete:

eb8b6787c2f2c951.png

Kemudian, buat client ID OAuth:

af07105da9fc35d2.png

Untuk Application type, pilih Desktop app.

1958672268c3283e.png

Terima namanya, lalu klik Create.

85c36e94f304f71f.png

Tindakan ini akan membuat Client ID dan Rahasia Klien yang harus Anda tambahkan ke lib/main.dart untuk mengonfigurasi alur googleapis_auth. Detail implementasi yang penting adalah alur googleapis_auth menggunakan server web sementara yang berjalan di localhost untuk menangkap token OAuth yang dihasilkan. Untuk macOS, Anda perlu mengubah file 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>

Anda tidak perlu melakukan perubahan ini pada file macos/Runner/DebugProfile.entitlements karena file tersebut telah memiliki hak untuk com.apple.security.network.server guna mengaktifkan Hot Reload dan alat debug Dart VM.

Sekarang Anda dapat menjalankan aplikasi di Windows, macOS, atau Linux (jika aplikasi Anda telah dikompilasi untuk target tersebut).

4f323b032dd9a419.png

Mengonfigurasi google_sign_in untuk Android

Kembalilah ke halaman kredensial project API Anda, lalu buat client ID OAuth lainnya. Untuk kali ini, pilih Android:

17687358b5a61a5b.png

Untuk kolom lainnya dalam formulir, isi Package name (Nama paket) dengan paket yang dideklarasikan di android/app/src/main/AndroidManifest.xml. Jika Anda telah mengikuti petunjuk dengan benar, nama paket seharusnya adalah com.example.adaptive_app. Ekstrak sidik jari sertifikat SHA-1 menggunakan petunjuk dari halaman bantuan Google Cloud Platform Console:

45b22059ae417ce2.png

Tindakan ini cukup untuk membuat aplikasi berfungsi di Android. Bergantung pada pilihan Google API yang Anda gunakan, Anda mungkin perlu menambahkan file JSON yang dihasilkan ke paket aplikasi Anda.

4b4c03d9655b02c.png

Mengonfigurasi google_sign_in untuk iOS

Kembalilah ke halaman kredensial project API Anda, lalu buat client ID OAuth lainnya. Untuk kali ini, pilih iOS:

. 86a84eb772759f1f.png

Untuk kolom lainnya dalam formulir, isi Bundle Identifier (ID Paket) dengan membuka ios/Runner.xcworkspace di Xcode. Buka Navigator Project, pilih Runner di navigator, lalu pilih tab General, dan salin Bundle Identifier (ID Paket). Jika Anda telah mengikuti codelab ini langkah demi langkah, ID paket seharusnya adalah com.example.adaptiveApp.

7cda08d3408046a1.png

Untuk saat ini, abaikan App Store ID dan Team ID karena ID tersebut tidak diperlukan untuk pengembangan lokal:

577c52bce54ad7c6.png

Download file .plist yang dihasilkan. Namanya didasarkan pada client ID yang dihasilkan. Ganti nama file yang didownload menjadi GoogleService-Info.plist, lalu tarik file tersebut ke editor Xcode yang sedang berjalan, bersama dengan file Info.plist di bagian Runner/Runner di sebelah kiri navigator. Untuk dialog opsi di Xcode, pilih Copy items if needed, Create folder references, dan Runner di bagian Add to targets.

5e6ad6dbf468585f.png

Keluar dari Xcode, lalu di pilihan IDE Anda, tambahkan berikut ini ke Info.plist Anda:

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>

Anda perlu mengedit nilai agar sesuai dengan entri di file GoogleService-Info.plist yang dihasilkan. Anda juga harus menetapkan versi iOS minimum ke 9. Edit ios/Podfile sebagai berikut:

ios/Podfile

# iOS 9 for google_sign_in
platform :ios, '9.0'      # Uncomment this line

# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'

project 'Runner', {
  'Debug' => :debug,
  'Profile' => :release,
  'Release' => :release,
}

def flutter_root
  generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
  unless File.exist?(generated_xcode_build_settings_path)
    raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
  end

  File.foreach(generated_xcode_build_settings_path) do |line|
    matches = line.match(/FLUTTER_ROOT\=(.*)/)
    return matches[1].strip if matches
  end
  raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end

require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)

flutter_ios_podfile_setup

target 'Runner' do
  use_frameworks!
  use_modular_headers!

  flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
end

post_install do |installer|
  installer.pods_project.targets.each do |target|
    flutter_additional_ios_build_settings(target)
  end
end

Jalankan aplikasi Anda. Setelah login, playlist Anda akan muncul.

fe0d11203497c860.png

Mengonfigurasi google_sign_in untuk web

Kembalilah ke halaman kredensial project API Anda, lalu buat client ID OAuth lainnya. Untuk kali ini, pilih Web application:

7f745c53956c1572.png

Untuk kolom lainnya dalam formulir, isi Authorized JavaScript origins (origin JavaScript yang Diotorisasi) sebagai berikut:

d45fb0e23e874e34.png

Tindakan ini akan menghasilkan Client ID. Tambahkan tag meta berikut ke web/index.html, yang diupdate agar menyertakan Client ID yang dihasilkan.

web/index.html

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

Anda memerlukan sedikit bantuan untuk menjalankan sampel ini. Anda perlu mengikuti petunjuk berikut untuk menjalankan proxy CORS yang Anda buat di langkah sebelumnya, serta untuk menjalankan aplikasi web Flutter pada port yang ditentukan dalam formulir client ID OAuth Aplikasi web.

Di satu terminal, jalankan server Proxy CORS sebagai berikut:

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

Di terminal lainnya, jalankan aplikasi Flutter sebagai berikut:

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

Setelah login sekali lagi, playlist Anda akan muncul:

c9c43252341fa197.png

8. Langkah berikutnya

Selamat!

Anda telah menyelesaikan codelab ini dan membangun aplikasi Flutter adaptif yang berjalan di keenam platform yang didukung Flutter. Anda telah mengadaptasikan kode untuk menangani berbagai perbedaan dalam cara menentukan tata letak layar, cara berinteraksi dengan teks, cara memuat gambar, dan cara kerja autentikasi.

Ada banyak hal lainnya yang dapat diadaptasikan di aplikasi Anda. Untuk mempelajari cara lain guna mengadaptasikan kode Anda dengan berbagai lingkungan tempat kode tersebut dijalankan, lihat Building adaptive apps.