中安拓也のブログ

プログラミングについて書くブログ

【Ionic(Cordova)】GradleによるAndroidビルド時のリポジトリの参照先をNexusに変更する

はじめに

Ionic(Cordova/Angular)アプリのAndroidビルドに時間がかかりすぎているせいで、Jenkinsのビルドが不安定になるという問題が発生しました。

そのため、mavenリポジトリの参照先をMaven Central リポジトリから、Nexusリポジトリに変更する対応を実施します。なお、AndroidビルドはGradleで実施していて、Nexusのリポジトリは社内向けのため認証があります。

環境

  • "cordova-android": "8.1.0",
  • "@ionic/angular": "5.1.0",
  • "@ionic/core": "5.1.0",

Cordova フックスクリプトを作成する

リポジトリの参照先をNexusに変更するには、platforms/android配下のbuild.gradleを修正する必要があります。ただし、build.gradleはビルド時に生成されるファイルであるため、手動で直接修正することはできません。

そのため、今回はCordovaフックスクリプトを作成することで、build.gradleファイルを修正します。

下記の通り、config.xml<platform name="android">配下に行を追記することで、Androidのビルド前にスクリプト(build_scripts/android-before-build.js)を呼び出すことができます。

config.xml

<platform name="android">
    <hook src="build_scripts/android-before-build.js" type="before_build" />
</platform>

続いてフックされるスクリプトを作成します。

build_scripts/android-before-build.js

/*
 * 最初に依存先を探すリポジトリをNexusに設定する。
 */
const fs = require('fs');
const path = require('path');
const async = require('async');

