Flutter でのアダプティブ アプリ

1. はじめに

Flutter は、1 つのコードベースからネイティブにコンパイルして、モバイル、ウェブ、デスクトップの美しいアプリケーションを作成できる Google の UI ツールキットです。この Codelab では、Android、iOS、ウェブ、Windows、macOS、Linux のいずれであっても、それが実行されているプラットフォームに適応する Flutter アプリの作成方法を学びます。

学習内容

  • モバイル用に設計された Flutter アプリを、Flutter でサポートされている 6 つのプラットフォームすべてで動作するように改良する方法
  • プラットフォームを検出するための各種の Flutter API と、それぞれの API を使用すべき状況
  • ウェブでアプリを実行する際の制約事項と前提事項に対する適応
  • 複数のパッケージを共存させながら使用して、Flutter の全プラットフォームをサポートする方法

作成するアプリの概要

この Codelab では、Android と iOS に対応した、Flutter の YouTube 再生リストを調べるアプリを最初に作成します。次に、このアプリケーションを、与えられたサイズのアプリケーション ウィンドウに情報を表示する方法を変更して、3 つのデスクトップ プラットフォーム(Windows、macOS、Linux)で機能するように適応させます。さらに、ウェブユーザーが想定するように、アプリに表示されるテキストを選択可能にして、アプリケーションをウェブに適応させます。最後に、アプリに認証を追加して、Flutter チームが作成した再生リストではなく、自分の再生リストを調べることができるようにします。このために、Android、iOS、ウェブでは、Windows、macOS、Linux の 3 つのデスクトップ プラットフォームとは異なる認証方法が必要となります。

以下は、この Flutter アプリの Android と iOS でのスクリーンショットです。

以下は、このアプリが macOS のワイド スクリーン レイアウトで実行されているときのスクリーンショットです。

b424266e6fd4b3c3.png

この Codelab では、モバイル向けの Flutter アプリを、6 つあるすべての Flutter プラットフォームで機能するアダプティブ アプリに変えることに集中します。関連のない概念とコードブロックについては軽く触れるにとどめ、そのままコピーして貼り付けられるようにしています。

この Codelab で学びたいことは次のどれですか?

このトピックは初めてなので、簡単に概要を知りたい。 このトピックについてある程度は知っているが、復習したい。プロジェクトで使用するサンプルコードを確認したい。特定の項目に関する説明を確認したい。

2. Flutter の開発環境をセットアップする

このラボを完了するには、Flutter SDKエディタの 2 つのソフトウェアが必要です。

