94
88

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

FirebaseとFlutterでアプリ開発してみる【其ノ二:Flutter&Dart篇】

Last updated at Posted at 2018-12-24

株式会社diffeasyでエンジニア修行僧のナナリー(@Nunnally_Engr)です。
この記事は、diffeasy Advent Calendar 2018 25日目の記事です。

■ 今回お話すること

  • Flutterについて
  • Flutter環境構築について
  • FlutterとFirebaseとの連携周りについて(主にアプリ登録、認証:Authentication、データベース:CloudFirestore)

■ 前提

■ アプリとしての流れ

  • サインインの画面からメールアドレス、パスワードを使ってFirebaseにサインインし、CloudFirestoreのユーザテーブル(users)にデバイストークン(deviceToken)を更新する。

  • サインアップの画面からメールアドレス、パスワードを使ってFirebaseに新規登録し、CloudFirestoreのユーザテーブル(users)にデバイストークン(deviceToken)を登録する。

  • 請求画面からCloudFunctionsを利用して、GCPのMySQLに請求データを登録する。

  • 画面的な流れと、操作的な流れの全体イメージ↓↓
    pay_wish_ワイヤーフレーム_flutter用.png

今回は上記赤枠の部分のお話をしてみたいと思います(๑•̀ㅁ•́๑)✧

■ 今回の作成したプログラムの全貌

  • gitHub:pay_wish_app
    ※ ちょこちょこ修正入ります、あしからず…

■ 今回の環境

> 開発端末

MacOS Mojave 10.14.1
SCV36(android-arm64)

> Flutter | Dart

Flutter 1.0.0 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 5391447fae (6 days ago) • 2018-11-29 19:41:26 -0800
Engine • revision 7375a0f414
Tools • Dart 2.1.0 (build 2.1.0-dev.9.4 f9ebf21297)

google_sign_in: ^3.2.4
firebase_auth: ^0.6.6
firebase_messaging: ^2.1.0
cloud_firestore: ^0.8.2+3
cloud_functions: ^0.0.5

> Firebase

Firebase CLI: 6.1.2
Node.js 8
firebase-admin: ~6.0.0
firebase-functions: ^2.1.0
mysql: ^2.16.0

> GCP:Cloud SQL

MySQL5.7 第2世代

>  Android Studio

Android Studio 3.2.1
Build #AI-181.5540.7.32.5056338, built on October 9, 2018
JRE: 1.8.0_152-release-1136-b06 x86_64
JVM: OpenJDK 64-Bit Server VM by JetBrains s.r.o
macOS 10.14.2

■ 開発する前準備

1. Firebase プロジェクト作成

  • 作成手順は割愛します。当記事で使用しているプロジェクトは「pay-wish」としてお話を進めていきます。

2. Firebase Authenticationの設定

  • 以下の内容で設定しています。(メールアドレスとパスワード認証のみ設定)
    スクリーンショット 2018-12-22 14.50.08.png

3. Firebase Cloud Firestoreの初期設定

  • 以下のテーブルを定義しています。(詳細は割愛)
users
 ├ {$uid}
  ├ deviceToken

4. DartのSDKインストール

  • FlutterではDartという言語で動いているので、brewコマンドを使ってインストールしておきます。
$ brew tap dart-lang/dart
$ brew install dart --with-content-shell --with-dartium

5. FlutterのSDKインストール

  • 以下のサイトからSDKのZipダウンロードして、任意のディレクトリに配置してください。導入手順も以下のサイトに記載されています。
    Get the Flutter SDK for Mac

6. Android Studioの環境構築

  •  以下のサイトがわかりやすく記載されてますので、『4. Android環境を構築 > 4.1. Android Studioをインストール』の箇所を参考にインストール&設定してみてください。
    Flutter開発環境構築(Mac編)

  •  【参考】新規インストール直後の場合、以下のような「Complete Installationダイアログ」が表示されます。これは旧バージョンの設定引継ぎを行うかどうかを問い合わせているのですが、引き継ぐ環境などありませんので「Do not import settings」を選択してください。