module.exports = context => {
  'use strict';
  const repoUrl =
    '[NexusリポジトリのURL]';
  const env = process.env;
  const gradleRepo = `maven {
     url "${repoUrl}"
     credentials {
       username "${env.NEXUS_USER}"
       password "${env.NEXUS_AUTH}"
     }
    }`;
  if (env.NEXUS_USER == null || env.NEXUS_AUTH == null) {
    return;
  }
  return new Promise((resolve, reject) => {
    const platformRoot = path.join(
      context.opts.projectRoot,
      'platforms/android'
    );

    const gradleFiles = findGradleFiles(platformRoot);

    // 最初に依存先を探すリポジトリをNexusに設定する。
    async.each(
      gradleFiles,
      function(file, callback) {
        let fileContents = fs.readFileSync(file, 'utf8');

        const insertLocations = [];
        const myRegexp = /\brepositories\s*{(.*)$/gm;
        let match = myRegexp.exec(fileContents);
        while (match != null) {
          if (match[1].indexOf(repoUrl) < 0) {
            insertLocations.push(match.index + match[0].length);
          }
          match = myRegexp.exec(fileContents);
        }

        if (insertLocations.length > 0) {
          insertLocations.reverse();
          insertLocations.forEach(location => {
            fileContents =
              fileContents.substr(0, location) +
              gradleRepo +
              fileContents.substr(location);
          });

          fs.writeFileSync(file, fileContents, 'utf8');
        }

        callback();
      },
      function(err) {
        if (err) {
          reject();
        } else {
          resolve();
        }
      }
    );
  });

  /**
   * gradleファイルのパスの一覧を返す
   *
   * @param {*} dir Androidプロジェクトのパス
   * @return {*} gradleファイルのパスの一覧
   */
  function findGradleFiles(dir) {
    let results = [];
    const list = fs.readdirSync(dir);
    list.forEach(fileName => {
      const filePath = path.join(dir, fileName);
      const stat = fs.statSync(filePath);
      if (stat && stat.isDirectory()) {
        results = results.concat(findGradleFiles(filePath));
      } else if (path.extname(filePath) === '.gradle') {
        results.push(filePath);
      }
    });
    return results;
  }
};

上記のスクリプトを実行するとbuild.gradleがこのようになります。

スクリプト実行前のbuild.gradle

f:id:l08084:20201210175410p:plain
スクリプト実行前のbuild.gradle

スクリプト実行後のbuild.gradle

f:id:l08084:20201210175727p:plain
スクリプト実行後のbuild.gradle

スクリプト実行前には、mavenCentralがリポジトリ探索の優先順位の一番だったのに、スクリプト実行後には、Nexusのリポジトリが探索の優先順位の一番になっていることがわかります。

スクリプトの処理内容について説明します。

上記のスクリプトは、repositoriesという文字列の後に下記の文言を追加するというものです。

    maven {
        url [NexusリポジトリURL]
        credentials {
          username [Nexusリポジトリ アカウントID]
          password [Nexusリポジトリ パスワード]
        }
    }

こうすることで、認証付きのNexusリポジトリにアクセスできるようになります。

参考記事

フック ガイド - Apache Cordova

android - Specify different repositories when using Cordova gradle wrapper - Stack Overflow

第8章 依存関係管理の基本

[CB-9704] Apache Cordova 5 does not support using a custom nexus repository for android builds - ASF JIRA

gradle + bitbucket + 社内向け (認証あり) maven リポジトリの設定手順 - Qiita

Where to put Gradle configuration (i.e. credentials) that should not be committed? - Stack Overflow

正規表現(RegExp) - とほほのWWW入門

【Angular】サロゲートペア(絵文字など)でもカウントできるValidator/Directiveの作成

はじめに

絵文字を使ってもバグらずに動くValidatorとDirectiveを作成します。

サロゲートペアについて

サロゲートペアとは、2つの文字コードを使って表現される文字を指し、通常の方法では正しく文字数をカウントできません。サロゲートペアには絵文字や一部の漢字が含まれます。次の例を見てください。

// 例1. 普通の文字
'A'.length; // 1
// 例2. 絵文字
'😀'.length; // 2
// 例3. 特殊な絵文字
'🏴󠁧󠁢󠁷󠁬󠁳󠁿'.length; // 14

例2の絵文字はサロゲートペアのため、2文字としてカウントされているのが分かります。また、例3のウェールズ国旗のような特殊な絵文字は、複数の絵文字で構成されているため、1文字で14文字としてカウントされています。

環境

TypeScriptベースのフレームワークであるAngularを使用します。

  • Angular: 8.2.14
  • stringz: 2.1.0

$ ng versionの実行結果

Angular CLI: 8.3.29
Node: 12.13.1
OS: darwin x64
Angular: 8.2.14
... animations, common, compiler, compiler-cli, core, forms
... language-service, platform-browser, platform-browser-dynamic
... router

Package                           Version
-----------------------------------------------------------
@angular-devkit/architect         0.803.29
@angular-devkit/build-angular     0.803.29
@angular-devkit/build-optimizer   0.803.29
@angular-devkit/build-webpack     0.803.29
@angular-devkit/core              8.3.29
@angular-devkit/schematics        8.3.29
@angular/cli                      8.3.29
@ngtools/webpack                  8.3.29
@schematics/angular               8.3.29
@schematics/update                0.803.29
rxjs                              6.4.0
typescript                        3.5.3
webpack                           4.39.2

絵文字を正確にカウントできるライブラリ

絵文字をカウントする処理をスクラッチで書くと大変なので下記のライブラリを使用します。

github.com

上記のライブラリを使えば、通常の処理では難しい絵文字を含む文字列のカウントや、文字列操作を使用することができます。

  • 本ライブラリの使用例
import { substring, length } from 'stringz';

length('Iñtërnâtiônàlizætiøn☃💩'); // 22
substring('Emojis 👍🏽 are 🍆 poison. 🌮s are bad.', 7, 14); // "👍🏽 are 🍆"

Validatorの作成

上記のライブラリstringzを使用して、サロゲートペア(絵文字など)を含んだ文字列の最小文字数、最大文字数のバリデーターを作成します。 いわゆるAngularの公式バリデーターであるValidators.minLength(), Validators.maxLength()の絵文字に対応したバージョンになります。

surrogate-pair.validator.ts

import { length } from 'stringz';

import { Injectable } from '@angular/core';
import { AbstractControl, ValidatorFn } from '@angular/forms';

@Injectable()
export class SurrogatePairValidator {

  /**
   * サロゲートペア文字(絵文字など)も含む最小文字数
   *
   * @static
   * @param {number} minLength
   * @returns {ValidatorFn}
   * @memberof SurrogatePairValidator
   */
  public static minLength(minLength: number): ValidatorFn {
    return (control: AbstractControl): { [key: string]: any } => {
      const value = (control.value || '') + '';
      if (value === '') {
        return null;
      }
      return length(value) >= minLength
        ? null
        : { minlength: { requiredLength: minLength } };
    };
  }

  /**
   * サロゲートペア文字(絵文字など)も含む最大文字数
   *
   * @static
   * @param {number} maxLength
   * @return {*}  {ValidatorFn}
   * @memberof StringValidator
   */
  public static maxLength(maxLength: number): ValidatorFn {
    return (control: AbstractControl): { [key: string]: any } => {
      const value = (control.value || '') + '';
      if (value === '') {
        return null;
      }
      return length(value) <= maxLength
        ? null
        : { maxlength: { requiredLength: maxLength } };
    };
  }
}

上記のバリデーターのテストコードをjasmine + Karmaで書きます。

surrogate-pair.validator.spec.ts

import { Component, OnInit } from '@angular/core';
/**
 * This is unit test code for SurrogatePairValidator using karma.
 */
import {
  FormBuilder,
  FormGroup,
  ReactiveFormsModule
} from '@angular/forms';
import { SurrogatePairValidator } from './surrogate-pair.validator';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';

/**
 * Component that creates input field for test
 *
 * @export
 * @class RegisterComponent
 */
@Component({
  selector: 'app-register',
  template: `
    <form [formGroup]="form">
      <label>minLength</label>
      <input formControlName="minLength" />
      <label>maxLength</label>
      <input formControlName="maxLength" />
    </form>
  `
})
export class RegisterComponent implements OnInit {
  public form: FormGroup;

  constructor(private fb: FormBuilder) {}
  /**
   * initialize form
   *
   * @memberof RegisterComponent
   */
  public ngOnInit(): void {
    this.form = this.fb.group({
      minLength: [null, SurrogatePairValidator.minLength(5)],
      maxLength: [null, SurrogatePairValidator.maxLength(5)]
    });
  }
}

/**
 * Check if the method checks invalid_inputs and valid_inputs correctly
 *
 * @param {string} field_name
 * @param {RegisterComponent} component
 * @param {string[]} valid_inputs
 * @param {string[]} invalid_inputs
 */
function assertValues(
  field_name: string,
  component: RegisterComponent,
  valid_inputs: string[],
  invalid_inputs: string[]
): void {
  const control = component.form.controls[field_name];
  for (const invalid of invalid_inputs) {
    control.setValue(invalid);
    expect(control.valid).toBeFalsy();
  }
  for (const valid of valid_inputs) {
    control.setValue(valid);
    expect(control.valid).toBeTruthy();
  }
}

describe('SurrogatePairValidator', () => {
  let component: RegisterComponent;
  let fixture: ComponentFixture<RegisterComponent>;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [ReactiveFormsModule, FormsModule],
      declarations: [RegisterComponent]
    });

    fixture = TestBed.createComponent(RegisterComponent);

    component = fixture.componentInstance;
    component.ngOnInit();
  });


  it('minLength', () => {
    const valid_inputs = ['12🐍4🏴󠁧󠁢󠁷󠁬󠁳󠁿', '1吉😀家😀🏴󠁧󠁢󠁷󠁬󠁳󠁿😱', '', null];
    const invalid_inputs = ['😀😀😀😀', '😀😀 😀', 'abce', '1234'];
    assertValues(
      'minLength',
      component,
      valid_inputs,
      invalid_inputs
    );
  });

  it('maxLength', () => {
    const valid_inputs = ['12🐍45', '1吉🏴󠁧󠁢󠁷󠁬󠁳󠁿😀', '', null];
    const invalid_inputs = ['😀😀😀😀😀😀', '😀😀 😀😀😀', 'abcedf', '123456'];
    assertValues(
      'maxLength',
      component,
      valid_inputs,
      invalid_inputs
    );
  });
});