この Codelab は、次のいずれかのデバイスを使って実行できます。

  • パソコンに接続され、デベロッパー モードに設定された物理デバイス(Android または iOS
  • iOS シミュレータ(Xcode ツールのインストールが必要)
  • Android Emulator(Android Studio でセットアップが必要)
  • ブラウザ(デバッグには Chrome が必要)
  • WindowsLinuxmacOS のデスクトップ アプリケーション。開発はデプロイする予定のプラットフォームで行う必要があります。たとえば、Windows のデスクトップ アプリを開発する場合は、適切なビルドチェーンにアクセスできるように Windows で開発する必要があります。オペレーティング システム固有の要件については、docs.flutter.dev/desktop に詳しい説明があります。

3. 始める

開発環境を確認する

開発準備がすべて終わっていることを確認するための最も簡単な方法は、次のコマンドを実行することです。

$ flutter doctor

チェックマークのないものが表示された場合は、次を実行して、何が間違っているかを詳しく調べます。

$ flutter doctor -v

モバイル開発用またはデスクトップ開発用のデベロッパー ツールをインストールする必要があるかもしれません。ツールの設定方法に関する詳細は、ホスト オペレーティング システムによって異なります。Flutter のインストールに関するドキュメントを確認してください。

Flutter プロジェクトを作成する

Flutter コマンドライン ツールを使って Flutter プロジェクトを作成することで、デスクトップ アプリ向けに Flutter のコーディングを簡単に開始できます。また、IDE で UI から Flutter プロジェクトを作成するワークフローが提供されている場合もあります。

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

問題なく動作していることを確認するために、以下のように、ボイラープレート Flutter アプリケーションをモバイルアプリとして実行します。または、IDE でこのプロジェクトを開き、その機能を使用してアプリケーションを実行します。さきほどの作業のおかげで、デスクトップ アプリケーションとして実行する以外の選択肢はなくなっているはずです。

$ 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=/

これでアプリが実行されるようになりました。以下のように lib/main.dart の内容を変更し、ホットリロードを行ってコンテンツを更新します。ホットリロードを行う方法は、アプリの実行方法によって異なります。コマンドラインからアプリを実行している場合は、コンソール ウィンドウに「r」を入力します。エディタから実行している場合は、ファイルを保存するだけでホットリロードがトリガーされます。

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

上記のアプリは、プラットフォームを検知して、それに適応する方法を把握してもらえるように設計したものです。以下は、このアプリが Android と iOS でネイティブに動作しているところです。

以下は、同じコードが macOS と、macOS 内の Chrome でネイティブに動作しているところです。

ここで重要なのは、一見したところ、Flutter は、コンテンツをそれが動作しているディスプレイに適応させるために、できることを行っているということです。これらのスクリーンショットを撮影したノートパソコンが高解像度の Mac ディスプレイを備えているため、このアプリの macOS 版とウェブ版の両方がデバイス ピクセル比 2 でレンダリングされています。それに対して、iPhone 12 では 3、Pixel 2 では 2.63 になっています。すべての場合で表示されているテキストは概ね同じなので、デベロッパーの仕事はかなり簡単になっています。

もう一点は、コードが実行されているプラットフォームを確認する 2 つの方法によって、結果の値が異なることです。1 つ目の方法では dart:io からインポートした Platform オブジェクトを調べ、2 つ目の方法(ウィジェットの build メソッドの中でのみ利用可能)では BuildContext 引数から Theme オブジェクトを取得します。

2 つの方法で得られる結果が異なるのは、目的が異なるためです。dart:io からインポートした Platform オブジェクトは、レンダリング方法から独立した判断をするために使用するものです。典型的な例は、どのプラグインを使用するかの判断です。物理プラットフォームに対応しているネイティブ実装が存在する場合と存在しない場合があります。

BuildContext から Theme を抽出するのは、テーマ中心の実装判断のためです。典型的な例は、Slider.adaptive で説明しているような、Material スライダーと Cupertino スライダーのどちらを使うかの判断です。

次のセクションでは、Android と iOS だけに最適化された、基本的な YouTube 再生リスト エクスプローラ アプリを作成します。それ以降のセクションでは、パソコンとウェブでの動作を改善する、さまざまな適応を追加します。

4. モバイルアプリを作成する

パッケージを追加する

このアプリでは、YouTube Data API、状態管理、テーマ設定を使用するために、さまざまな Flutter パッケージを使用します。

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

1 つ目のパッケージ googleapis は、Google API へのアクセス用に生成された Dart ライブラリです。

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

http パッケージは、API キーを使用して YouTube Data 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!

状態管理には、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!

url_launcher は、再生リストの動画を開始する手段として使用します。解決される依存関係からわかるように、url_launcher には、デフォルトの Android と iOS 用以外にも、Windows、macOS、Linux、ウェブ用の実装が用意されています。これは、プラットフォーム固有のコードを作成する必要のない機能の 1 つです。

$ 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

このパッケージは、適切なデフォルト カラーパターンをアプリに設定するためだけのものです。機能の全貌については、flex_color_scheme のドキュメントをご覧ください。

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

画面間のナビゲーションを実現するには、go_router をプロジェクトに追加します。

このパッケージには、Flutter の Router を使用してナビゲーションするための便利な URL ベースの API が用意されています。

モバイルアプリ向けに url_launcher を設定する

url_launcher プラグインには、Android と iOS のランナーアプリの設定が必要です。iOS の Flutter ランナーでは、以下の行を辞書 plist に追加します。

ios/Runner/Info.plist

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

Android の Flutter ランナーでは、以下の行を 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 アカウントをすでに持っていることを前提としていますので、まだの場合は作成してください。

以下のように、Play Console にアクセスして、API プロジェクトを作成します。

7fe39926b91104c3.png

プロジェクトができたら、API ライブラリのページに移動します。検索ボックスに「youtube」と入力し、「youtube data api v3」を選択します。

26ac7d6164430ece.png

YouTube Data API v3 の詳細ページで、この API を有効にします。

5a877ea82b83ae42.png

API が有効になったら、認証情報のページに移動して API キーを作成します。

a75ba6e17bef352.png

数秒後に、ダイアログが開き、新規の API キーが表示されます。このキーは、すぐ後で使用します。

d808e4a25d448ecc.png

コードを追加する

この手順の残りの部分では、コメントのない、モバイルアプリを作成するための大量のコードをカット アンド ペーストします。この Codelab の目的は、モバイルアプリを作成し、それをデスクトップとウェブの両方に適応させることです。モバイル向け Flutter アプリの作成の初歩については、初めての Flutter アプリの作成、パート 1パート 2Flutter を使用した美しい UI の作成をご覧ください。

以下のファイルを追加します。最初は、アプリの状態オブジェクトです。

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

次は、個別の再生リストの詳細ページを追加します。

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

次は、再生リストのリストを追加します。

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

このコードを Android と iOS で実行するまで、あと少しです。もう少し変更します。14 行目youTubeApiKey 定数を、前のステップで生成した YouTube API のキーに変更します。

lib/main.dart

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

このアプリを macOS で実行するには、次のように、HTTP リクエストを発行できるようにする必要があります。次のように DebugProfile.entitlementsRelease.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 Emulator や iPhone シミュレータで正常に実行できるはずです。Flutter の再生リストが表示され、再生リストを選択すると再生リストに動画が表示されます。そして、再生ボタンをクリックすると、その動画を視聴するための YouTube のインターフェースが起動されます。

しかし、デスクトップで実行すると、通常のデスクトップ サイズのウィンドウに拡張されたときに、不自然なレイアウトで表示されます。次のステップでは、これを適応させる方法について見ていきます。

5. デスクトップに適応する

デスクトップでの問題

Windows、macOS、Linux のいずれかのネイティブなデスクトップ プラットフォームでアプリを実行すると、興味深い問題が発生します。機能は問題ありませんが、おかしな表示がされます。

これを修正するには、スプリット ビューを追加して、左側に再生リスト、右側に動画を表示します。ただし、このレイアウトを作動させるのは、Android と iOS 以外で実行されていて、ウィンドウに十分な幅がある場合だけです。以下の手順で、この機能の実装方法を示します。

まず、このレイアウトの構築に利用する split_view パッケージを追加します。

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

アダプティブ ウィジェットを導入する

この Codelab で使用するパターンは、画面幅、プラットフォーム テーマなどの属性に応じて実装を選択する Adaptive ウィジェットの導入です。ここでは、PlaylistsPlaylistDetails との間のやり取りを再構築する AdaptivePlaylists ウィジェットを導入します。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';
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,
    );
  }
}