7. Android仮想端末設定

  • AndroidStudioを起動したら、右上の方にアイコンがあるので選択します。
    3.png
  • 「Create Virtual Device」を選択します。
    4.png
  • 端末を選ぶ画面が表示されるので、好きな端末を選択し、「Next」ボタンを押してください。(私はGalaxyが好きなのでその端末を選択しました(笑))
    5.png
  • システムイメージで"Pie"を選択し、「Next」ボタンをおしてください。
    6.png
  • 特にこだわりがない人はそのまま「Finish」ボタンを押して設定を終えてください。
    7.png
  • 先程設定した端末が表示されます。
    8.png

8. VSCodeプラグインインストール(任意)

  • VsCodeで開発したので、私は以下のプラグインをインストールしました。
  • Dart
  • Flutter
  • Flutter Widget Snippets

■ Flutter環境構築

1. プロジェクトを作成する

  • VSCodeのコマンドパレットからプロジェクトの作成します。
    コマンドパレットからプロジェクト作成.png

  • 以下の画面が表示されて怒られた場合・・・((((;゚Д゚))))ガクガクブルブル
    VSCode怒られた時.png

  • 「Locate SDK」を押して、SDKが置いてある場所を参照させてください。Locate SDK設定画面.png

2. プロジェクト名設定

  • 当記事で使用しているプロジェクトは「pay-wish」なので、アプリとわかるように「pay_wish_app」という名前で設定します。

    Flutter環境構築_2_プロジェクト設定.png

3. プロジェクトディレクトリ選択

  • 今回はdevelop配下にプロジェクトディレクトリを作りたいので、以下の様にdevelopを選択し、「select 〜」ボタンを押してください。

    Flutter環境構築_3_プロジェクトディレクトリ選択.png

4. ライブラリ追加

pay_wish_app/pubspec.yaml
dependencies:
  google_sign_in: ^3.2.4
  firebase_auth: ^0.6.6
  firebase_messaging: ^2.1.0
  cloud_firestore: ^0.8.2+3
  cloud_functions: ^0.0.5
  intl: ^0.15.7
  • ライブラリを追加したら、flutter packages getが自動で実行されて.packagesが更新されてしまうので、更新後以下のパスを追加しましょう。
pay_wish_app/.packages
login:lib/
src:lib/src

5. 仮想端末を選択

  • VSCodeの右下に”No Device”と表示されているところを選択します。

    1.png
  • 先程AndroidStudioで設定した仮想端末を選択します。
    2.png
  • 選択されていることを確認します。

    3.png
  • 端末も表示されます。

    4.png

6. Flutter起動する

  • 一旦、設定ができているかどうか確認します。以下のコマンドを実行しましょう。
$ flutter doctor
  • 実行結果です。(今回はAndroidのみで開発するのでiOSでの設定はしていません。取り急ぎ、iOSのエラーは放置します。)
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, v1.0.0, on Mac OS X 10.14.2 18C54, locale ja-JP)
[✓] Android toolchain - develop for Android devices (Android SDK 28.0.3)
[✗] iOS toolchain - develop for iOS devices
    ✗ Xcode installation is incomplete; a full installation is necessary for iOS development.
      Download at: https://developer.apple.com/xcode/download/
      Or install Xcode via the App Store.
      Once installed, run:
        sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer
    ✗ libimobiledevice and ideviceinstaller are not installed. To install with Brew, run:
        brew update
        brew install --HEAD usbmuxd
        brew link usbmuxd
        brew install --HEAD libimobiledevice
        brew install ideviceinstaller
    ✗ ios-deploy not installed. To install with Brew:
        brew install ios-deploy
    ✗ CocoaPods not installed.
        CocoaPods is used to retrieve the iOS platform side's plugin code that responds to your plugin usage on the Dart side.
        Without resolving iOS dependencies with CocoaPods, plugins will not work on iOS.
        For more info, see https://flutter.io/platform-plugins
      To install:
        brew install cocoapods
        pod setup
[✓] Android Studio (version 3.2)
[✓] VS Code (version 1.30.1)
[✓] Connected device (1 available)

! Doctor found issues in 1 category.
  • 以下のコマンドでサンプルアプリが起動するかを確認しましょう。カウントアップアプリが表示されたらFlutterの設定は完了です。