上記のテストコードをng testコマンドで実行すると作成したバリデーターが正しく動くことがわかります。

f:id:l08084:20201206192241p:plain
絵文字の最小/最大文字数のバリデーターが正しく動いていることがわかる

Directiveの作成

続いて、input要素のmaxlength属性の絵文字対応バージョンを作成するために、指定文字数以上を入力したら、削除するようなDirectiveを作成します。

surrogate-pair-max-length.directive.ts

import { length, substring } from 'stringz';

import {
  Directive,
  ElementRef,
  HostListener,
  Input,
  Renderer2
} from '@angular/core';

/**
 * Surrogate pair max length directive.
 *
 * @export
 * @class SurrogatePairMaxLengthDirective
 */
@Directive({
  selector: '[appSurrogatePairMaxLength]'
})
export class SurrogatePairMaxLengthDirective {
  @Input() public appSurrogatePairMaxLength: number;

  constructor(private el: ElementRef, private renderer: Renderer2) {}

  /**
   * Handles input event.
   *
   * @param {*} event
   * @memberof SurrogatePairMaxLengthDirective
   */
  @HostListener('input', ['$event'])
  public onChange(event: any): void {
    if (length(event.target.value) > this.appSurrogatePairMaxLength) {
      this.renderer.setProperty(
        this.el.nativeElement,
        'value',
        substring(event.target.value, 0, this.appSurrogatePairMaxLength)
      );
    }
  }
}

下記のコードでinput要素に文字を入力するたびに、指定文字数よりも長い文字列が入力された場合は、Renderer2で余分な文字を削除した状態の文字列で置き換える、といった処理をしています。

  @HostListener('input', ['$event'])
  public onChange(event: any): void {
    if (length(event.target.value) > this.appSurrogatePairMaxLength) {
      this.renderer.setProperty(
        this.el.nativeElement,
        'value',
        substring(event.target.value, 0, this.appSurrogatePairMaxLength)
      );
    }
  }

上記のDirectiveを作成した後、下記のようなコードを書けば、3文字以上の文字を入力できないinput要素が完成します。

app.component.html

<input type="text" appSurrogatePairMaxLength="3">

f:id:l08084:20201206200119p:plain
3文字以上入力できない

参考サイト

GitHub - sallar/stringz: :100: Super fast unicode-aware string manipulation Javascript library

僕は、なぜ絵文字の長さが、直感に反するのか理解したい...!! - Qiita

A deep dive into Angular’s Renderer2.setValue method | by Konda Reddy Yaramala | JavaScript In Plain English | Medium

ionic2 - Ionic 3/Angular 2 - Renderer2.setValue() doesn't update the value of my input field - Stack Overflow

【Ionic v5】[個人開発]利用規約とプライバシーポリシー画面を作る

はじめに

Ionicで作成中の体重計アプリ「SpeedWeight」に利用規約とプライバシー画面を追加します。

環境

ハイブリットモバイルアプリ用フレームワークであるIonic(Angular)とFirebaseを使用してアプリを作成しています。

  • firebase@7.21.1

$ ionic infoコマンドの実行結果

$ ionic info

Ionic:

   Ionic CLI                     : 6.11.8 (/usr/local/lib/node_modules/@ionic/cli)
   Ionic Framework               : @ionic/angular 5.3.3
   @angular-devkit/build-angular : 0.1000.8
   @angular-devkit/schematics    : 10.0.8
   @angular/cli                  : 10.0.8
   @ionic/angular-toolkit        : 2.3.3

Capacitor:

   Capacitor CLI   : 2.4.1
   @capacitor/core : 2.4.1

Utility:

   cordova-res : not installed
   native-run  : not installed

System:

   NodeJS : v12.13.1 (/usr/local/bin/node)
   npm    : 5.6.0
   OS     : macOS Catalina

ログイン画面にリンクをつける

まず、ログイン画面に利用規約とプライバシーポリシー画面のリンクを追加します。

f:id:l08084:20201031190618p:plain

