お店の出店・予約管理をカンタンにできるアプリを作成した

## はじめに
こんにちは！
[そると](https://twitter.com/jsalt_0525)です。

[前回](https://qiita.com/beniho/items/0b94b209e29865327360)に引き続き個人開発してみた記事第二弾です！

## どんなアプリ？
今回のアプリは「**cachette**」という名前のアプリで記事のタイトルにあるように**お店の出店・予約管理をカンタンに**することができます。

アプリの利用者は**お店=カシェット**の出店者と予約者で、自分だけのカシェットをカンタンに出店したり、自分だけが知っているお店を予約できたりと**プライベート**なやり取りができるアプリになっております。

また、cachetteはフランス語で"**隠れ家**"という意味なのでこのアプリにピッタリですよね。

※アプリの概要については[こちら](https://cachette.me/)にランディングページも用意していますのでご覧ください。
![アプリ紹介画像.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/181865/b7acc24b-eafd-a218-f0c0-0315f25509cd.png)

## フレームワーク等
* ionic
* capacitor
* Firebase


## 機能紹介

今回も前回同様ちょくちょく実装例を交えながら説明したいと思います。

### ①ユーザー登録・ログイン
![グループ 342.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/181865/63efb2cf-8514-d290-8170-6048426a382f.png)
まずはユーザー登録・ログインですが、
さっそくこの機能についてはリリースに当たって機能制限したところがありました・・・

もともとは**ソーシャルログイン**までできるようにしたかったのですが、ユーザーの定義といいますか、例えばメールアドレスで新規登録していたユーザーがソーシャルログインをしたら新規ユーザーになる？既存ユーザーと同じメールアドレスだったら既存ユーザーに紐づく？とか色々考えることいっぱいあるなあと思ったのでとりあえずメールアドレスでのみ登録・ログインができるようになっています。

また、新規登録後にメールアドレス認証ができないとアプリを使えないようにもなっております。

メールアドレスで登録・認証は全て[Firebase Authentication](https://firebase.google.com/docs/auth?hl=ja)を使用しております。

### ②出店
![グループ 345.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/181865/116df5d4-565e-c56f-9a43-091e4ba50768.png)
ホーム画面上部のSegmented Controlを「作る」に選択すると**作成したカシェットの一覧**が表示されます。

右下の＋ボタンから新規作成することができ、カシェットに必要な情報を入力してカシェットを作成することができます。

作成したカシェット一覧はカード形式で表示されており、そこに各機能を起動するボタンが付いています。

特に大事なのが右上の**QRコード表示ボタン**で、起動した画面でQRコードの表示と共有ができます。

### ③予約
![グループ 339.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/181865/11678186-65ae-d30d-5c39-fd1aedbb3f08.png)
ホーム画面上部のSegmented Controlを「予約する」に選択すると**QRコード読み取りボタン**と**以前予約したカシェット一覧**が表示されます。

QRコード読み取りボタン押下で読み取り画面が起動し、カシェットが発行したQRコードを読み取ることができます。
QRコード読み取り後は予約画面に自動で遷移します。

ちなみにQRコード読み取り画面は以下のように実装しています。
[こちら](https://devdactic.com/pwa-qr-scanner-ionic/)を参考にしました。

```qrscan.component.html
<input #fileinput type="file" accept="image/*;capture=camera" hidden (change)="handleFile($event.target.files)">

<div class="QR">
  <video #video [hidden]="!scanActive"></video>

  <!-- 点滅させる枠の画像 -->
  <ion-img class="blinkimg" src="../../assets/QRreaderFrame.svg"></ion-img>
</div>

<canvas #canvas hidden></canvas>

<div class="other">
  <p>QRコードをスキャンすると<br>
    カシェットの予約ができます</p>
  <ion-button color="secondary" (click)="takePhotoLibrary()">
    <ion-icon slot="start" name="image"></ion-icon>
    アルバムから選択
  </ion-button>
</div>
```

```qrscan.component.scss
video {
    object-fit: cover;
    width: 80vw;
    height: 80vw;
}

.other {
    text-align: center;
    padding: 0px 10px 10px 10px;
}

.QR {
    height: 80vw;
    position: relative;

    ion-img {
        position: absolute;
        width: 70vw;
        height: 70vw;
        top: 5vw;
        right: 5vw;
        left: 5vw;
        bottom: 5vw;
    }
}

p {
    color: var(--ion-color-primary);
}

ion-button {
    --border-radius: 30px;
}

/**************************** 点滅 ****************************/
.blinkimg{
    -webkit-animation:blink 0.8s ease-in-out infinite alternate;
    -moz-animation:blink 0.8s ease-in-out infinite alternate;
    animation:blink 0.8s ease-in-out infinite alternate;
}

@-webkit-keyframes blink{
    0% {opacity:0.2;}
    100% {opacity:1;}
}

@-moz-keyframes blink{
    0% {opacity:0.2;}
    100% {opacity:1;}
}

@keyframes blink{
    0% {opacity:0.2;}
    100% {opacity:1;}
}
/**************************** 点滅 ****************************/

```
```qrscan.component.ts
import { Component, OnInit, ViewChild, ElementRef } from '@angular/core';
import { LoadingController, Platform, PopoverController, AlertController } from '@ionic/angular';
import jsQR from "jsqr";
import { Plugins, CameraSource, CameraResultType } from '@capacitor/core';

@Component({
  selector: 'app-qrscan',
  templateUrl: './qrscan.component.html',
  styleUrls: ['./qrscan.component.scss'],
})
export class QrscanComponent implements OnInit {

  ngOnInit() {}

  @ViewChild('video', { static: false }) video: ElementRef;
  @ViewChild('canvas', { static: false }) canvas: ElementRef;
  @ViewChild('fileinput', { static: false }) fileinput: ElementRef;

  canvasElement: any;
  videoElement: any;
  canvasContext: any;
  scanActive = false;
  scanResult = null;
  loading: HTMLIonLoadingElement = null;

  constructor(
    private popoverController: PopoverController, 
    private loadingCtrl: LoadingController,
    private alertController: AlertController
  ) {}

  ngAfterViewInit() {
    this.canvasElement = this.canvas.nativeElement;
    this.canvasContext = this.canvasElement.getContext('2d');
    this.videoElement = this.video.nativeElement;
    this.startScan();
  }

  /* -------------------------------------------
  スキャン成功
  ------------------------------------------- */
  async scanSuccess() {
    this.popoverController.dismiss(this.scanResult);
  }

  /* -------------------------------------------
  スキャン開始
  ------------------------------------------- */
  async startScan() {
    // ここの getUserMedia が iOS14.5 じゃないとエラーになる・・・
    const stream = await navigator.mediaDevices.getUserMedia({
      video: { facingMode: 'environment' }
    });
  
    this.videoElement.srcObject = stream;
    this.videoElement.setAttribute('playsinline', true);
  
    this.loading = await this.loadingCtrl.create({});
    await this.loading.present();
  
    this.videoElement.play();
    requestAnimationFrame(this.scan.bind(this));
  }
  
  /* -------------------------------------------
  スキャン
  ------------------------------------------- */
  async scan() {
    if (this.videoElement.readyState === this.videoElement.HAVE_ENOUGH_DATA) {
      if (this.loading) {
        await this.loading.dismiss();
        this.loading = null;
        this.scanActive = true;
      }
  
      this.canvasElement.height = this.videoElement.videoHeight;
      this.canvasElement.width = this.videoElement.videoWidth;
  
      this.canvasContext.drawImage(
        this.videoElement,
        0,
        0,
        this.canvasElement.width,
        this.canvasElement.height
      );
      const imageData = this.canvasContext.getImageData(
        0,
        0,
        this.canvasElement.width,
        this.canvasElement.height
      );
      const code = jsQR(imageData.data, imageData.width, imageData.height, {
        inversionAttempts: 'dontInvert'
      });
  
      if (code) {
        this.scanActive = false;
        this.scanResult = code.data;
        this.scanSuccess();
      } else {
        if (this.scanActive) {
          requestAnimationFrame(this.scan.bind(this));
        }
      }
    } else {
      requestAnimationFrame(this.scan.bind(this));
    }
  }

  //フォトライブラリ
  async takePhotoLibrary() {
    // 画像取得
    const imageData = await Plugins.Camera.getPhoto({
      quality: 100,
      resultType: CameraResultType.Base64,
      source: CameraSource.Photos
    });

    // 画像からQR読み取り
    await this.handleFile("data:image/jpeg;base64,"+imageData.base64String);
  }
  
  /* -------------------------------------------
  イメージ読み込み
  ----------------------------------------------
  [param]
   base64：イメージファイル
  ------------------------------------------- */
  async handleFile(base64: string) {
    // ローディングコントローラー表示
    const loading = await this.loadingCtrl.create({
      message: '読み取り中...',
    });
    await loading.present();
  
    var img = new Image();
    img.onload = () => {
      this.canvasContext.drawImage(img, 0, 0, this.canvasElement.width, this.canvasElement.height);
      const imageData = this.canvasContext.getImageData(
        0,
        0,
        this.canvasElement.width,
        this.canvasElement.height
      );
      const code = jsQR(imageData.data, imageData.width, imageData.height, {
        inversionAttempts: 'dontInvert'
      });
  
      if (code) {
        this.scanResult = code.data;
        this.scanSuccess();
      } else {
        this.alert("","QRコードが取得できません。");
      }
    };
    img.src = base64;

    // ローディングコントローラー閉じる
    loading.dismiss();
  }

  /* -------------------------------------------
  アラート
  ----------------------------------------------
  [param]
   pstrTitle：タイトル
   pstrMessage：メッセージ
  ------------------------------------------- */
  private async alert(pstrTitle, pstrMessage) {
    const alert = await this.alertController.create({
      header: pstrTitle,
      message: pstrMessage,
      buttons: ['OK']
    });
    await alert.present();
  }
}
```

予約したことがあるカシェットは以前予約したカシェット一覧に表示され、その中で再度予約したいカシェットを選択するとそのカシェットの予約画面に遷移することができます。

予約画面では必要事項を入力して予約を確定することができます。

### ④予約状況確認
![グループ 344.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/181865/43c259c9-4f11-b92b-561a-b03b33cef221.png)
自分が出店したカシェットはホーム画面のマイカシェットから左下のボタンを押下することで**予約状況確認画面**が起動します。

予約状況はリスト表示とカレンダー表示の２パターン表示することができます。

このアプリは**予約は全て承認制**になっているので予約は**承認・拒否**しないと確定しません。

### ⑤予約履歴
![グループ 343.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/181865/297e86c3-a569-355e-fba4-632c4ac690a0.png)
ホーム画面右上のボタンから自分が予約した履歴を表示できます。

予約日の前日まででしたらこの画面で予約のキャンセルを行うことができます。

## まとめ
今回のアプリは実装するより考える時間の方が長かったように感じます。今まで作成したアプリの中では仕組みが複雑な方なので第三者の意見が無いと悩む場面が多かったです。個人開発あるあるだと思いますが、だんだんモチベーションを保つのが難しくなってくるんですよね（笑）でもそんなこともありましたが最後までしっかりこのアプリに向き合えたのは良かったと思いました。もっとユーザー数が増えたら機能追加等して皆さんが使いやすいアプリにどんどんバージョンアップしていけたらなと思います！もしご興味ありましたらインストールお願いします！ここまでお読みいただきありがとうございました！

### アプリのリンク
[iOS](https://apps.apple.com/us/app/cachette/id1565488450)
[android](https://play.google.com/store/apps/details?id=jp.JShiota.cachette)