Flutter開発:Googleカレンダーにアクセスしてデータの登録・読込・削除等をする方法

Flutter

Flutterアプリから、Googleカレンダーにアクセスして、予定データを登録したり、データを読み込んだりする機能の実装方法を知りたい。

という方向けの記事になります。

 

Googleカレンダーにアクセスするには、「Google Calendar API」を利用する必要がありますが、自分が初めて実装したとき、日本語・英語ともに情報が少なく、苦労したので、その経験を共有したいと思います。

 

最大の難点は、

 

GoogleによるOAuth検証の事前承認が必要

 

という点です。

 

Google Driveへのアクセスなら、事前承認が不要なレベルに留める方法もありますが、Google Calendarはユーザーデータの機密性が高いため、事前承認が必須になってしまいます。

 

また、Googleカレンダーに限った話ではありませんが、Googleサインインを実装する際の

 

同意画面のエラーハンドリング(ユーザーが想定外の選択をした場合の処理)

 

も苦戦しました。

 

本記事では、自分の経験をもとに、FlutterアプリからGoogleカレンダーにアクセスするサンプルプロジェクトを作成したので、それを元に、具体的な実装方法や注意点をご説明します。

 

サンプルプロジェクト実装の大まかな流れは以下のとおりです。

 

  1. FirebaseでFirebaseプロジェクトを作成し、Googleサインインを有効化
  2. Google Cloud Platform(以下、GCP)で同意画面の設定APIキーの制限
  3. Flutterプロジェクトに必要なパッケージを導入
  4. Flutterのコードを書く(Google サインイン、Googleカレンダーへの保存・読込処理等)
  5. GoogleによるOAuth検証の承認申請 ※アプリをリリースする場合

  

サンプルコードのみをご覧になりたい方は、こちらをご覧ください。

 

下記記事に、他のGoogle APIの実装例として、Google Driveへのアクセス方法を整理していますので、よろしければご参考ください。

 

 

※GCPの設定や、APIへのアクセス方法など、一部内容が重複する部分があることをご了承くださいm(_ _)m。

 


 

40代からプログラミング(Flutter)を始めて、GooglePlayAppStoreにアプリを公開しているhalzo appdevです。

 

作成したアプリはこちら↓ 全てFlutterで開発したアプリです。

 

超即ToDo –最短2タップで通知登録できるタスク管理アプリ

Google Play で手に入れよう
Download on the App Store

 

かんたんプリント管理:アラート・OCR文字認識・検索機能を搭載

Google Play で手に入れよう
Download on the App Store

 

シンプルメモ帳「BasicMemo」 – 文字カウント、ワンタッチ入力、タグ管理等の機能を搭載

Google Play で手に入れよう
Download on the App Store

 

  1. 参考になる情報ソース
  2. Google Calendar API実装にあたり検討すべき選択肢
    1. Googleサインインの実装方法
    2. ユーザー同意の取得方法
  3. Googleカレンダー サンプルプロジェクトの概略
  4. サンプルプロジェクトの実装手順
    1. 実装手順① Firebaseプロジェクトの作成 
    2. 実装手順② Google Cloud Platform(GCP)での設定
      1. 同意画面の設定
      2. APIキーの制限
    3. 実装手順③ パッケージの導入
    4. 実装手順④ コードの記述
      1. サンプルコードの全体像
      2. Google APIで使用するスコープ(権限範囲)を指定する
      3. Google サインインの画面をキャンセルされたときの処理を入れる
      4. iOSの挙動に備えて、signInSilentlyメソッドを併用する
      5. サインイン済でもエラーとなる場合は、認証情報を初期化する
      6. 同意画面にチェックなしで「続行」を押されたときのエラー回避処理
      7. ステップ① HTTPクライアントのインスタンスを作成
      8. ステップ① Eventクラスのインスタンスに各種予定情報を格納
      9. ステップ① events.insertメソッドで、カレンダーIDを指定して登録
      10. ステップ② events.getメソッドで、登録した予定情報を取得
      11. ステップ③ events.updateメソッドで、登録した予定情報を更新
      12. ステップ④ events.deleteメソッドで、登録した予定情報を削除(完全には消えない)
      13. ステップ⑤ 「disconnect」メソッドでサインアウト処理
  5. リリース前に、GoogleにOAuth検証の承認を得る(必須)
  6. その他補足:一度同意した同意画面を再度表示させる方法

参考になる情報ソース

Googleカレンダーへのアクセスを実装するには、まず、Googleが提供するAPI (Google Calendar API)へのアクセス方法(OAuthと呼ばれる認証方式によるアクセス方法)を理解する必要がありますが、Flutterを前提としたGoogle公式の情報ソースを見つけるのに苦労しました。

 

Google Calendar APIの公式サイトは以下ですが、主に、Java、Python、PHP、Rubyのコード例しかなく、Flutter(Dart)のコード例はありません。

 

 

色々探した結果、最終的に、以下3つが有力な情報ソースになりました。

 

①Flutter公式

 

 

これが一番参考になります。

 

Flutter公式のため、ネイティブアプリを前提とした内容になっています。Flutter公式サイト内のリンクからは辿れないページのため、直接ググってたどり着きました。。

 

②Google Developer公式

 

 

上記ページの中の「Dart」のリンクからGitHubのページに行くと、Dartで書かれたサンプルコードを見ることができます。

 

但し、基本的にWebアプリを想定した内容になっている点に注意が必要です。  

 

③各パッケージの公式説明

 

コードを書く際に導入する下記パッケージのReadMe、Sample、ソースコード内のコメントなどが参考になります。

 

 

特に、Google Calendar APIのメソッドは、「googleapis(googleapis/calendar/v3.dart)」のソースコード内を探索すると、説明が詳しく書かれているので、情報を見つけやすいです。

 

また、Googleカレンダーに関する具体的な処理を記述したコードを紹介されている貴重な例として、下記記事が大変参考になりました。ありがとうございます!

 

 

 

  

Google Calendar API実装にあたり検討すべき選択肢

Googleサインインの実装方法

アプリからGoogleカレンダーにアクセスする際、Googleアカウントによるサインイン処理を実装する必要があります。

 

方法としては、「google_sign_in」パッケージのみを使う方法と、「firebase_auth」パッケージも併用して、サインインしたユーザー情報をFirebase内に記録する方法とがあります。

 

一般的には、Firestoreとの連携などを想定して、後者の方法が紹介されていることが多いかと思います。

 

しかし、サインインしたユーザーのメールアドレスがFirebase上に記録されるため、個人情報の管理が発生してしまいます。

 

 

個人的には、できるだけ個人情報を取得せず、情報管理の難易度を下げるため、後述するサンプルコードでは、「google_sign_in」パッケージのみを用いた方法にしました。

 

なお、「firebase_auth」を併用する際のコードも、参考としてコメントアウトで記載しました。

 

ユーザー同意の取得方法

サインイン後は、同意取得画面(OAuth同意画面と呼ばれる。以下、同意画面)を表示して、Google カレンダーへのアクセスについて、ユーザーから同意を得る必要があります。

 

同意画面の表示には、アプリ上のポップアップで行う方法と、Webブラウザに飛ばしてブラウザ上で行う方法とがあります。

 

この画面は、GoogleカレンダーとGoogleドライブにアクセスする場合の例です。

 

  • 前者が、Flutter公式で紹介されている方法(「extension_google_sign_in_as_googleapis_auth」パッケージを用いる方法)
  • 後者が、Google Developer公式で紹介されている方法(「googleapi_auth」パッケージを用いる方法)

 

になります。

 

開発者にとっては、後者の方が実装の仕組みが分かりやすいのですが、ユーザーにとってはアプリとブラウザの切り替えが生じるので、利便性が悪いです。

 

そのため、本記事では前者の方法(アプリ上のポップアップで行う方法)を採用しました。

 

Googleカレンダー サンプルプロジェクトの概略