次に、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'),
            ),
        ],
      ),
    );
  }
}

このファイルは、いくつかの理由から興味深いものとなっています。まず、ウィンドウの幅を両方とも使用し(MediaQuery.of(context).size.width を使用)、テーマを調査して(Theme.of(context).platform を使用)、SplitView ウィジェットで幅の広いレイアウトを表示するか、それを使用せずに狭いレイアウトを表示するかを決めています。

2 つ目に気付くのは、前にハードコードしたナビゲーション処理で対応していることです。そのために、Playlists ウィジェットでコールバックの引数を外に出しています。このコールバックは、ユーザーが再生リストを選択したので、その再生リストの表示に必要な処理を行う必要があることを、自分を取り囲んでいるコードに通知するコールバックです。さらに気付くのは、ScaffoldPlaylists ウィジェットと PlaylistDetails とウィジェットからくくり出されたため、これらのウィジェットがトップレベルではなくなったことです。

次に、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 の導入は、幅広のレイアウトで 2 つの ListView ウィジェットを並べているために必要となることから、興味深いものとなっています。スマートフォンでは 1 つの ListView を使うものとされていたので、すべての ListView が、それぞれのライフサイクルの間、寿命の長い 1 つの 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).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,
          ),
        ),
      ],
    );
  }
}