ログイン画面に追加した利用規約とプライバシーポリシー画面のリンクのコードは以下になります。

      <div class="notes">新規登録、ログインのどちらも上記のリンクから行うことができます。
        <a routerLink="/terms-of-service">
          利用規約
        </a><a routerLink="/privacy-policy">
          プライバシーポリシー
        </a>に同意したうえでログインしてください。
      </div>

リンクを追加したログイン画面のコードの全体像は以下になります。

「SpeedWeight」のログイン画面

利用規約画面を作成する

f:id:l08084:20201031191714p:plain
作成した利用規約画面

汎用的な利用規約の雛形(ひな型) | Webサイトの利用規約(無料テンプレート・商用利用可)

上記リンクの利用規約の雛形を参考にして、利用規約画面を作成します。

「SpeedWeight」の利用規約

プライバシーポリシー画面を作成する

f:id:l08084:20201031191759p:plain
作成したプライバシーポリシー画面

プライバシーポリシーの雛形(ひな型) | Webサイトの利用規約(無料テンプレート・商用利用可)

上記リンクのプライバシーポリシーの雛形を参考にして、プライバシーポリシー画面を作成します。

「SpeedWeight」のプライバシーポリシー

参考サイト

Webサービス個人開発するなら知りたい利用規約とプライバシーポリシーの作り方 - Qiita

汎用的な利用規約の雛形(ひな型) | Webサイトの利用規約(無料テンプレート・商用利用可)

プライバシーポリシーの雛形(ひな型) | Webサイトの利用規約(無料テンプレート・商用利用可)

個人開発者がサービスリリースに際してやることリスト - Qiita

個人開発アプリのプライバシーポリシーを参考にしてみる – 輝く僕らの学費

ion-router-link - Ionic Documentation

Angular + Firebase でGitHub認証

f:id:l08084:20201025172314p:plain

はじめに

Ionic(Angular)のアプリでFirebase認証によるGitHubのサインイン処理を実装します。

関連記事

メールアドレス/パスワード、Twitter、Facebook、GoogleのFirebase認証については過去に記事にしています。

  • メールアドレス/パスワードによる認証

AngularでFirebase認証(その1) Firebaseのセットアップ - 中安拓也のブログ

AngularでFirebase認証(その2) Angular Materialを使ったログイン画面の作成 - 中安拓也のブログ

AngularでFirebase認証(その3) Firebase Authentication の呼び出し - 中安拓也のブログ

Angular + Firebase でアカウント登録画面の作成 - 中安拓也のブログ

  • Twitter認証

Angular + Firebase でTwitter認証 - 中安拓也のブログ

  • Facebook認証

Angular + Firebase でFacebook認証 - 中安拓也のブログ

  • Google認証

Angular + Firebase でGoogle認証 - 中安拓也のブログ

  • Firebase認証のリダイレクトモード

【Angular】リダイレクトモードでFirebase認証を行う - 中安拓也のブログ

環境

ハイブリットモバイルアプリ用フレームワークであるIonic(Angular)とFirebaseを使用してアプリを作成しています。

  • firebase@7.21.1

$ ionic infoコマンドの実行結果

$ ionic info

Ionic:

   Ionic CLI                     : 6.11.8 (/usr/local/lib/node_modules/@ionic/cli)
   Ionic Framework               : @ionic/angular 5.3.3
   @angular-devkit/build-angular : 0.1000.8
   @angular-devkit/schematics    : 10.0.8
   @angular/cli                  : 10.0.8
   @ionic/angular-toolkit        : 2.3.3

Capacitor:

   Capacitor CLI   : 2.4.1
   @capacitor/core : 2.4.1

Utility:

   cordova-res : not installed
   native-run  : not installed

System:

   NodeJS : v12.13.1 (/usr/local/bin/node)
   npm    : 5.6.0
   OS     : macOS Catalina

Step1: FirebaseコンソールでGitHub認証を有効にする

Firebaseのコンソールを開いて、[Authentication] セクションを開きます。

続いて、[ログイン方法] タブで、トグルを操作して、[GitHub] プロバイダを有効にしようとします。

f:id:l08084:20201025184514p:plain
クライアントIDとクライアントシークレットがないため、有効にできない

この時点では、クライアントIDとクライアントシークレットがないため、GitHubプロバイダを有効にできません。この画面からStep2で必要になる認証コールバック URL だけコピーしてStep2に進みます。

Step2: GitHubからクライアントIDとクライアントシークレットを取得する

このリンクでGitHubにアプリを登録し、クライアントIDとクライアントシークレットを取得します。

f:id:l08084:20201025185234p:plain
Register a new applicationをクリックしてアプリを登録する

続いて、Register a new application ボタンをクリックしてアプリの登録画面に進みます。

f:id:l08084:20201025185647p:plain
アプリの登録画面

Authorization callback URLの欄にはStep1で取得した認証コールバック URLを入力します。

アプリの登録が完了すると、クライアントIDとクライアントシークレットが発行されるので、Step1に戻ってFirebaseコンソールからGitHub認証を有効にします。

Step3: GitHubログインボタンのテンプレートを作成する

login.page.html

      <ion-button (click)="signInWithGitHub()" color="github" class="login-button border">
        <ion-icon class="sns-icon" name="logo-github"></ion-icon>GitHubでログイン
      </ion-button>

Step4: リダイレクトモードでFirebase認証(GitHub)

モバイルデバイスによる認証を想定しているため、FirebaseのGitHub認証をリダイレクトモード(signInWithRedirect)で実装していきます。