具体的な実装方法を示すために、今回作成したサンプルプロジェクトの概略をご説明します。

 

画面イメージは下記に掲載しますが、以下の流れでGoogleカレンダーとの間で予定の登録・読込・削除をテストできる仕様になっています。

 

各ステップに対応したボタンをタップすると、下記内容が実行されます。

 

  • ステップ①:登録
    • テキスト入力欄に、予定のタイトル予定のメモを入力後、①のボタンを押すと、サインインしたGoogleアカウントのGoogleカレンダーに予定が登録されます。
      • 登録する前に、Google アカウントへのサインインと、Googleカレンダーへのアクセスに関する同意取得処理を行います(本処理は、①〜④のいずれかを最初に実行した時のみ行われます)。
    • 本サンプルでは、登録される予定の日時は現在時刻から5日後に設定され(登録される時間の幅は1時間です)、リマインダーは、上記時刻から1日前ポップアップで通知されるよう、設定されます。
      • 現在時刻に応じた登録予定日時とリマインダーのタイミングは、画面内に表示されます。
  • ステップ②:取得
    • 画面中央の予定ID入力欄に、Googleカレンダーの登録内容を確認したい予定のID(32桁。以下「予定ID」)を貼り付けます。
      • ステップ①で予定を登録すると、入力欄の上に予定IDが表示されます。登録したばかりの予定にアクセスしたい場合は、これをコピーして入力欄に貼り付けます。
      • 本サンプルプロジェクトで登録すると、メモ欄の最初に予定IDが表示されるので、それを入力欄に貼り付ければ、過去に登録した予定も指定できます。
    • ②のボタンを押すと、貼り付けた予定IDの登録情報(予定のタイトル、登録日時、リマインダー時期・方法、メモ)が取得され、コンソール(デバッグウィンドウ)に表示されます。
  • ステップ③:更新
    • 予定のタイトルやメモを修正し、更新したい予定の予定IDを貼り付けて③のボタンを押すと、Googleカレンダー上の予定内容が更新されます。
  • ステップ④:削除
    • 削除したい予定の予定IDを貼り付けて④のボタンを押すと、Googleカレンダー上の予定が削除されます(完全には削除されず、Googleカレンダー内のゴミ箱に移動します)。
  • ステップ⑤:サインアウト
    • Google アカウントからサインアウトします。
      • 本サンプルプロジェクトでは、サインアウトすると、もう一度、同意画面への同意が必要になります。

 

 

Googleカレンダーは、機密性の高いスコープに該当するため、GoogleからのOAuth検証の承認(後述)を得るまでは、同意画面に遷移する前に、下図のような警告画面が出ます。

 

 

この警告画面は、GCPの同意画面設定時に、「公開ステータス」を「本番環境」にした場合の画面ですが、下記手順で「テストに戻る」でテストモードに変更した場合は、下図のような画面(やや警告のトーンが低い画面)になります。

 

※テストモードは、事前登録したメールアドレスのユーザーのみ(100人まで)が利用可能になるモードです。

 

 

サンプルプロジェクトの実装手順

実装手順① Firebaseプロジェクトの作成 

まず、Firebaseプロジェクトを作成し、AndroidアプリとiOSアプリの設定をしておきます。

 

具体的な作成・設定方法は、下記記事に整理しましたので、こちらをご参考にしてください。

 

 

なお、Google サインインの実装を、「google_sign_in」パッケージのみで実装する場合であっても、Firebase Authentication上で、Google サインインを有効化する処理は必要になります。

 

そのため、上記参考記事の「Firebase Authenticationの設定手順」まで(「Google Playリリース後の設定」の手前まで)は完了させてください。

 

同時に、Google Cloud Platform(GCP)のプロジェクトも作成されることになります。

 

実装手順② Google Cloud Platform(GCP)での設定

同意画面の設定

Firebaseプロジェクトを作成したら、Google Cloud Platform(GCP)で同意画面の設定をします。

 

具体的な手順は、以下の記事に詳細を整理したので、こちらをご参照ください。

 

 

上記関連記事の「OAuth検証の審査申請手順」の中の

 

  • 「APIの有効化」※「Google Calendar API」のみで大丈夫です。
  • 「OAuth同意画面等の設定(アプリ登録の編集)」※スコープは「…/auth/calendar」のみで大丈夫です。

 

を実施してください。

 

この段階では同意画面の設定までを行い、GoogleへのOAuth検証の承認申請は、実装後の段階で行います。

 

APIキーの制限

セキュリティ向上のため、APIキーの編集画面で、アクセスできるアプリに制限をかけます(制限無しでもアプリは動作するため、任意の対応事項になりますが、やったほうが安全かと思います)。

 

制限をかけていない状態だと、下図のようにオレンジの警告マークが表示されます。

 

 

下記手順で、iOS、Androidいずれも設定します(WebのAPIキーを設定した場合もご参考に掲載します)。

 

※本記事では、アプリケーション側に制限をかける例を説明していますが、APIにも制限をかければ、よりセキュリティが強化されます。ただし、Firebase関連のAPIなどは、選択を誤って制限をかけると、正常に動作しない場合があるので、注意が必要です。

 

【iOSの場合】

 

 

【Androidの場合】

 

 

 

【Webの場合】

 

 

以上により、下図のように緑のチェックマークが表示されれば完了です。

 

 

実装手順③ パッケージの導入

Flutterプロジェクトの「pubspec.yaml」ファイルに、以下のパッケージを記載し、「pub get」します。

 

 

サンプルコードでは、上記以外にも「material」を冒頭でインポートしていますが、いずれもFlutterに標準で入っているため、「pubspec.yaml」への記載は不要です。

 

実装手順④ コードの記述

サンプルコードの全体像

以下に、今回作成したサンプルコードの全内容を掲載します。

 

コードを見ただけでも分かるよう、できるだけコード内にコメントで説明をつけました。

 

// クラス名、メソッド名、プロパティ名(変数名)について、筆者が作成したもの(名前変更可のもの)
// の名前の末尾には、大文字のオー「O」をつけています
// ※ライブラリ(パッケージ)で予め決められているもの(名前の変更不可のもの)と、
//  自分で作成したもの(名前の変更可のもの)の区別をしやすくするため

import 'package:flutter/material.dart';
import 'package:google_sign_in/google_sign_in.dart' as signInO;
import 'package:googleapis/calendar/v3.dart' as calendarO;
import 'package:extension_google_sign_in_as_googleapis_auth/extension_google_sign_in_as_googleapis_auth.dart';
import 'package:uuid/uuid.dart';

// // firebase上にサインインしたユーザー情報を記録する場合は以下をインポート ※pubspec.yamlに追記が必要
// import 'package:firebase_core/firebase_core.dart';
// import 'package:firebase_auth/firebase_auth.dart';


void main() => runApp(MyApp());

// // firebase上にサインインのユーザー情報を記録する場合は、
// // firebaseの初期化処理が必要になるため、上の1文を下記に書き換える
// // ※firebase_coreのインポートが必要
// Future<void> main() async{
//
//   // main関数内で非同期処理をするときはこれが必要
//   WidgetsFlutterBinding.ensureInitialized();
//
//   // Firebaseの初期化処理
//   await Firebase.initializeApp();
//
//   runApp(MyApp());
// }

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: "Google Calendar Test",
      theme: ThemeData.light(),
      home: SampleScreenO(),
    );
  }
}

class SampleScreenO extends StatefulWidget {
  @override
  _SampleScreenOState createState() => _SampleScreenOState();
}

class _SampleScreenOState extends State<SampleScreenO> {

  // 以下のプロパティは、メソッド間で共有したいのでクラスのトップで定義

  // Google SignIn認証のためのインスタンス
  // ※google_sign_inパッケージのインポート文に、as signInOを付けているので、
  //  google_sign_inのクラスには「signInO.」を付けて表記
  late signInO.GoogleSignIn googleSignInO;