上の Playlists ウィジェットと同様に、このファイルでも、Scaffold ウィジェットを排除し、被所有の ScrollController を導入する変更を行っています。

アプリを再度実行する

アプリを Windows、macOS、Linux のうちの好きなパソコンで実行します。これで期待どおりに動作するはずです。

c356b0976c708cdb.png

6. ウェブに適応する

この画像はどういうことでしょうか?

このアプリをそのままウェブで実行すると、前のステップからレイアウトが変更されているとはいえ、ウェブブラウザ環境に適応させるには、まだ作業が必要だとわかります。

2413ac49488025b4.png

デバッグ コンソールに目をやれば、次にやるべきことを示す親切なヒントが表示されています。

════════ 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
═════════════════════════════════════════════════════════════════════════

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 サーバーのディレクトリに移動し、必要な依存関係をいくつか追加します。

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

現在の依存関係の中に不要になったものがあります。それを次のようにして削除します。

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

次に、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

次に、ウェブブラウザで実行されているときのみ、この CORS プロキシを利用するように、Flutter コードを変更します。

2 つのアダプタブル ウィジェット

1 つ目のウィジェットは、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 を使用していることです。2 つ目のアダプタブル ウィジェットは、次のように、テキストは選択可能だというウェブブラウザ ユーザーの期待に対応しています。

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

次に、これらの適応をコードベース全体に広げます。

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

上記のコードでは、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 ウィジェットはそのままにしました。これは、Text ウィジェットを適応させると、テキストがタップされたときに ListTileonTap 機能がブロックされてしまうため、意図的に行ったものです。

アプリをウェブ上で正しく実行する

CORS プロキシが実行されているので、アプリのウェブ版を実行することができ、次のように表示されます。

1e4f272524ebedb0.png

7. アダプティブな認証

このステップでは、アプリを拡張し、ユーザーを認証してから、そのユーザーの再生リストを表示する機能を追加します。OAuth の処理は、Android、iOS、ウェブ、Windows、macOS、Linux の間で大きく異なるため、アプリが実行される各種プラットフォームに対応するために、複数のプラグインを使用する必要があります。

Google 認証を実現するプラグインを追加する

Google 認証に対応するために、3 つのパッケージをインストールします。

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

ユーザーのウェブブラウザを使用して Windows、macOS、Linux 上で認証するために、googleapis_auth プラグインを使用します。Android、iOS、ウェブ上では、google_sign_inextension_google_sign_in_as_googleapis_auth を使用します。後者は、この 2 つのパッケージの間で相互運用性を確保するためのつなぎの役目を果たします。

コードを更新する

まずは、再利用可能な抽象化として AdaptiveLogin ウィジェットを作成します。このウィジェットは再利用することを前提に設計されているため、設定がいくつか必要です。

lib/src/adaptive_login.dart

import 'dart:io' show Platform;

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

import 'app_state.dart';

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

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

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

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

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

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

  final _AdaptiveLoginButtonWidget button;
  final List<String> scopes;

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

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

  late final GoogleSignIn _googleSignIn;

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

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

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

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

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

  Uri? _authUrl;

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

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