$ flutter run

6. ディレクトリ構造の確認

  • 今回説明するソースの構造は以下の通りとなっています。
  • (*)のソースは、今回私が作成したファイルと、Firebaseの設定ファイルになります。(ポイントの部分だけ後ほど説明します)
pay_wish_app/
  ├ android/
    ├ build.gradle
    ├ app/
      ├ build.gradle
      ├ google-services.json(*)
      ├ src/
        ├ main/
          ├ AndroidManifest.xml
  ├ lib/
    ├ src/(*)
      ├ auth.dart
      ├ table/
        ├ users.dart
    ├ claim.dart(*)
    ├ dashboard.dart(*)
    ├ main.dart
    ├ primary_button.dart(*)
    ├ sign_in.dart(*)
    ├ sign_up.dart(*)
  ├ .packages
  ├ pubspec.yaml

■ Firebaseとの連携

1. Firebaseのプロジェクト設定

  • Firebaseのプロジェクト設定画面を開き、Androidのアイコンを選択する。
    1.png

  • Android パッケージ名に、pay_wish_app/android/app/build.gradleに設定されている、applicationIdを設定しましょう。

pay_wish_app/android/app/build.gradle
android {
    defaultConfig {
        // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
        applicationId "com.example.paywishapp"
    }
}

2.png

  • applicationIdを設定したら以下の画面が表示されるので、表示内容にしたがってダウンロードしたファイルを配置してください。

    3.png

  • Firebase SDKの設定をFlutterのプロジェクトに設定します。
    4.png

pay_wish_app/android/build.gradle
buildscript {
    dependencies {
        classpath 'com.google.gms:google-services:4.0.1'
    }
}
pay_wish_app/android/app/build.gradle
dependencies {
    implementation 'com.google.firebase:firebase-core:16.0.1'
}

apply plugin: 'com.google.gms.google-services'

2. Flutterを実行して連携する

  • 1.でプロジェクト設定が完了したら、以下の画面が表示されるので画面を表示したまま、Flutterアプリを実行します。
    5.png
$ flutter run
  • flutterが正常に実行されたら、連携成功です。(プロジェクト設定画面が以下のようになっていればOKです)
    6.png

■ 実装ファイル説明: 認証(Authentication)

  • 今回Firebase Authenticationの処理を、abstract classに設定しました。
pay_wish_app/lib/src/auth.dart
import 'dart:async';
// Firebase ↓ (01) 必要なpackageをimport
import 'package:firebase_auth/firebase_auth.dart';
import 'package:src/table/users.dart';

abstract class BaseAuth {

  Future<String> signIn(String email, String password);
  Future<String> createUser(String email, String password, String displayName, String photoUrl);
  Future<String> currentUser();
  Future<void> signOut();
}

class Auth implements BaseAuth {
  final FirebaseAuth _firebaseAuth = FirebaseAuth.instance;
  final BaseUsers _users = new Users();

  Future<String> signIn(String email, String password) async {

    // Firebase Authentication サインイン ↓ (02) サインインの処理
    FirebaseUser user = await _firebaseAuth.signInWithEmailAndPassword(email: email, password: password);

    // Usersテーブル更新
    await _users.update(user.uid);

    return user.uid;
  }

  Future<String> createUser(String email, String password, String displayName, String photoUrl) async {

    // Firebase Authentication 登録 & サインイン ↓ (03) 登録 & サインイン
    FirebaseUser user = await _firebaseAuth.createUserWithEmailAndPassword(email: email, password: password);

    // Firebase UserInfo 更新 ↓ (04) AuthenticationのuserInfo更新処理
    UserUpdateInfo info = new UserUpdateInfo();
    info.displayName = displayName; // 表示名前
    info.photoUrl = photoUrl;       // 画像URL
    user.updateProfile(info);

    // Usersテーブル作成
    await _users.create(user.uid);

    return user.uid;
  }

  Future<String> currentUser() async {
    FirebaseUser user = await _firebaseAuth.currentUser();
    return user != null ? user.uid : null;
  }

  Future<void> signOut() async {
    return _firebaseAuth.signOut();
  }

}

(01) 必要なpackageをimportします。