  // GoogleSignInAccount?(ユーザー情報を保持するクラス)のインスタンス
  // サインインをキャンセルしたときはnullになりうるので、null許容「?」で定義する
  signInO.GoogleSignInAccount? accountO;

  // Google認証情報を入れるプロパティ
  // 本来はAuthClient型で宣言するプロパティだが、AuthClientを明示すると、googleapis_authパッケージの
  // インポートが必要になるので、varで宣言してインポートを回避する
  // ※googleapis_authパッケージは、extension_google_sign_in_as_googleapis_authパッケージ内で
  //  インポート済のため、本コード内でインポートしなくても動作する
  late var httpClientO;

  // Google Calendar APIのインスタンス
  // ※googleapisパッケージのインポート文に、as calendarO を付けているので、
  //  googleapisのクラスには「calendarO.」を付けて表記
  late calendarO.CalendarApi googleCalendarApiO;

  // Google SignInの状態を示す文字列 ※起動時は空文字
  String signInStatusO = "";

  // Googleカレンダーに登録する予定ID ※初期値は空文字
  String registeredScheduleIdO = "";

  // 予定のタイトル、予定のメモ、予定IDのそれぞれの入力欄(TextFormField)に
  // 設置するコントローラー
  TextEditingController titleControllerO = TextEditingController();
  TextEditingController memoControllerO = TextEditingController();
  TextEditingController scheduleIdControllerO = TextEditingController();

  // スクロールバーを常時表示するために必要なコントローラー
  ScrollController scrollControllerO = ScrollController();

  // 予定日時を入れるプロパティ
  late DateTime registeredDateO;

  // リマインダーを発動するタイミングを表す値(予定日時からどれだけ前に通知するか)
  late int reminderTimingO;

  // リマインダーを発動するタイミングを表す単位(分前、時間前、日前、週間前を設定可能)
  late String reminderUnitO;

  // リマインダーの方法を表すプロパティ
  // ここでは、プッシュ通知にするため"popup"とする
  // ※メール通知にしたい場合は、"email"に変更する
  String reminderMethodO = "popup";


  @override
  void initState() {

    // 予定のタイトル・メモの初期値は空文字を設定
    titleControllerO.text = "";
    memoControllerO.text = "";

    // 本サンプルコードでは、簡略化のため、
    // Googleカレンダーに登録する予定日時を、現在時刻から5日後とする
    registeredDateO = DateTime.now().add(Duration(days: 5));

    // 本サンプルコードでは、簡略化のため、
    // リマインダーの発動タイミングを、予定日時の1日前とする
    reminderTimingO = 1;
    reminderUnitO = "日前";

    super.initState();
  }

  @override
  void dispose() {

    // 画面遷移を実装した場合は、各コントローラーの破棄が必要なため設定
    titleControllerO.dispose();
    memoControllerO.dispose();
    scheduleIdControllerO.dispose();
    scrollControllerO.dispose();
    super.dispose();
  }


  @override
  Widget build(BuildContext context) {

    return Scaffold(
      appBar: AppBar(
        title: FittedBox(
          fit: BoxFit.scaleDown,
          child: Text("Google Calendar 登録・読込テスト"),
        ),
      ),
      body: Scrollbar(
        // スクロールバーを常時表示させる
        isAlwaysShown: true,
        // スクロールバー常時表示のため、SingleChildScrollViewと同じ
        // コントローラーの設置が必要
        controller: scrollControllerO,

        // 画面内に表示しきれない時の描画範囲エラーを回避するため、
        // SingleChildScrollViewでラップ
        child: SingleChildScrollView(
          // スクロールバー常時表示のため、Scrollbarと同じ
          // コントローラーの設置が必要
          controller: scrollControllerO,
          child: Center(
            child: Column(
              children: <Widget>[
                SizedBox(
                  height: 15,
                ),

                /// 予定のタイトルを入力するフォーム
                Container(
                  width: MediaQuery.of(context).size.width * 0.7,
                  child: TextFormField(
                    // 予定タイトルの入力結果を保持するコントローラーを設置
                    controller: titleControllerO,
                    decoration: InputDecoration(
                        hintText: "予定のタイトルを入力"
                    ),
                  ),
                ),
                SizedBox(
                  height: 15,
                ),

                /// 予定のメモを入力するフォーム
                Container(
                  width: MediaQuery.of(context).size.width * 0.7,
                  child: TextFormField(
                    // 予定メモの入力結果を保持するコントローラーを設置
                    controller: memoControllerO,
                    decoration: InputDecoration(
                        hintText: "予定のメモを入力"
                    ),
                  ),
                ),
                SizedBox(
                  height: 15,
                ),

                /// ①③でGoogleカレンダーに登録する日時とリマインダーのタイミングを表示
                Text(
                  "登録日時(現在から5日後): ${registeredDateO.toString().substring(0,16)}",
                ),
                SizedBox(
                  height: 15,
                ),
                Text(
                    "リマインダー: 登録日時の $reminderTimingO $reminderUnitO"
                ),
                SizedBox(
                  height: 15,
                ),

                /// ①Googleカレンダーに予定を登録するボタン
                ElevatedButton(
                  // Googleカレンダーに登録するメソッドの呼び出し
                  onPressed: () => _registerInGoogleCalendarO(),
                  child: Text(
                    "①Googleカレンダーに予定を登録",
                    textAlign: TextAlign.center,
                  ),
                ),
                SizedBox(
                  height: 15,
                ),

                /// ①で登録した予定IDを表示
                // 次の予定ID入力欄にコピーペーストできるよう、SelectableTextで表示
                SelectableText(
                  "今登録した予定ID:\n$registeredScheduleIdO",
                ),
                SizedBox(
                  height: 15,
                ),

                /// ②③④の実行対象となる予定IDを入力する欄
                Container(
                  width: MediaQuery.of(context).size.width * 0.7,
                  child: TextFormField(
                    // 予定IDの入力結果を保持するコントローラーを設置
                    controller: scheduleIdControllerO,
                    decoration: InputDecoration(
                        hintText: "アクセスしたい予定IDを貼り付け"
                    ),
                    style: TextStyle(fontSize: 14.0),
                  ),
                ),
                SizedBox(
                  height: 15,
                ),

                /// ②Googleカレンダーから予定情報を取得するボタン
                ElevatedButton(
                  // Googleカレンダーから予定情報を取得するメソッドの呼び出し
                  // 入力した予定IDを引数として渡す
                  onPressed: () => _getScheduleO(scheduleIdControllerO.text),
                  child: Text(
                    "②貼り付けたIDの予定情報を取得",
                    textAlign: TextAlign.center,
                  ),
                ),
                SizedBox(
                  height: 15,
                ),

                /// ③Googleカレンダーに登録した予定を更新するボタン
                ElevatedButton(
                  // Googleカレンダーの予定を更新するメソッドの呼び出し
                  // 入力した予定IDを引数として渡す
                  onPressed: () => _updateScheduleO(scheduleIdControllerO.text),
                  child: Text(
                    "③貼り付けたIDの予定を更新",
                    textAlign: TextAlign.center,
                  ),
                ),
                SizedBox(
                  height: 15,
                ),

                /// ④Googleカレンダーに登録した予定を削除するボタン
                ElevatedButton(
                  // Googleカレンダーの予定を削除するメソッドの呼び出し
                  // 入力した予定IDを引数として渡す
                  onPressed: () => _deleteScheduleO(scheduleIdControllerO.text),
                  child: Text(
                    "④貼り付けたIDの予定を削除",
                    textAlign: TextAlign.center,
                  ),
                ),
                SizedBox(
                  height: 15,
                ),

                /// Googleアカウントからサインアウトするボタン
                ElevatedButton(
                  // サインアウトするメソッドの呼び出し
                  onPressed: () => _signOutFromGoogleO(),
                  child: Text(
                    "⑤サインアウト",
                    textAlign: TextAlign.center,
                  ),
                ),
                SizedBox(
                  height: 15,
                ),

                /// Googleアカウントへのサインイン状態を表示
                Text(signInStatusO),
              ],
            ),
          ),
        ),
      ),
    );
  }