このファイルでは多くの作業を行います。重要なのは AdaptiveLoginbuild メソッドです。ここでは、kIsWeb と、dart:ioPlatform.isXXX 呼び出しを組み合わせて実行時のプラットフォームをチェックし、Android、iOS、ウェブの場合にはステートフル ウィジェット _GoogleSignInLogin をインスタンス化し、Windows、macOS、Linux の場合はステートフル ウィジェット _GoogleApisAuthLogin を構築します。

これらのクラスを使用するには、もう 1 つの設定が必要です。この設定は、この新しいウィジェットを使用するために残りのコードベースを更新してから行います。まず、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();
    } 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

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

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

同じように Playlists ウィジェットを更新します。

lib/src/playlists.dart

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

  final PlaylistsListSelected playlistSelected;

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

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

最後に、main.dart ファイルを更新して、新しい AdaptiveLogin ウィジェットを正しく使用するようにします。

lib/main.dart

// Drop dart:io import

import 'package:flex_color_scheme/flex_color_scheme.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:googleapis_auth/googleapis_auth.dart'; // Add this line
import 'package:provider/provider.dart';

import 'src/adaptive_login.dart';                     // Add this line
import 'src/adaptive_playlists.dart';
import 'src/app_state.dart';
import 'src/playlist_details.dart';

// Drop flutterDevAccountId and youTubeApiKey

// Add from this line
// From https://developers.google.com/youtube/v3/guides/auth/installed-apps#identify-access-scopes
final scopes = [
  'https://www.googleapis.com/auth/youtube.readonly',
];

// TODO: Replace with your Client ID and Client Secret for Desktop configuration
final clientId = ClientId(
  'TODO-Client-ID.apps.googleusercontent.com',
  'TODO-Client-secret',
);
// To this line

