##はじめに
こんにちは!
そるとです。
前回に引き続き個人開発してみた記事第二弾です!
##どんなアプリ?
今回のアプリは「cachette」という名前のアプリで記事のタイトルにあるようにお店の出店・予約管理をカンタンにすることができます。
アプリの利用者はお店=カシェットの出店者と予約者で、自分だけのカシェットをカンタンに出店したり、自分だけが知っているお店を予約できたりとプライベートなやり取りができるアプリになっております。
また、cachetteはフランス語で"隠れ家"という意味なのでこのアプリにピッタリですよね。
※アプリの概要についてはこちらにランディングページも用意していますのでご覧ください。
##フレームワーク等
- ionic
- capacitor
- Firebase
##機能紹介
今回も前回同様ちょくちょく実装例を交えながら説明したいと思います。
###①ユーザー登録・ログイン
まずはユーザー登録・ログインですが、
さっそくこの機能についてはリリースに当たって機能制限したところがありました・・・
もともとはソーシャルログインまでできるようにしたかったのですが、ユーザーの定義といいますか、例えばメールアドレスで新規登録していたユーザーがソーシャルログインをしたら新規ユーザーになる?既存ユーザーと同じメールアドレスだったら既存ユーザーに紐づく?とか色々考えることいっぱいあるなあと思ったのでとりあえずメールアドレスでのみ登録・ログインができるようになっています。
また、新規登録後にメールアドレス認証ができないとアプリを使えないようにもなっております。
メールアドレスで登録・認証は全てFirebase Authenticationを使用しております。
###②出店
ホーム画面上部のSegmented Controlを「作る」に選択すると作成したカシェットの一覧が表示されます。
右下の+ボタンから新規作成することができ、カシェットに必要な情報を入力してカシェットを作成することができます。
作成したカシェット一覧はカード形式で表示されており、そこに各機能を起動するボタンが付いています。
特に大事なのが右上のQRコード表示ボタンで、起動した画面でQRコードの表示と共有ができます。
###③予約
ホーム画面上部のSegmented Controlを「予約する」に選択するとQRコード読み取りボタンと以前予約したカシェット一覧が表示されます。
QRコード読み取りボタン押下で読み取り画面が起動し、カシェットが発行したQRコードを読み取ることができます。
QRコード読み取り後は予約画面に自動で遷移します。
ちなみにQRコード読み取り画面は以下のように実装しています。
こちらを参考にしました。
<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>
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;}
}
/**************************** 点滅 ****************************/
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();
}
}
予約したことがあるカシェットは以前予約したカシェット一覧に表示され、その中で再度予約したいカシェットを選択するとそのカシェットの予約画面に遷移することができます。
予約画面では必要事項を入力して予約を確定することができます。
###④予約状況確認
自分が出店したカシェットはホーム画面のマイカシェットから左下のボタンを押下することで予約状況確認画面が起動します。
予約状況はリスト表示とカレンダー表示の2パターン表示することができます。
このアプリは予約は全て承認制になっているので予約は承認・拒否しないと確定しません。
###⑤予約履歴
ホーム画面右上のボタンから自分が予約した履歴を表示できます。
予約日の前日まででしたらこの画面で予約のキャンセルを行うことができます。
##まとめ
今回のアプリは実装するより考える時間の方が長かったように感じます。今まで作成したアプリの中では仕組みが複雑な方なので第三者の意見が無いと悩む場面が多かったです。個人開発あるあるだと思いますが、だんだんモチベーションを保つのが難しくなってくるんですよね(笑)でもそんなこともありましたが最後までしっかりこのアプリに向き合えたのは良かったと思いました。もっとユーザー数が増えたら機能追加等して皆さんが使いやすいアプリにどんどんバージョンアップしていけたらなと思います!もしご興味ありましたらインストールお願いします!ここまでお読みいただきありがとうございました!