  /// Google SignIn処理をするメソッド
  Future<bool> _googleSignInMethodO() async{

    // Google SignIn認証のためのインスタンスを作成
    googleSignInO = signInO.GoogleSignIn(
        scopes: [

          // Google APIで使用したいスコープを指定
          // ※ここではGoogleカレンダーへのアクセス権を要求
          calendarO.CalendarApi.calendarScope,

        ]);

    // サインイン画面や同意画面のポップアップをキャンセルした場合のエラーを回避するため、
    // try catchを設定
    // 但し、デバッグモードでは止まってしまうので、キャンセル時の挙動を確かめるには、
    // Runモードで実行する必要あり
    try {

      // isSignedInメソッドでサインイン済か否か確認
      final checkSignInResultO = await googleSignInO.isSignedIn();
      print("サインインしているか否か $checkSignInResultO");

      // サインイン済の場合は、サインインのポップアップを出さずにサインイン処理する
      // ※iOSの場合はsignInSilentlyにしないと、毎回サインインのポップアップが出てしまうため
      if (checkSignInResultO) {
        accountO = await googleSignInO.signInSilently();

        // サインイン済にもかかわらず返り値がnullの場合、
        // ユーザーがGoogleアカウントの設定画面で接続解除をした可能性があるので、
        // disconnectメソッドで完全サインアウトし、認証情報を初期化する
        if (accountO == null) {
          print("認証情報を初期化する必要が生じたため、もう一度ボタンを押してください。");
          await googleSignInO.disconnect();
          // 例外処理を投げて、下方のcatchに飛ばす
          throw Exception();
        }

      } else {
        // サインインしていない場合は、ポップアップを出してサインイン処理
        accountO = await googleSignInO.signIn();

        // 返り値がnullだったら、サインインもしくは同意処理がキャンセルされたと判断し、
        // 例外処理を投げて、下方のcatchに飛ばす
        if (accountO == null) {
          print("キャンセル");
          throw Exception();
        }
      }

      // /// firebase上にサインインしたユーザー情報を記録する場合は以下を追加
      // // ※firebase_auth、firebase_coreのインポートが必要
      //
      // signInO.GoogleSignInAuthentication authO =
      // await accountO!.authentication;
      //
      // final OAuthCredential credentialO =
      // GoogleAuthProvider.credential(
      //   idToken: authO.idToken,
      //   accessToken: authO.accessToken,
      // );
      //
      // // このユーザーデータ(userO)を必要に応じて使用する
      // User? userO =
      //     (await FirebaseAuth.instance.signInWithCredential(credentialO)).user;
      //
      // // 使用する一例として、Firebase上で管理される「ユーザーUID」をログに表示
      // print("ユーザーUID: ${userO!.uid}");

      // サインイン表示に変更し、再描画
      setState(() {
        signInStatusO = "サインイン中";
      });

      // 返り値trueを返す
      return true;

    } catch (e) {

      // サインアウト表示に変更し、再描画
      setState(() {
        signInStatusO = "サインアウト中";
      });

      // 返り値falseを返す
      print("サインインできず $e");
      return false;
    }
  }


  /// ステップ① 予定の新規登録
  Future<void> _registerInGoogleCalendarO() async{

    /// Google SignInの処理
    final signInResultO = await _googleSignInMethodO();

    if (!signInResultO) {
      // サインインできなかった場合は、早期リターン
      return;
    }

    /// Googleカレンダーのインスタンス作成
    // Googleサインインで認証済のHTTPクライアントのインスタンスを作成
    // extension_google_sign_in_as_googleapis_authパッケージのメソッドを使用
    try {
      httpClientO = (await googleSignInO.authenticatedClient())!;

      // Androidの同意画面で、チェックせずに続行後、キャンセルした場合のエラー処理は、ここに含む
    } catch (eO) {
      print("権限付与エラー $eO");
      // エラーの場合は、同意画面に再度チェックさせるため、一度完全サインアウトする
      await _signOutFromGoogleO();
      // 早期リターン
      return;
    }

    // Google Calendar APIのインスタンスを作成
    googleCalendarApiO = calendarO.CalendarApi(httpClientO);

    // 予定を登録するカレンダーのIDを指定
    // 本サンプルコードでは、全て「primary」
    // (Googleカレンダー利用時に最初に作成されるカレンダー)を
    // 指定することとする
    // ※"primary"は、Googleアカウントのメールアドレス("...@gmail.com")にしても可能
    String calendarIdO = "primary";

    // Googleアカウント内に複数のカレンダーを作成していて、primary以外のカレンダーに
    // 登録したい場合は、下記のように「calendarList.list」メソッドを用いて
    // 該当のカレンダーIDを取得しておく
    // また、iOSの同意画面でチェックせず続行した場合は、このlistメソッドがエラーになるので、
    // try catchでエラー処理も行う
    // ※このパートは、primaryを使う場合は必須ではないが、上記エラー処理を入れる目的で①〜④の各箇所に設置
    try {
      calendarO.CalendarList calendarListO = await googleCalendarApiO.calendarList.list();
      List<calendarO.CalendarListEntry>? calendarListEntryO = calendarListO.items;
      calendarListEntryO!.forEach((elementO){print("calendarIdを表示 ${elementO.id}");});

     // iOSで同意画面にチェックせず続行した場合のエラー処理
    } catch (eO) {
      print("権限付与エラー $eO");
      // エラーの場合は、同意画面に再度チェックさせるため、一度完全サインアウトする
      await _signOutFromGoogleO();
      // 早期リターン
      return;
    }

    // 予定情報を格納するためのEventクラスのインスタンスを作成
    calendarO.Event eventO = calendarO.Event();

    /// 予定タイトルをインスタンス内のプロパティに設定
    eventO.summary = titleControllerO.text;

    /// 予定日時(期間の開始時刻)をインスタンス内のプロパティに設定
    // EventDateTimeクラスのインスタンスを作成し、開始日時とタイムゾーンのプロパティに値を設定
    calendarO.EventDateTime startO = calendarO.EventDateTime();
    startO.dateTime = registeredDateO;

    // 日本の場合は"GMT+09:00"
    startO.timeZone = "GMT+09:00";

    // 上記開始日時に関するEventDateTimeクラスのインスタンスを
    // Eventクラスのインスタンス内のstartプロパティに設定
    eventO.start = startO;

    /// 予定日時+1h(期間の終了時刻)の登録
    calendarO.EventDateTime endO = new calendarO.EventDateTime();

    // 本サンプルコードでは、Googleカレンダーに登録する終了時刻を
    // 開始時刻の1時間後とする
    endO.dateTime = registeredDateO.add(Duration(hours: 1));
    endO.timeZone = "GMT+09:00";
    eventO.end = endO;

    /// リマインダーの設定
    // リマインダー情報を設定するためのEventReminderクラスのインスタンスを作成
    // リマインダーは複数設定できるため、リスト型になる
    List<calendarO.EventReminder>? overridesO = [];

    // インスタンスに設定するリマインダーの値(予定日時からどれだけ前にリマインダーを出すか)を
    // 分(minute)に換算して設定する
    final int reminderMinutesO = reminderTimingO * 60 * 24;
    calendarO.EventReminder reminderFirstO = calendarO.EventReminder(
      method: reminderMethodO,

      // リマインダーの値には上限(40320分)・下限(0分)があるため、
      // 上限・下限チェックを入れる
      minutes: (reminderMinutesO > 40320)
          ? 40320
          : (reminderMinutesO < 0)
          ? 0
          : reminderMinutesO,
    );

    // リマインダー設定値をリストに格納
    overridesO.add(reminderFirstO);

    // 上記リストをEventReminders型のインスタンスに設定
    // デフォルトのリマインダー設定を使わないため、useDefaultプロパティはfalseにする
    calendarO.EventReminders eventRemindersO =
    calendarO.EventReminders(overrides: overridesO, useDefault: false);

    // 上記EventReminders型のインスタンスを
    // Eventクラスのインスタンス内のremindersプロパティに設定
    eventO.reminders = eventRemindersO;

    /// 予定IDの設定
    // uuidパッケージを使ってユニークなIDを作成し、
    // Eventクラスのインスタンス内のidプロパティに設定
    // Googleカレンダーのidはハイフン不可のため削除しておく
    // ※ハイフンを削除しないとエラーになる
    registeredScheduleIdO = Uuid().v1().replaceAll("-", "");
    eventO.id = registeredScheduleIdO;

    /// 予定メモの設定
    // メモ内容をEventクラスのインスタンス内のdescriptionプロパティに設定
    // 上記予定IDを確認できるよう、メモの冒頭に挿入する
    eventO.description = "予定ID:$registeredScheduleIdO\n${memoControllerO.text}";

    /// Googleカレンダーへの登録処理
    // 下記に出てくる「events」はGoogle Calendar API内のプロパティ(既出の「eventO」とは別物)
    // events.insertメソッドで予定を登録する
    // ※指定したカレンダー(ここでは"primary")に
    //  上記で一連の値を設定したEventクラスのインスタンスを登録する
    try {
      await googleCalendarApiO.events.insert(eventO, calendarIdO).then((value) {

        // 問題なく登録されると、返り値のstatusプロパティに"confirmed"が
        // 返ってくるので、それに応じて成否メッセージを表示
        if (value.status == "confirmed") {
          print("予定の登録成功");
        } else {
          print("予定が登録されず");
        }
      });
    } catch (eO) {
      print("登録エラー $eO");
      // エラーのため予定IDは表示せずリターン
      return;
    }
    // 登録された予定IDを表示するため再描画
    setState(() {});
  }