下記のコードはFirebase認証を呼び出すサービスクラスです。ログイン画面から呼び出されます。

authentication.service.ts

export class AuthenticationService {
  constructor(public afAuth: AngularFireAuth) {}

  /**
   * GitHub認証を呼び出す。
   * 認証成功時にリダイレクトする。
   *
   * @returns {Promise<void>}
   * @memberof AuthenticationService
   */
  public signInWithGitHub(): Promise<void> {
    return this.afAuth.signInWithRedirect(
      new firebase.auth.GithubAuthProvider()
    );
  }

  /**
   * リダイレクト後の処理。
   *
   * @returns {Promise<firebase.auth.UserCredential>}
   * @memberof AuthenticationService
   */
  public getRedirectResult(): Promise<firebase.auth.UserCredential> {
    return this.afAuth.getRedirectResult();
  }
}

下記のコードはログイン画面のコンポーネントクラスです。

login.page.ts

export class LoginPage implements OnInit {
  constructor(
    private authenticationService: AuthenticationService,
    private router: Router
  ) {}

  ngOnInit() {
    this.getRedirectResult();
  }

  /**
   * GitHubで認証する。
   *
   * @memberof LoginPage
   */
  public async signInWithGitHub() {
    await this.authenticationService.signInWithGitHub();
  }

  /**
   * リダイレクト後に呼び出される処理。
   *
   * @private
   * @memberof LoginPage
   */
  private async getRedirectResult() {
    const result: firebase.auth.UserCredential = await this.authenticationService.getRedirectResult();
    try {
      if (result.user != null) {
        this.router.navigate(['/weight/tabs/tab1']);
      }
    } catch (error) {
      console.log(error);
    }
  }
}

上記のコードで行っているのは、下記の内容です。

  1. ユーザーがsignInWithGitHub()でログインします。
  2. GitHub でログインを行うため、signInWithRedirect()メソッドによってリダイレクトがトリガーされます。
  3. GitHubログインをすると、ユーザーはログイン画面のコンポーネントに戻されます。
  4. ユーザーのログインは、ログイン画面のngOnInit()内のgetRedirectResult()で返される Promise によって解決されます。
  5. navigate()メソッドで、ルーターがユーザーを/weight/tabs/tab1に移動させます。

参考サイト

JavaScript による GitHub を使用した認証  |  Firebase

Firebase - Github Authentication - Tutorialspoint

Firebase-github-authentication - Dev Guides

【Ionic v5】ダークモードを無効にする

はじめに

ダークモード対応が面倒なので、Ionicがデフォルトで対応してくれているダークモードを解除したい。

f:id:l08084:20201025152207p:plain
外観モードをライトにした場合

f:id:l08084:20201025152302p:plain
外観モードをダークにした場合

環境

ハイブリットモバイルアプリ用フレームワークであるIonic(Angular)を使用してアプリを作成しています。

$ ionic infoコマンドの実行結果

$ ionic info

Ionic:

   Ionic CLI                     : 6.11.8 (/usr/local/lib/node_modules/@ionic/cli)
   Ionic Framework               : @ionic/angular 5.3.3
   @angular-devkit/build-angular : 0.1000.8
   @angular-devkit/schematics    : 10.0.8
   @angular/cli                  : 10.0.8
   @ionic/angular-toolkit        : 2.3.3

Capacitor:

   Capacitor CLI   : 2.4.1
   @capacitor/core : 2.4.1

Utility:

   cordova-res : not installed
   native-run  : not installed

System:

   NodeJS : v12.13.1 (/usr/local/bin/node)
   npm    : 5.6.0
   OS     : macOS Catalina

ダークテーマを無効にする