(02) Future<String> signIn(String email, String password)でメールアドレスとパスワードを使ってサインインの処理をさせます。firebase.authのsignInWithEmailAndPasswordメソッドに、メールアドレスとパスワードを渡すだけです。

(03) Future<String> createUser(String email, String password, String displayName, String photoUrl)でメールアドレスとパスワードを使ってユーザの登録&サインインをさせます。firebase.authのcreateUserWithEmailAndPasswordメソッドに、メールアドレスとパスワードを渡すだけです。

(04)Authenticationが用意している、userInfoの表示名と画像のURLも更新できるように設定しています。

■ 実装ファイル説明: Dartの状態管理について

  • 今回、画面としては①サインイン画面、②サインアップ画面、③ダッシュボード、④請求画面と4画面用意してます。表示する画面や認証状態をDartの状態管理方法であるStateを使って制御します。状態管理や画面の遷移に関しては、scoped model・redux・BLoCといくつかあるそうなのですが、今回はmain.dartでRootPageをStatefulWidgetとして利用し、main.dartで状態を管理する方法でやってみます。

  • では実際に画面制御や状態を制御しているmain.dartの説明をします。

pay_wish_app/lib/main.dart
// flutter
import 'package:flutter/material.dart';
// Firebase
import 'package:firebase_messaging/firebase_messaging.dart';
// Page
import 'package:src/auth.dart';
import 'package:login/sign_in.dart';
import 'package:login/sign_up.dart';
import 'package:login/dashboard.dart';
import 'package:login/claim.dart';

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

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'Flutter Firebase Login',
      theme: new ThemeData(
        primarySwatch: Colors.orange,
      ),
      home: new RootPage(auth: new Auth())
    );
  }
}

class RootPage extends StatefulWidget {
  RootPage({Key key, this.auth}) : super(key: key);
  final BaseAuth auth;

  @override
  State<StatefulWidget> createState() => new _RootPageState();
}

// 状態定義 ↓ (01) 認証状態と画面情報定義
enum AuthStatus {
  notSignedIn,
  signedIn,
  signedUp
}

// カレントページ ↓ (01) 認証状態と画面情報定義
enum CurrentPage {
  dashboard,
  claim,
  other
}

class _RootPageState extends State<RootPage> {

  AuthStatus authStatus = AuthStatus.notSignedIn;
  CurrentPage currentPage = CurrentPage.other;

  initState() {
    final FirebaseMessaging _firebaseMessaging = new FirebaseMessaging();
    super.initState();

    // Firebase 認証 ↓ (02) カレントユーザ情報取得
    widget.auth.currentUser().then((userId) {
      setState(() {
        authStatus = userId != null ? AuthStatus.signedIn : AuthStatus.notSignedIn;
      });
    });

    // Firebase FCM
    _firebaseMessaging.configure(
      onMessage: (Map<String, dynamic> message) async {
        print("onMessage: $message");
        _buildDialog(context, "onMessage:請求が届いたようだ(๑•̀ㅁ•́๑)✧");
      },
      onLaunch: (Map<String, dynamic> message) async {
        print("onLaunch: $message");
        _buildDialog(context, "onLaunch:請求が届いたようだ(๑•̀ㅁ•́๑)✧");
      },
      onResume: (Map<String, dynamic> message) async {
        print("onResume: $message");
        _buildDialog(context, "onResume:請求が届いたようだ(๑•̀ㅁ•́๑)✧");
      },
    );
    // Push通知の許可
    _firebaseMessaging.requestNotificationPermissions(
        const IosNotificationSettings(sound: true, badge: true, alert: true));
    // Push通知の許可・設定(iOS)
    _firebaseMessaging.onIosSettingsRegistered
        .listen((IosNotificationSettings settings) {
      print("Settings registered: $settings");
    });
  }

  // ダイアログを表示するメソッド
  void _buildDialog(BuildContext context, String message) {
    showDialog(
      context: context,
      barrierDismissible: false,
      builder: (BuildContext context) {
        return new AlertDialog(
          content: new Text("Message: $message"),
          actions: <Widget>[
            new FlatButton(
              child: const Text('CLOSE'),
              onPressed: () {
                Navigator.pop(context, false);
              },
            ),
            new FlatButton(
              child: const Text('SHOW'),
              onPressed: () {
                Navigator.pop(context, true);
              },
            ),
          ],
        );
      }
    );
  }