  /// ステップ② 予定情報の取得
  Future<void> _getScheduleO(String scheduleIdO) async{

    /// 予定IDの入力が空だったら早期リターン
    // Googleカレンダーのidはハイフン不可のため、念のため除外しておく
    scheduleIdO = scheduleIdO.replaceAll("-", "");
    if (scheduleIdO == "") return;

    /// Google SignInの処理
    // サインインせずに実行した場合に備え、ここでもサインイン処理をする
    final signInResultO = await _googleSignInMethodO();

    if (!signInResultO) {
      // サインインできなかった場合は、早期リターン
      return;
    }

    /// Googleカレンダーからの情報取得処理
    // 起動後最初にこのボタンを実行した場合に備え、ここでも
    // Googleサインインで認証済のHTTPクライアントのインスタンスを作成
    try {
      httpClientO = (await googleSignInO.authenticatedClient())!;
    } catch (eO) {
      print("権限付与エラー $eO");
      // エラーの場合は、同意画面に再度チェックさせるため、一度完全サインアウトする
      await _signOutFromGoogleO();
      return;
    }

    // Google Calendar APIのインスタンスを作成
    googleCalendarApiO = calendarO.CalendarApi(httpClientO);

    // 予定情報を取得したいカレンダーのIDを指定
    // 本サンプルコードでは、「primary」カレンダーとする
    String calendarIdO = "primary";

    try {
      calendarO.CalendarList calendarListO = await googleCalendarApiO.calendarList.list();
      List<calendarO.CalendarListEntry>? calendarListEntryO = calendarListO.items;
      calendarListEntryO!.forEach((elementO){print("calendarIdを表示 ${elementO.id}");});
      // iOSで同意画面にチェックせず続行した場合のエラー処理
    } catch (eO) {
      print("権限付与エラー $eO");
      await _signOutFromGoogleO();
      return;
    }

    // 上記カレンダーIDと、予定ID(TextFormFieldに入力したID)を指定し、
    // events.getメソッドで予定情報を取得する
    // ※エラーにならなければ、データがあることを意味する
    //  その場合、予定データを取得し、設定したプロパティの値をprintで表示
    try {
      await googleCalendarApiO.events.get(calendarIdO, scheduleIdO).then(
            // events.getメソッドの返り値(Event型)をvalueOで受ける
            (valueO) {
              // 端末のTimeZoneで表示するため、.toLocal()をつける
              // リマインダーは1つしか設定していないため、配列の1番目(配列番号0)のみを取得
          print("タイトル:${valueO.summary}, 予定日時:${valueO.start!.dateTime!.toLocal().toString()}, リマインダー:${valueO.reminders!.overrides![0].minutes}分前 (方法:${valueO.reminders!.overrides![0].method}), メモ:${valueO.description}");
        },
      );

      // エラーの時はデータが無いので、何もせずリターン
    } catch (e) {
      print("予定データなし $e");
      return;
    }
  }


  /// ステップ③ 予定の更新
  Future<void> _updateScheduleO(String scheduleIdO) async{

    /// 予定IDの入力が空だったら早期リターン
    // Googleカレンダーのidはハイフン不可のため、念のため除外しておく
    scheduleIdO = scheduleIdO.replaceAll("-", "");
    if (scheduleIdO == "") return;

    /// Google SignInの処理
    // サインインせずに実行した場合に備え、ここでもサインイン処理をする
    final signInResultO = await _googleSignInMethodO();

    if (!signInResultO) {
      // サインインできなかった場合は、早期リターン
      return;
    }

    /// Googleカレンダーのインスタンス作成
    // 起動後最初にこのボタンを実行した場合に備え、ここでも
    // Googleサインインで認証済のHTTPクライアントのインスタンスを作成
    try {
      httpClientO = (await googleSignInO.authenticatedClient())!;
    } catch (eO) {
      print("権限付与エラー $eO");
      // エラーの場合は、同意画面に再度チェックさせるため、一度完全サインアウトする
      await _signOutFromGoogleO();
      return;
    }

    // Google Calendar APIのインスタンスを作成
    googleCalendarApiO = calendarO.CalendarApi(httpClientO);

    // 予定情報を更新したいカレンダーのIDを指定
    // 本サンプルコードでは、「primary」カレンダーとする
    String calendarIdO = "primary";

    try {
      calendarO.CalendarList calendarListO = await googleCalendarApiO.calendarList.list();
      List<calendarO.CalendarListEntry>? calendarListEntryO = calendarListO.items;
      calendarListEntryO!.forEach((elementO){print("calendarIdを表示 ${elementO.id}");});
      // iOSで同意画面にチェックせず続行した場合のエラー処理
    } catch (eO) {
      print("権限付与エラー $eO");
      await _signOutFromGoogleO();
      return;
    }

    // 更新用のEventクラスのインスタンスを作成
    calendarO.Event eventO = calendarO.Event();

    /// Googleカレンダーに指定した予定IDのデータがあるか否かの判定処理
    // events.getメソッドでチェックし、エラーにならなければデータあり
    try {
      await googleCalendarApiO.events.get(calendarIdO, scheduleIdO).then(
            (valueO) {
          print("既存データあり");
        },
      );

      // エラーの時はデータが無いため、早期リターン
    } catch (e) {
      print("既存データなし $e");
      return;
    }

    /// タイトルの再登録
    eventO.summary = titleControllerO.text;

    /// 予定日時(期間の開始時刻)の再登録
    calendarO.EventDateTime startO = calendarO.EventDateTime();
    startO.dateTime = registeredDateO;
    // 日本の場合は"GMT+09:00"
    startO.timeZone = "GMT+09:00";
    eventO.start = startO;

    /// 予定日時+1h(期間の終了時刻)の再登録
    calendarO.EventDateTime endO = new calendarO.EventDateTime();
    endO.dateTime = registeredDateO.add(Duration(hours: 1));
    endO.timeZone = "GMT+09:00";
    eventO.end = endO;

    /// リマインダーの再設定
    List<calendarO.EventReminder>? overridesO = [];
    final int reminderMinutesO = reminderTimingO * 60 * 24;
    calendarO.EventReminder reminderFirstO = calendarO.EventReminder(
      method: reminderMethodO,
      // 念のため上限・下限チェックを入れる
      minutes: (reminderMinutesO > 40320)
          ? 40320
          : (reminderMinutesO < 0)
          ? 0
          : reminderMinutesO,
    );
    // アラート内容をリストに追加
    overridesO.add(reminderFirstO);

    // 上記リストをEventReminders型のインスタンスに設定
    calendarO.EventReminders eventRemindersO =
    calendarO.EventReminders(overrides: overridesO, useDefault: false);
    eventO.reminders = eventRemindersO;

    // 予定IDは設定済のため、再設定は不要

    /// メモ内容の再設定
    eventO.description = "予定ID:$scheduleIdO\n${memoControllerO.text}";

    /// 予定IDのデータの更新処理
    try {
      //  events.updateメソッドに、
      //  "primary"カレンダーを表す calendarIdO、予定IDを表す scheduleIdO、
      //  更新用のEventクラスのインスタンス eventO を引数として渡すことで、予定を更新する
      await googleCalendarApiO.events
          .update(eventO, calendarIdO, scheduleIdO)
          .then((value) {
        if (value.status == "confirmed") {
          print("予定の更新に成功");
        } else {
          print("予定の更新失敗");
        }
      });

    } catch (e) {
      print("エラー $e");
      return;
    }

  }