variables.scssから@media (prefers-color-scheme: dark) {のブロックを削除すると、Ionicのダークモード対応が無効になります。

src/theme/variables.scss

@media (prefers-color-scheme: dark) {
  /*
   * Dark Colors
   * -------------------------------------------
   */

  body {
    --ion-color-primary: #428cff;
    --ion-color-primary-rgb: 66, 140, 255;
    --ion-color-primary-contrast: #ffffff;
    --ion-color-primary-contrast-rgb: 255, 255, 255;
    --ion-color-primary-shade: #3a7be0;
    --ion-color-primary-tint: #5598ff;

    --ion-color-secondary: #50c8ff;
    --ion-color-secondary-rgb: 80, 200, 255;
    --ion-color-secondary-contrast: #ffffff;
    --ion-color-secondary-contrast-rgb: 255, 255, 255;
    --ion-color-secondary-shade: #46b0e0;
    --ion-color-secondary-tint: #62ceff;

    --ion-color-tertiary: #6a64ff;
    --ion-color-tertiary-rgb: 106, 100, 255;
    --ion-color-tertiary-contrast: #ffffff;
    --ion-color-tertiary-contrast-rgb: 255, 255, 255;
    --ion-color-tertiary-shade: #5d58e0;
    --ion-color-tertiary-tint: #7974ff;

    --ion-color-success: #2fdf75;
    --ion-color-success-rgb: 47, 223, 117;
    --ion-color-success-contrast: #000000;
    --ion-color-success-contrast-rgb: 0, 0, 0;
    --ion-color-success-shade: #29c467;
    --ion-color-success-tint: #44e283;

    --ion-color-warning: #ffd534;
    --ion-color-warning-rgb: 255, 213, 52;
    --ion-color-warning-contrast: #000000;
    --ion-color-warning-contrast-rgb: 0, 0, 0;
    --ion-color-warning-shade: #e0bb2e;
    --ion-color-warning-tint: #ffd948;

    --ion-color-danger: #ff4961;
    --ion-color-danger-rgb: 255, 73, 97;
    --ion-color-danger-contrast: #ffffff;
    --ion-color-danger-contrast-rgb: 255, 255, 255;
    --ion-color-danger-shade: #e04055;
    --ion-color-danger-tint: #ff5b71;

    --ion-color-dark: #f4f5f8;
    --ion-color-dark-rgb: 244, 245, 248;
    --ion-color-dark-contrast: #000000;
    --ion-color-dark-contrast-rgb: 0, 0, 0;
    --ion-color-dark-shade: #d7d8da;
    --ion-color-dark-tint: #f5f6f9;

    --ion-color-medium: #989aa2;
    --ion-color-medium-rgb: 152, 154, 162;
    --ion-color-medium-contrast: #000000;
    --ion-color-medium-contrast-rgb: 0, 0, 0;
    --ion-color-medium-shade: #86888f;
    --ion-color-medium-tint: #a2a4ab;

    --ion-color-light: #222428;
    --ion-color-light-rgb: 34, 36, 40;
    --ion-color-light-contrast: #ffffff;
    --ion-color-light-contrast-rgb: 255, 255, 255;
    --ion-color-light-shade: #1e2023;
    --ion-color-light-tint: #383a3e;
  }

  /*
   * iOS Dark Theme
   * -------------------------------------------
   */

  .ios body {
    --ion-background-color: #000000;
    --ion-background-color-rgb: 0, 0, 0;

    --ion-text-color: #ffffff;
    --ion-text-color-rgb: 255, 255, 255;

    --ion-color-step-50: #0d0d0d;
    --ion-color-step-100: #1a1a1a;
    --ion-color-step-150: #262626;
    --ion-color-step-200: #333333;
    --ion-color-step-250: #404040;
    --ion-color-step-300: #4d4d4d;
    --ion-color-step-350: #595959;
    --ion-color-step-400: #666666;
    --ion-color-step-450: #737373;
    --ion-color-step-500: #808080;
    --ion-color-step-550: #8c8c8c;
    --ion-color-step-600: #999999;
    --ion-color-step-650: #a6a6a6;
    --ion-color-step-700: #b3b3b3;
    --ion-color-step-750: #bfbfbf;
    --ion-color-step-800: #cccccc;
    --ion-color-step-850: #d9d9d9;
    --ion-color-step-900: #e6e6e6;
    --ion-color-step-950: #f2f2f2;

    --ion-toolbar-background: #0d0d0d;

    --ion-item-background: #000000;

    --ion-card-background: #1c1c1d;
  }


  /*
   * Material Design Dark Theme
   * -------------------------------------------
   */

  .md body {
    --ion-background-color: #121212;
    --ion-background-color-rgb: 18, 18, 18;

    --ion-text-color: #ffffff;
    --ion-text-color-rgb: 255, 255, 255;

    --ion-border-color: #222222;

    --ion-color-step-50: #1e1e1e;
    --ion-color-step-100: #2a2a2a;
    --ion-color-step-150: #363636;
    --ion-color-step-200: #414141;
    --ion-color-step-250: #4d4d4d;
    --ion-color-step-300: #595959;
    --ion-color-step-350: #656565;
    --ion-color-step-400: #717171;
    --ion-color-step-450: #7d7d7d;
    --ion-color-step-500: #898989;
    --ion-color-step-550: #949494;
    --ion-color-step-600: #a0a0a0;
    --ion-color-step-650: #acacac;
    --ion-color-step-700: #b8b8b8;
    --ion-color-step-750: #c4c4c4;
    --ion-color-step-800: #d0d0d0;
    --ion-color-step-850: #dbdbdb;
    --ion-color-step-900: #e7e7e7;
    --ion-color-step-950: #f3f3f3;

    --ion-item-background: #1e1e1e;

    --ion-toolbar-background: #1f1f1f;

    --ion-tab-bar-background: #1f1f1f;

    --ion-card-background: #1e1e1e;
  }
}

variables.scssの上記の部分を削除すると、端末の設定をダークモードにしても、Ionicアプリはダークモードにならなくなります。

f:id:l08084:20201025152207p:plain
外観モードをダークにしてもIonicアプリの色はライトのまま

参考サイト

ダークモード - Ionic Framework 日本語ドキュメンテーション

【Angular】Akita学習(2) - TODOアプリ作成

f:id:l08084:20201010181001p:plain

はじめに

前回の記事に引き続き、状態管理ライブラリのAkitaについて学習していきます。今回は、AkitaのEntityStoreを使用して、TODOアプリを作成します。

環境

  • Angular: 8.2.14
  • @datorama/akita: 5.2.4

$ ng versionの実行結果

Angular CLI: 8.3.29
Node: 12.13.1
OS: darwin x64
Angular: 8.2.14
... animations, common, compiler, compiler-cli, core, forms
... language-service, platform-browser, platform-browser-dynamic
... router

Package                           Version
-----------------------------------------------------------
@angular-devkit/architect         0.803.29
@angular-devkit/build-angular     0.803.29
@angular-devkit/build-optimizer   0.803.29
@angular-devkit/build-webpack     0.803.29
@angular-devkit/core              8.3.29
@angular-devkit/schematics        8.3.29
@angular/cli                      8.3.29
@ngtools/webpack                  8.3.29
@schematics/angular               8.3.29
@schematics/update                0.803.29
rxjs                              6.4.0
typescript                        3.5.3
webpack                           4.39.2

TODOアプリ作成

データベースのテーブルのようなデータ構造を持っているEntityStoreを使用して、TODOアプリを作成します。

EntityStoreには、通常のStoreと違ってselectAlladdremoveなどのメソッドが用意されています。

Model

まずは、Modelから作成していきます。Modelはデータベースでいうところのテーブルの構造にあたります。

todo.model.ts

import { ID } from '@datorama/akita';

export interface Todo {
  id: ID;
  title: string;
}

export function createTodo(params: Partial<Todo>) {
  return {} as Todo;
}

EntityStore

TODOの状態を保持するStoreです。今回はEntityStoreを採用しています。

todo.store.ts

import { Injectable } from '@angular/core';
import { EntityState, EntityStore, StoreConfig } from '@datorama/akita';
import { Todo } from './todo.model';

export interface TodoState extends EntityState<Todo> {}

@Injectable({ providedIn: 'root' })
@StoreConfig({ name: 'todo' })
export class TodoStore extends EntityStore<TodoState> {
  constructor() {
    super();
  }
}

Query

TODOの取得機能を提供しているQueryです。

todo.query.ts

import { Injectable } from '@angular/core';
import { QueryEntity } from '@datorama/akita';
import { TodoState, TodoStore } from './todo.store';

@Injectable({ providedIn: 'root' })
export class TodoQuery extends QueryEntity<TodoState> {
  constructor(protected store: TodoStore) {
    super(store);
  }
}

Service

addTodoメソッドとremoveTodoメソッドでTODOの追加と削除の機能を提供しています。

todo.service.ts

import { Injectable } from '@angular/core';
import { guid, ID } from '@datorama/akita';
import { TodoStore } from './todo.store';
@Injectable({ providedIn: 'root' })
export class TodoService {
  constructor(private store: TodoStore) {}

  /**
   * TODOを追加する
   *
   * @param {string} title
   * @memberof TodoService
   */
  addTodo(title: string) {
    this.store.add({
      id: guid(),
      title,
    });
  }

  /**
   * TODOを削除する
   *
   * @param {ID} id
   * @memberof TodoService
   */
  removeTodo(id: ID) {
    this.store.remove(id);
  }
}

Component

TODO画面のコンポーネントクラスです。QueryのselectAllメソッドでTODOの全量を取得し、Serviceを経由してTODOの追加と削除を行っています。

todo.component.ts

import { TodoState } from './state/todo.store';
import { Component, OnInit } from '@angular/core';
import { getEntityType, ID } from '@datorama/akita';
import { Observable } from 'rxjs';
import { TodoService } from './state/todo.service';
import { TodoQuery } from './state/todo.query';
import { FormBuilder, FormControl, FormGroup } from '@angular/forms';

@Component({
  selector: 'app-todo',
  templateUrl: './todo.component.html',
  styleUrls: ['./todo.component.scss'],
})
export class TodoComponent implements OnInit {
  readonly allTodo$: Observable<getEntityType<TodoState>[]>;
  todoFormGroup: FormGroup;
  titleControl: FormControl;

  constructor(
    private service: TodoService,
    private query: TodoQuery,
    private fb: FormBuilder
  ) {
    this.allTodo$ = this.query.selectAll();
    this.todoFormGroup = this.fb.group({
      title: ['', []],
    });
    this.titleControl = this.todoFormGroup.get('title') as FormControl;
  }

  ngOnInit() {}

  /**
   * TODOを追加する
   *
   * @memberof TodoComponent
   */
  addTodo() {
    this.service.addTodo(this.titleControl.value);
  }

  /**
   * TODOを削除する
   *
   * @param {ID} id
   * @memberof TodoComponent
   */
  removeTodo(id: ID) {
    this.service.removeTodo(id);
  }
}

todo.component.html

<div class="todo-area">
  <form [formGroup]="todoFormGroup">
    <mat-form-field>
      <input matInput placeholder="TODOタイトル" formControlName="title" />
    </mat-form-field>
  </form>
  <button (click)="addTodo()" mat-raised-button color="primary">
    Add TODO
  </button>
</div>
<ul>
  <li *ngFor="let todo of allTodo$ | async">
    {{ todo.title }}
    <button (click)="removeTodo(todo.id)" mat-button color="warn">Remove</button>
  </li>
</ul>

実際にTODOアプリを動作させる

上記のコードを動作させると、下記のようなアプリになります。Add TODOボタンを押下するとTODOが追加され、Removeボタンを押下すると、TODOが削除されます。

f:id:l08084:20201010185612p:plain
TODOアプリ

参考サイト

リストレンダリング — Vue.js

TypeScript特有の組み込み型関数 - log.pocka.io

Akita | Reactive State Management

Netanel Basal – Datorama Engineering

Angular向け状態管理ライブラリAkitaの紹介 - Qiita

Angularのシンプルな状態管理ライブラリ Akita について - Qiita

Akita🐶でがんばる状態管理 - Qiita

素朴な Angular 向けの type safe で immutable な Flux Store - Qiita

【Angular】Akita学習(1) - カウンターアプリ作成

はじめに

仕事で状態管理ライブラリのAkitaを使うことになったので勉強のためにAngularとAkitaを使ってカウンターアプリを作ることにしました。

Akitaって?

f:id:l08084:20201005162817p:plain

Angular, React, Vueに対応している状態管理ライブラリになります。

f:id:l08084:20201006221324p:plain

状態を保持するStore、現在の状態を取得するQuery、状態を更新したり、APIを呼び出したりするServiceという風に機能が分かれています。

環境

  • Angular: 8.2.14
  • @datorama/akita: 5.2.4

$ ng versionの実行結果

Angular CLI: 8.3.29
Node: 12.13.1
OS: darwin x64
Angular: 8.2.14
... animations, common, compiler, compiler-cli, core, forms
... language-service, platform-browser, platform-browser-dynamic
... router

Package                           Version
-----------------------------------------------------------
@angular-devkit/architect         0.803.29
@angular-devkit/build-angular     0.803.29
@angular-devkit/build-optimizer   0.803.29
@angular-devkit/build-webpack     0.803.29
@angular-devkit/core              8.3.29
@angular-devkit/schematics        8.3.29
@angular/cli                      8.3.29
@ngtools/webpack                  8.3.29
@schematics/angular               8.3.29
@schematics/update                0.803.29
rxjs                              6.4.0
typescript                        3.5.3
webpack                           4.39.2

Akitaインストール

まず、Angular CLIを使ってAngularプロジェクト(akita-counter)を作成します。

$ ng new akita-counter

続いて、Akitaライブラリをインストールします。

$ ng add @datorama/akita

Akita CLIもグローバルインストールします。

$ npm install @datorama/akita-cli -g

カウンターアプリ作成

値を一つずつ増やしたり減らしたりできるカウンターアプリを作成していきます。

Store

まず、カウンターの値を保持するStoreを作成します。

Akitaには通常のStoreとデータベースのテーブルのような取り扱いができるEntityStoreがありますが、今回の例では通常のStoreを使用しています。

counter.store.ts

import { Injectable } from '@angular/core';
import { Store, StoreConfig } from '@datorama/akita';

export interface CounterState {
  counter: number;
}

export function createInitialState(): CounterState {
  return {
    counter: 0,
  };
}
@Injectable({ providedIn: 'root' })
@StoreConfig({ name: 'counter' })
export class CounterStore extends Store<CounterState> {
  constructor() {
    super(createInitialState());
  }
}

Query

続いて、Storeから現在のカウンターの値を取得するQueryを作成します。

counter.query.ts

import { Injectable } from '@angular/core';
import { Query } from '@datorama/akita';
import { CounterState, CounterStore } from './counter.store';
@Injectable({ providedIn: 'root' })
export class CounterQuery extends Query<CounterState> {
  constructor(protected store: CounterStore) {
    super(store);
  }
}

Service

カウンターの値を更新する機能を持つServiceを作成します。加算を行うincrement()と減算を行うdecrement()を作成しました。

counter.service.ts

import { Injectable } from '@angular/core';
import { CounterStore } from './counter.store';
@Injectable({ providedIn: 'root' })
export class CounterService {
  constructor(private counterStore: CounterStore) {}

  increment() {
    this.counterStore.update((state) => ({
      counter: state.counter + 1,
    }));
  }

  decrement() {
    this.counterStore.update((state) => ({
      counter: state.counter - 1,
    }));
  }
}

increment()decrement()の内部ではStoreの値を更新するためにStoreのメソッドであるupdate()を呼び出しています。

Component

カウンター画面を作成します。QueryとServiceをDIすることで、カウントの取得と更新を行っています。

counter.component.ts

import { Component, OnInit } from '@angular/core';
import { CounterService } from './state/counter.service';
import { CounterQuery } from './state/counter.query';
import { Observable } from 'rxjs';

@Component({
  selector: 'app-counter',
  templateUrl: './counter.component.html',
  styleUrls: ['./counter.component.scss'],
})
export class CounterComponent implements OnInit {
  readonly counter$: Observable<number>;

  constructor(
    private counterService: CounterService,
    private counterQuery: CounterQuery
  ) {
    this.counter$ = this.counterQuery.select('counter');
  }

  ngOnInit() {}

  increment() {
    this.counterService.increment();
  }

  decrement() {
    this.counterService.decrement();
  }
}

Storeの値を取得するメソッドとして、Queryは2種類のメソッドを用意しています。select()getValue()です。select()ではStoreの値をObservable型で返します。getValue()はStoreの生の値を返します。

今回の例では、Storeの値の取得にselect()を使っています。

counter.component.html

<button (click)="increment()">Increment</button>
<button (click)="decrement()">Decrement</button>
{{ counter$ | async }}

上記のコードを実行すると、このようなカウンターアプリが表示されます。

f:id:l08084:20201006222153p:plain
カウンターアプリ

なお、AkitaはChromeのRedux Devtools Extensionで値の変遷を追うことができます。

f:id:l08084:20201007152716p:plain
AkitaにRedux Devtools Extentionを使っているところ

次回の記事

【Angular】Akita学習(2) - TODOアプリ作成 - 中安拓也のブログ

参考サイト

Akita | Reactive State Management

https://engineering.datorama.com/@NetanelBasal

Angular向け状態管理ライブラリAkitaの紹介 - Qiita

Angularのシンプルな状態管理ライブラリ Akita について - Qiita

Akita🐶でがんばる状態管理 - Qiita

素朴な Angular 向けの type safe で immutable な Flux Store - Qiita