final _router = GoRouter(
  routes: <RouteBase>[
    GoRoute(
      path: '/',
      builder: (context, state) {
        return const AdaptivePlaylists();
      },
      // Add redirect configuration
      redirect: (context, state) {
        if (!context.read<AuthedUserPlaylists>().isLoggedIn) {
          return '/login';
        } else {
          return null;
        }
      },
      // To this line
      routes: <RouteBase>[
        // Add new login Route
        GoRoute(
          path: 'login',
          builder: (context, state) {
            return AdaptiveLogin(
              clientId: clientId,
              scopes: scopes,
              loginButtonChild: const Text('Login to YouTube'),
            );
          },
        ),
        // To this line
        GoRoute(
          path: 'playlist/:id',
          builder: (context, state) {
            final title = state.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,
    );
  }
}

このファイルへの変更には、Flutter の YouTube 再生リストを表示していたのを、認証済みユーザーの再生リストを表示するようにする変更が反映されています。コードはこれで完成ですが、google_sign_in パッケージと googleapis_auth パッケージの認証の設定を正しく行うためには、このファイルと個々の Runner アプリの配下にあるファイルに、いくつかの変更を行う必要があります。

googleapis_auth を設定する

認証を設定するための最初のステップとして、前に設定して使用していた API キーを削除します。API プロジェクトの認証情報のページに移動して、API キーを削除します。

e7bf4977a5dcf985.png

削除ボタンを押すと、確認を求めるポップアップが表示されます。

eb8b6787c2f2c951.png

次に、OAuth クライアント ID を作成します。

af07105da9fc35d2.png

[アプリケーションの種類] で [デスクトップ アプリ] を選択します。

1958672268c3283e.png

名前はそのままにして、[作成] をクリックします。

85c36e94f304f71f.png

これにより、クライアント ID とクライアント シークレットが作成されます。googleapis_auth フローを設定するために、これらを lib/main.dart に追加する必要があります。実装上重要な点として、googleapis_auth フローが、生成された OAuth トークンを取得するために localhost で実行されている一時的なウェブサーバーを使用することがあります。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 ファイルには、ホットリロードと Dart VM デバッグツールを有効にするために、すでに com.apple.security.network.server のエンタイトルメントが設定されているため、同様の変更を行う必要はありません。

これで、アプリを Windows、macOS、Linux で実行できるようになりました(これらのターゲットでコンパイルしている場合)。

4f323b032dd9a419.png

google_sign_in を Android 向けに設定する

API プロジェクトの認証情報のページに戻り、別の OAuth クライアント ID を作成しますが、今回は [Android] を選択します。

17687358b5a61a5b.png

フォームの残りでは、パッケージ名に android/app/src/main/AndroidManifest.xml で宣言したパッケージを入力します。手順どおりの名前にしていれば、com.example.adaptive_app のはずです。Google Cloud Platform Console ヘルプの手順に沿って、SHA-1 証明書のフィンガープリントを抽出します。

45b22059ae417ce2.png

Android でアプリを正しく動作させるには、これで十分です。使用する Google API によっては、生成された JSON ファイルをアプリケーション バンドルに追加する必要があります。

4b4c03d9655b02c.png

google_sign_in を iOS 向けに設定する

API プロジェクトの認証情報のページに戻り、別の OAuth クライアント ID を作成しますが、今回は [iOS] を選択します。

86a84eb772759f1f.png

フォームの残りでは、Xcode で ios/Runner.xcworkspace を開いて、バンドル ID を入力します。プロジェクト ナビゲータに移動し、ナビゲータで [Runner] を選択してから、[General] タブを選択し、[Bundle Identifier] をコピーします。この Codelab の手順を忠実に実行していれば、com.example.adaptiveApp のはずです。

7cda08d3408046a1.png

ローカルの開発には不要なので、今回は「App Store ID」と「Team ID」を無視してください。

577c52bce54ad7c6.png

生成された .plist ファイルをダウンロードします。名前は生成されたクライアント ID がベースになっています。ダウンロードしたファイルの名前を GoogleService-Info.plist に変更し、それを実行中の Xcode エディタにドラッグして、左側のナビゲータの Runner/Runner の配下の Info.plist ファイルの隣に並べます。Xcode のオプション ダイアログで、[Copy items if needed]、[Create folder references]、[Add to targets] の [Runner] を選択します。

5e6ad6dbf468585f.png

Xcode を終了し、任意の IDE で以下を Info.plist に追加します。

ios/Runner/Info.plist

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

値は、生成された GoogleService-Info.plist のエントリに合わせる必要があります。最小 iOS バージョンは 9 に設定してください。ios/Podfile を次のように編集します。

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

アプリを実行して、ログインすると、自分の再生リストが表示されます。

fe0d11203497c860.png

google_sign_in をウェブ向けに設定する

API プロジェクトの認証情報のページに戻り、別の OAuth クライアント ID を作成しますが、今回は [ウェブ アプリケーション] を選択します。

7f745c53956c1572.png

残りのフォームでは、[承認済みの JavaScript 生成元] に次のように入力します。

d45fb0e23e874e34.png

これによってクライアント ID が生成されます。以下の meta タグを web/index.html に追加し、生成されたクライアント ID が含まれるように更新します。

web/index.html

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

このサンプルを実行するには、もう少し作業が必要です。前のステップで作成した CORS プロキシを実行する必要があります。また、次の手順に沿って、ウェブ アプリケーションの OAuth クライアント ID のフォームで指定したポートで Flutter ウェブアプリを実行する必要もあります。

ターミナルで、次のようにして 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".

再度ログインすると、自分の再生リストが表示されます。

c9c43252341fa197.png

8. 次のステップ

おめでとうございます!

この Codelab を修了し、Flutter でサポートされている 6 つのプラットフォームすべてで動作するアダプティブな Flutter アプリを作成しました。画面のレイアウト方法、テキストの操作方法、画像の読み込み方法、認証の動作における違いに対応するようコードを適用させました。

アプリケーションで適応できることは、ほかにも多くあります。実行される環境にコードを適応させるためのその他の方法については、アダプティブなアプリの作成をご覧ください。