  /// ステップ④ 予定の削除
  Future<void> _deleteScheduleO(String scheduleIdO) async{

    /// 予定IDの入力が空だったら早期リターン
    // Googleカレンダーのidはハイフン不可のため、念のため除外しておく
    scheduleIdO = scheduleIdO.replaceAll("-", "");
    if (scheduleIdO == "") return;

    /// Google SignInの処理
    // サインインせずに実行した場合に備え、ここでもサインイン処理をする
    final signInResultO = await _googleSignInMethodO();

    if (!signInResultO) {
      // サインインできなかった場合は、早期リターン
      return;
    }

    /// Googleカレンダーのインスタンス作成
    // 起動後最初にこのボタンを実行した場合に備え、ここでも
    // Googleサインインで認証済のHTTPクライアントのインスタンスを作成
    try {
      httpClientO = (await googleSignInO.authenticatedClient())!;
    } catch (eO) {
      print("権限付与エラー $eO");
      // エラーの場合は、同意画面に再度チェックさせるため、一度完全サインアウトする
      await _signOutFromGoogleO();
      return;
    }

    // Google Calendar APIのインスタンスを作成
    googleCalendarApiO = calendarO.CalendarApi(httpClientO);

    // 予定を削除したいカレンダーのIDを指定
    // 本サンプルコードでは、「primary」カレンダーとする
    String calendarIdO = "primary";

    try {
      calendarO.CalendarList calendarListO = await googleCalendarApiO.calendarList.list();
      List<calendarO.CalendarListEntry>? calendarListEntryO = calendarListO.items;
      calendarListEntryO!.forEach((elementO){print("calendarIdを表示 ${elementO.id}");});
      // iOSで同意画面にチェックせず続行した場合のエラー処理
    } catch (eO) {
      print("権限付与エラー $eO");
      await _signOutFromGoogleO();
      return;
    }

    /// Google Calendarに指定した予定IDのデータがあるか否かの判定処理
    // events.getメソッドでチェックし、エラーにならなければデータあり
    try {
      await googleCalendarApiO.events.get(calendarIdO, scheduleIdO).then(
            (valueO) {
          print("既存データあり");
        },
      );

      // エラーになればデータ無しのため、早期リターン
    } catch (e) {
      print("既存データなし $e");
      return;
    }

    /// 予定IDのデータの削除処理
    try {
      // "primary"カレンダーを表す calendarIdO と、予定IDを表す scheduleIdO を引数で指定する
      // ※イベントデータ eventO は引数に取れない
      // ※insert、updateと異なり、返り値はなし
      // events.deleteメソッドは、ゴミ箱に移すだけで、完全には削除しない(events.getメソッドで情報取得可能)
      // Googleカレンダー上で手動でゴミ箱から削除すれば、完全削除される(events.getメソッドで取得できなくなる)
      await googleCalendarApiO.events.delete(calendarIdO, scheduleIdO);
      print("ID $scheduleIdO の予定を削除");
    } catch (e) {
      print("エラー $e");
      return;
    }

  }


  /// ステップ⑤ サインアウト処理
  Future<void> _signOutFromGoogleO() async {

    // サインインせずこのボタンを押した場合を想定し、
    // ここでもGoogle SignIn認証のためのインスタンスを作成する
    googleSignInO = signInO.GoogleSignIn(scopes: [
      calendarO.CalendarApi.calendarScope,
    ]);

    try {
      await googleSignInO.disconnect();
      // // 再サインインするときに同意画面を表示させたくない場合は、上記1文を以下に変更
      // await googleSignInO.signOut();

      // // firebase上にサインインしたユーザー情報を記録している場合は以下を追加
      // // ※firebase_auth、firebase_coreのインポートが必要
      // await FirebaseAuth.instance.signOut();

      // サインアウト表示に変更し、再描画
      setState(() {
        signInStatusO = "サインアウト中";
      });
    } catch (e) {
      print("サインアウトエラー $e");

      // サインイン中か否か判定して、それに応じた表示に変更
      final isSignedInO = await googleSignInO.isSignedIn();
      setState(() {
        isSignedInO ? signInStatusO = "サインイン中" : signInStatusO = "サインアウト中";
      });

      return;
    }
  }

}

 

実装手順①②③が完了していれば、上記コードをコピーして「main.dart」に貼り付ければ、そのまま挙動を確認できるかと思います。

 

なお、「firebase_core」、「firebase_auth」をインポートしてFirebaseにユーザー情報を登録するケースについては、コメントアウトした状態でコードを記載しており、コメントアウトを外すことで、このケースも試すことも可能です。

 

※一部、他の箇所をコメントアウトするなどの調整が必要になります。調整の方法はコメントに記載しています。

 

以降では、自分が苦労した部分など、いくつかの留意点を記載します。

 

Google APIで使用するスコープ(権限範囲)を指定する

該当箇所は、321行目です。

 

Googleカレンダーへのアクセスを要求するには、「GoogleSignIn」コンストラクタの「scopes」プロパティに、

 

  • 「CalendarApi.calendarScope」または
  • 「’https://www.googleapis.com/auth/calendar’」

 

を指定します。

 

これは機密性の高いスコープに該当するので、GoogleによるOAuth検証の事前承認が必要となります。

 

なお、GCPのスコープ設定画面では、機密性の高いスコープに該当しない「https://www.googleapis.com/auth/calendar.app.created」というスコープも選択できますが、残念ながら実際は使用不可になっています。

 

 

試しに、「scopes」に上記URLを指定して実行すると、

 

DetailedApiRequestError(status: 404, message: Not Found)

 

というエラーが出ます。

 

この点については、下記の記事

 

 

にも説明があるとおり、現在のGoogle APIの仕様(v3)では、使用可能リスト(下記ページ)に入っていないため、使用できません。

 

 

そのため、Googleカレンダーを使う場合、OAuth検証の事前承認は避けられないことになります。。

 

Google サインインの画面をキャンセルされたときの処理を入れる

該当箇所は、356〜359行目です。

 

Googleサインインのポップアップが出たとき、周りの暗い部分をタップするとキャンセルできますが、その際にエラーでアプリが停止してしまいます。

 

※これは同意画面でキャンセルした場合も同様です。

 

 

そのため、「try catch」文を入れ、キャンセルにより「signIn」メソッドの返り値がnullだった場合は、例外処理を投げて、以降の処理を回避するようにします。

 