  // 認証状態更新 ↓ (03)Stateを更新する処理
  void _updateAuthStatus(AuthStatus status) {
    setState(() {
      authStatus = status;
    });
  }

  // カレントページを更新 ↓ (03)Stateを更新する処理
  void _updateCurrentPage(CurrentPage page) {
    setState(() {
      currentPage = page;
    });
  }

  @override
  Widget build(BuildContext context) {
    // 認証状態に応じて表示する画面を分ける ↓ (04)画面制御処理
    switch (authStatus) {
      case AuthStatus.notSignedIn:
        print('■ サインイン');
        // サインインページ
        return new SignIn(
          title: 'Flutter Firebase SignIn',
          auth: widget.auth,
          onSignIn: () => _updateAuthStatus(AuthStatus.signedIn),
          onSignUp: () => _updateAuthStatus(AuthStatus.signedUp),
        );
      case AuthStatus.signedIn:
        switch (currentPage) {
          case CurrentPage.claim:
            print('■ 請求画面');
            // 請求画面
            return new Claim(
              auth: widget.auth,
              onSignOut: () => _updateAuthStatus(AuthStatus.notSignedIn),
              currentPageDashboardSet: () => _updateCurrentPage(CurrentPage.dashboard)
            );
          default:
            print('■ ダッシュボード');
            // ダッシュボードページ
            return new Dashboard(
              auth: widget.auth,
              onSignOut: () => _updateAuthStatus(AuthStatus.notSignedIn),
              currentPageClaimSet: () => _updateCurrentPage(CurrentPage.claim)
            );
        }
        break;
      case AuthStatus.signedUp:
        print('■ サインアップ');
        // 新規登録ページ
        return new SignUp(
          title: 'Flutter Firebase SignUp',
          auth: widget.auth,
          onSignOut: () => _updateAuthStatus(AuthStatus.notSignedIn),
          onSignIn: () => _updateAuthStatus(AuthStatus.signedIn)
        );
    }
  }
}

(01)認証状態と、表示している画面の情報を定義しています。

(02)initStateで、Firebaseからカレントユーザの情報を取得し、Stateに保持させます。

(03)Stateを更新する処理を記述します。この処理を、画面遷移時にVoidCallbackで渡して遷移先でこのStateを更新することによって、画面を切り替えてます。

(04)画面制御を行っている部分です。まず①認証していない・②認証済み・③新規登録というAuthStatus Stateで条件分岐し、②認証済みの場合は、①ダッシュボード・②請求画面のCurrentPage Stateで条件分岐をし画面を切り替えてます。

  • 呼び出された側の処理今回はダッシュボード側の処理dashboard.dartを見ていきます。
pay_wish_app/lib/dashboard.dart
import 'package:flutter/material.dart';
import 'package:src/auth.dart';
import 'package:cloud_functions/cloud_functions.dart';

class Dashboard extends StatelessWidget {
  Dashboard({this.auth, this.onSignOut, this.currentPageClaimSet});
  final BaseAuth auth;
  final VoidCallback onSignOut; // ← (01) main.dartで渡した処理をVoidCallbackで受け取る
  final VoidCallback currentPageClaimSet;