但し、デバッグモードで実行すると、例外処理を入れてもアプリが止まってしまうので、キャンセル時の挙動を試したい場合は、リリースモード(flutter run –release)で実行する必要があります。

 

※Android Studioの場合は、右三角(▶)ボタン(Run ‘main.dart’)で実行しても大丈夫です。

 

iOSの挙動に備えて、signInSilentlyメソッドを併用する

該当箇所は、338行目です。

 

最初にどのボタンが押されても良いように、①〜⑤のボタン押下後、いずれも最初にGoogleサインインの処理メソッド(_googleSignInMethodO)を呼んでいます。

 

Androidの場合は、サインイン済なら「signIn」メソッドの所で何もせず通過してくれますが、iOSの場合には、毎回サインインのポップアップが起動してしまい、煩わしい挙動になります。

 

そのため、サインイン済か否かを「isSignedIn」メソッドで確認し、サインイン済の場合には、「signInSilently」メソッドに切り替え、ポップアップの起動を省略しています。

 

サインイン済でもエラーとなる場合は、認証情報を初期化する

該当箇所は、343〜348行目です。

 

「isSignedIn」メソッドでサインイン済と判定されても、「signInSilently」メソッドの返り値がnullになってしまうことがあります。

 

その状態で、「disconnect」メソッドではなく「signOut」メソッド(説明は後述)を使ってサインアウトした場合、再度サインインしようとすると、以下のようなエラーが発生します。

 

E/flutter (14109): [ERROR:flutter/lib/ui/ui_dart_state.cc(199)] Unhandled Exception: Access was denied (www-authenticate header was: Bearer realm=”https://accounts.google.com/”, error=invalid_token).

 

自分の経験では、この現象は、ユーザーが、Googleアカウントの設定画面から、アプリのアクセス権を手動で解除した場合に発生します(具体的な解除方法は、後述の「その他補足」をご参照ください)。

 

これは、アプリのGoogleアカウントに対する認証情報が更新されないまま、アクセス権が解除されてしまった事が原因と理解しています(自分の経験では、トークンの有効期間とされている1時間を経過すると、再びアクセスできるようになります)。

 

そのため、サインイン済と判定されたにもかかわらず、サインインの返り値がnullとなる場合は、「disconnect」メソッドを用いて完全サインアウトさせ、アプリの認証情報を初期化する処理を入れています。

 

その後で、再度サインインすれば、認証情報が再構築され、問題なく処理が継続できるようになります。

 

同意画面にチェックなしで「続行」を押されたときのエラー回避処理

実は同意画面には1つ厄介なトラップがあります。

 

下記の画面が出たときに、同意のチェックを入れずに「続行」で進めてしまうと、エラーが発生します。

 

 

このチェックは、急いでいるユーザーが見落とす可能性は十分にありそうです。。

 

エラーの挙動は、AndroidとiOSで異なるので、それぞれについて対処法を記載します。

 

これの解決策を見出すのに、結構苦労しました。。

 

■Androidの場合:

 

同意画面でチェックを入れずに「続行」すると、親切にもう一度、同意画面を出してくれます

 

チェック不要の同意画面が再表示されるが、「キャンセル」するとエラーになる。

 

この2回目の画面にはチェックボックスが無いので、チェック忘れは発生しませんが、ここで「キャンセル」をすると、エラーが発生してしまいます

 

非同期処理のためか、時によって挙動が異なり、

 

356行目の

 

if (accountO == null) {

 

の「null」判定に引っかかってくれるケースと、

 

サインインのメソッドを通過して、ステップ①〜④にある

 

httpClientO = (await googleSignInO.authenticatedClient())!;

 

の部分(HTTPクライアントのインスタンスを作る部分)で、下記エラーが発生するケースがあります。

 

 

Unhandled Exception: PlatformException(failed_to_recover_auth, Failed attempt to recover authentication, null, null)

 

そのため、後者のケースに備え、「try catch」を設定し、エラーでもアプリが停止しないようにします。

 

さらに、「disconnect」メソッドで、中途半端なサインイン状態を完全解除するようにします(同意を得られず、サインインだけできている状態になっているため)。

 

※該当箇所の例は、421〜427行目です。

 

■iOSの場合:

 

Androidとは異なり、同意画面でチェックを入れずに「続行」すると、再度、同意画面を出すことはなく、エラーになります。

 

この場合も、時によって挙動が異なり、356行目の

 

if (accountO == null) {

 

で「null」判定に引っかかってくれるケースと、そうでないケースがあります。

 

後者は、Androidとは異なり、HTTPクライアントのインスタンス作成は問題なく進みますが、Google Calendar APIの何らかのメソッド(「calendarList.list」、「events.insert」、「events.get」、「events.update」、「events.delete」など)を実行した時に、下記エラーが発生します。

 

Unhandled Exception: Access was denied (www-authenticate header was: Bearer realm=”https://accounts.google.com/”, error=insufficient_scope, scope=”https://www.googleapis.com/auth/drive”).

 

本サンプルコードでは、ステップ①〜④のいずれにおいても、サインイン処理後、「calendarList.list」メソッドを実行しているため、この部分に「try catch」を設定することで、エラーによるアプリ停止を回避します。

 

また、Androidのケースと同様、エラー時には「disconnect」メソッドで、中途半端な認証状態を完全解除します(該当箇所の例は、451〜457行目です)。

 

ステップ① HTTPクライアントのインスタンスを作成

該当箇所は、418行目です。

 

Google Calendar APIのインスタンスを作成するには、HTTPクライアントのインスタンスが必要ですが、本記事では、Flutter公式ページの推奨に従って、「extension_google_sign_in_as_googleapis_auth」パッケージを使う方法を採用しています。

 

HTTPクライアントのインスタンスを自作する方法もありますが、パッケージを使うと、記述するコード量が少なく楽なので、本記事では割愛します(Google Drive APIの利用方法を整理したこちらの記事に記載しました)。

 

ステップ① Eventクラスのインスタンスに各種予定情報を格納

該当箇所は、460〜529行目です。

 

Googleカレンダーに登録するデータは、「Event」クラス、「EventDateTime」クラス、「EventReminder」クラスの各インスタンス(本サンプルコードでは「eventO」、「startO」または「endO」、「reminderFirstO」)のプロパティに、下記のとおりデータを代入することで作成します。

 

※本サンプルコードで使用するプロパティのみ記載

  • Eventクラスのインスタンスのプロパティ
    • summary ← 予定のタイトル
    • start ← 予定の開始時刻
    • end ← 予定の終了時刻
    • reminders ← リマインダー時期
    • id ← 予定ID
    • description ← 予定のメモ
  • EventDateTimeクラスのインスタンスのプロパティ
    • dateTime ← 開始時刻 または 終了時刻
    • timeZone ← 開始時刻 または 終了時刻のタイムゾーン(日本なら「GMT+09:00」
  • EventReminderクラスのインスタンスのプロパティ
    • method ← リマインダーの方法(通知「popup」かメール「email」
    • minutes ← 開始時刻の何分前か

 

「description」プロパティにメモ内容を代入する際、ステップ②③④で使用する予定IDを確認できるよう、メモ冒頭に予定IDを自動挿入するようにしました。

 

また、留意点が下記2点ほどあります。

 

  • リマインダー時期の設定には限度がある
    • リマインダー時期は、予定開始時刻前の時間を分換算して設定する必要がありますが、最大で40,320分前(=4週間前)が限度となっています。
    • 本サンプルコードでは、簡略化のため、1,440分前(=1日前)で固定にしていますが、可変にした場合にも対応するため、0〜40,320分の範囲になるようチェックを入れています。

 

  • 予定IDにはハイフン「-」を使用できない
    • Google Calendar APIのソースコードを見ると、「id」プロパティに代入できる文字列は、「小文字のa〜vと数字の0〜9」と書かれています。
    • そのため、予定ID作成時に、uuidパッケージを用いて32桁のランダム英数字を作成した後、ハイフンを除く処理をしています。
    • ハイフンを入れたままでGoogleカレンダーに登録しようとすると、「DetailedApiRequestError(status: 400, message: Invalid resource id value.)」のエラーが出ます。

 

ステップ① events.insertメソッドで、カレンダーIDを指定して登録

該当箇所は、537行目です。

 

「events.insert」メソッドの引数に、各種予定情報を代入した「Event」クラスのインスタンス(本サンプルコードでは「eventO」)と、どのカレンダーに登録するかを示すカレンダーID(本サンプルコードでは「calendarIdO」に代入した文字列)を設定することで、Googleカレンダーに登録できます。

 

カレンダーIDは、あくまでカレンダー全体に振られるIDであり、個々の予定に振られるIDとは別物になります(当初、この点を理解するのに苦労しました。。)。

 

Googleアカウントで使用しているメインのカレンダー(当該Googleアカウントで最初に作成されたカレンダー)に登録する場合は、カレンダーIDを「primary」にします。

 

Googleアカウントのメールアドレス「…@gmail.com」を指定しても同じ結果になります。

 

本サンプルコードでは、全て「primary」カレンダーに登録する事を前提としています。

 

■メイン以外のカレンダーに登録したい場合:

 

Googleカレンダーには、下記のようにメイン以外のカレンダーも追加作成することができます。

 

 

メイン以外のカレンダーに登録したい場合、「primary」は使用できないので、事前に「calendarList.list」メソッドを用いて、Googleアカウント上にあるカレンダーIDのリストを取得し、該当するIDを「events.insert」メソッドの引数に設定します。

 

本サンプルコードでは、参考として、①②③④のボタンを押すと、カレンダーIDのリストを取得し、コンソール(デバッグウィンドウ)に表示するコードを入れています。

 

下図の例で表示されている3つ目の情報が、追加作成したカレンダーのIDです。

 

 

カレンダーIDは、下記手順により、Googleカレンダー上でも手動で確認できます。

 

 

このカレンダーIDを、「events.insert」メソッドの引数に入れれば、メインのカレンダーではなく、追加作成したカレンダーの方に予定を登録できます(本サンプルコードではこの部分の例は記載していません)。

 

ステップ② events.getメソッドで、登録した予定情報を取得

該当箇所は、609行目です。

 

「events.get」メソッドの引数に、カレンダーIDと、予定ID個々の予定に振られるID。本サンプルコードでは「scheduleIdO」に代入する文字列)を設定することで、Googleカレンダーから該当する予定情報を取得できます。

 

「try catch」を仕掛けることで、Googleカレンダー上に、該当する予定IDの予定情報が無い場合は、エラー検出して処理しています。

 

ステップ③ events.updateメソッドで、登録した予定情報を更新

該当箇所は、737〜739行目です。

 

「events.update」メソッドの引数に、ステップ①と同じ方法で再作成した「Event」クラスのインスタンス、カレンダーID、予定IDの3つを設定することで、Googleカレンダーに登録した予定情報を更新できます。

 

ステップ④ events.deleteメソッドで、登録した予定情報を削除(完全には消えない)

該当箇所は、824行目です。

 

「events.delete」メソッドの引数に、カレンダーIDと、予定IDを設定することで、Googleカレンダーに登録した予定情報を削除できます。

 

ただし、完全には削除されず、ステップ②の「events.get」メソッドで、引き続き情報を取得できます

 

当初、理由がわからず悩んだのですが、結論は、

 

ゴミ箱に行っていただけ

 

でした。

 

下記手順で、ゴミ箱から削除すると完全にデータが消え、「events.get」メソッドで情報取得できなくなります。

 

 

Google Calendar APIのソースコードを調べた限りでは、ゴミ箱から完全削除するメソッドは無く、Googleカレンダー上で手動で消す必要があります。

 

なお、下記記事では、テンポラリで作成したカレンダーにデータを移し、そのカレンダーごと削除する、という荒技が紹介されていました。

 

 

ただ、30日待てば自動削除されるため、そこまでの対応が必要か否かは、完全削除の緊急性に応じて、検討するのが良いかと思います。

 

ステップ⑤ 「disconnect」メソッドでサインアウト処理

該当箇所は、844行目です。

 

サインアウトの処理には、「signOut」メソッドと「disconnect」メソッドの2通りがあります。

 

「signOut」メソッドは、あくまで「サインアウト状態」にするだけで、認証状態の破棄も含めた完全なサインアウトではありません。

 

そのため、再サインイン時には、最初に要求された同意画面は表示されず、簡単にサインインできます。

 

一方、「disconnect」メソッドは、前述のとおり、認証状態の破棄も含めた完全なサインアウトになるため、再サインイン時には、改めて同意画面が表示されます。

 

サービス提供側としては、ユーザーに同意画面を見せるのは最初の1回だけにしたいはずなので、「signOut」メソッドを採用したいところです。

 

しかし、前述のとおり、ユーザーが、Googleアカウントの画面で手動でアクセス権を解除したり、サインイン時に同意画面にチェックを入れず続行した場合などに、認証状態が不十分な状態になるため「signOut」メソッドだとその状態から抜け出すことができません

 

そのため、認証状態を初期化し、完全にサインアウトできる手段を確保する、という観点から、本サンプルコードでは、「disconnect」メソッドを採用しました。

 

※サインアウトボタンには「signOut」メソッドを使用し、認証状態エラーのハンドリング時だけ「disconnect」メソッドを使用する、という方法もあり得ます。ただ、自分の試した限りでは、同意画面の挙動が時によって異なり、まれに「try catch」の網を通り抜けるケースもあるので、ユーザー側で完全サインアウトできる手段はあった方が良い、と考えています。

 

リリース前に、GoogleにOAuth検証の承認を得る(必須)

実装が完了したら、GoogleへのOAuth検証の承認申請をします。

 

警告画面が表示される状態は、サービスとして心証が悪いので、Googleから「このアプリは問題ないよ」というお墨付きをもらうべく、申請を出します。

 

主に必要な対応は、

 

  • プライバシーポリシーの作成
  • YouTubeのデモ動画の作成(主に同意画面の画面遷移部分)
  • GCPの管理画面から申請処理

 

になります。

 

詳細な手順は、下記記事に整理していますので、こちらをご参照ください。

 

 

上記記事の「OAuth検証の審査申請手順」の中の

 

  • 「同意画面の表示プロセスを撮影したYouTube動画(デモビデオ)の作成」
  • 「OAuth検証審査への申請提出」

 

を実施してください。

 

承認されるまで、色々と指摘を受ける可能性がありますが、、、乗り越えると、無事、警告画面なしでGoogleカレンダーへのアクセスができるようになります。

 

なお、自分自身が、OAuth検証時にGoogleから受けた指摘事項と対処法について、下記に整理していますので、よろしければこちらもご参考にして下さい。

 

 

その他補足:一度同意した同意画面を再度表示させる方法

サインアウトに「disconnect」メソッドではなく、「signOut」メソッドを用いた場合、一度、同意画面に同意すると、以降のサインイン時には同意画面が表示されません(サインアウトして再サインインしても表示されません)。

  

同意画面をもう一度表示させたい場合には、該当するGoogleアカウントのページで、下記手順により、アクセス権を削除すると可能になります。

 

この画面はGoogle Driveへのアクセスに同意していた場合の例

 

 


ご説明は以上になります。

 

FlutterからのGoogleカレンダーの利用を検討されている方には、ご参考になれば幸いです。

 

最後までお読みいただき、ありがとうございました。

 

\ Flutterの学習で役立ったコンテンツ・書籍 /

 

 

 


Dart入門 – Dartの要点をつかむためのクイックツアー

コメント

タイトルとURLをコピーしました