  @override
  Widget build(BuildContext context) {

    void _signOut() async {
      try {
        await auth.signOut();
        onSignOut(); // ← (02) state更新処理を走らせ、画面遷移させる
      } catch (e) {
        print(e);
      }

    }

    // 登録ボタン
    void onPressdClaimCreate() async {
      // ページセット
      currentPageClaimSet();
    }

    // 取得ボタン
    void onPressdClaimSelect() async {
      print('>>> Click:onCallClaimsSelect');
      final dynamic resp = await CloudFunctions.instance.call(
                              functionName: 'onCallClaimsSelect'
                            );
      print(resp);
    }

    return new Scaffold(
      appBar: new AppBar(
        title: new Text('ダッシュボード(*´ω`*)'),
        actions: <Widget>[
          new FlatButton(
              onPressed: _signOut, // ← (03)画面のヘッダーにある「サインアウト」を押した時の処理
              child: new Text('サインアウト', style: new TextStyle(fontSize: 17.0, color: Colors.white))
          )
        ],
      ),
      body: Column(children: <Widget>[
        Padding(
          padding: EdgeInsets.all(20.0),
          child: RaisedButton(
            child: const Text('取得'),
            color: Theme.of(context).accentColor,
            elevation: 4.0,
            splashColor: Colors.blueGrey,
            shape: RoundedRectangleBorder(
              borderRadius: BorderRadius.all(Radius.circular(10.0)),
            ),
            onPressed: onPressdClaimSelect,
          ),
        ),
       ],
      ),
      floatingActionButton: FloatingActionButton.extended(
        onPressed: onPressdClaimCreate,
        label: Text("請求する"),
        icon: Icon(Icons.add),
      ),
      floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
    );
  }
}

(01)main.dartで渡したState更新処理をVoidCallbackにて受け取ります。

(02)サインアウト時に、VoidCallbackとして定義したState更新処理を実行することで、呼び出し元画面のStateが更新され、今の画面を破棄しサインインの画面を表示しています。

(03)画面のヘッダーにある「サインアウト」を押した時の処理で、onPressedで_signOut()が動くように指定します。

■ 実装ファイル説明: データ操作(CloudFirestore)

  • CloudFirestoreを使った登録、更新処理はusers.dartに記述しています。
pay_wish_app/lib/src/table/users.dart
import 'dart:async';
import 'package:intl/intl.dart';
// Firebase
import 'package:cloud_firestore/cloud_firestore.dart';       // ← (01) cloud_firestoreのpackageをインポート
import 'package:firebase_messaging/firebase_messaging.dart'; // ← (02) firebase_messagingのpackageをインポート

abstract class BaseUsers {

  Future<void> create(String uid);
  Future<void> update(String uid);
}

class Users implements BaseUsers {

  final FirebaseMessaging _firebaseMessaging = new FirebaseMessaging();

  Future<void> create(String uid) async {
    String _token = await _firebaseMessaging.getToken(); // ← (03) FCMトークン取得
    var db = Firestore.instance;
    // ↓ (04) CloudFirestore.usersに登録
    await db.collection("users").document(uid).setData({
      "deviceToken": _token,
      "createdAt": DateFormat("y/m/d H:mm", "en_US").format(new DateTime.now()),
      "updatedAt": DateFormat("y/m/d H:mm", "en_US").format(new DateTime.now()),
      "deletedAt": ''
    });
  }

  Future<void> update(String uid) async {
    String _token = await _firebaseMessaging.getToken(); // ← (03) FCMトークン取得
    var db = Firestore.instance;
    // ↓ (05) CloudFirestore.usersに更新
    await db.collection("users").document(uid).updateData({
      "deviceToken": _token,
      "updatedAt": DateFormat("y/m/d H:mm", "en_US").format(new DateTime.now())
    });
  }

}

(01)cloud_firestoreのpackageをインポートします。

(02)今回、端末の情報としてデバイストークンを取得したいので、firebase_messagingのpackageもインポートしてます。

(03)FirebaseMessagingクラスのgetToken()を利用して、FCMのトークン情報を取得します。

(04)Firestoreクラスをインスタンス化し、setDataを利用して、データをCloudFirestore.usersテーブルに登録していきます。

(05)CloudFirestore.usersテーブルを更新する時は(04)と同じ様にデータをセットして、updateDataを利用して更新します。

■ アプリを動かしてみる

  • サインイン ⇒ サインアップ ⇒ ダッシュボード
  • サインイン ⇒ ダッシュボード

■ あとがき

  • 画面遷移周りは正直苦労しました・・・。まだ理解していない部分もありますが・・・。
  • 画面遷移と状態管理って結構ペアなイメージがあるので、簡単なアプリで知識を深めようと思います。
  • ただ、Firebaesとの連携はすぐにできたのでアプリ開発を初めてするにはいいかもしれません。

■ 参考リンク

94
88
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
94
88

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?