クソアプリ検討会① 〜スマホを使った野球バットスイングシミュレーションアプリ〜

# はじめに
スマホを使った野球バットスイングシミュレーションアプリを思い付きましたので企画・設計からリリースまでの流れを検討しました。
![DALL·E 2024-05-05 07.56.55 - An advertisement image for a smartphone app simulating baseball bat swings, targeted at a Japanese audience. The image features a young Japanese man i.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/603047/47e3c91c-15fc-2882-61e7-2dd95ab9e49a.jpeg)

# 企画
### アプリの目的
このアプリは野球のバットスイングをシミュレーションすることで、ユーザーが野球の打撃練習を楽しく学べるようにすることを目的とします。
また、エンターテインメント要素を取り入れることで、幅広い年齢層に楽しんでもらうことも目指します。

## 主要機能
#### スイング検出
スマートフォンの加速度センサーとジャイロスコープを使用して、ユーザーのスイングの速度と角度を検出します。
#### 音声再生
スイングの品質に基づいて異なる音声を再生します。
最適なスイングで高音のクリアな音（カキーン）、不適切なスイングで低い鈍い音（ボコッ）を出します。
#### フィードバックと指導
スイングのデータを分析して、ユーザーに具体的な改善点やアドバイスを提供します。
#### スイング履歴の保存と分析
ユーザーのスイング履歴を保存し、進捗をグラフや統計で表示します。

## ユーザーインターフェース
#### メイン画面
スイングを開始するための「スイング開始」ボタンと、履歴やアドバイスを見るためのメニューを配置します。
#### フィードバック画面
スイングごとにフィードバックを表示し、何を改善すべきか具体的な指示を提供します。
#### 履歴と分析画面
スイングの履歴を日付ごとに見ることができ、進捗をグラフで確認できます。

## 使用技術
#### プラットフォーム
+ iOS
+ Android
#### プログラミング言語
+ Dart(フレームワーク：Flutter)
+ Python(フレームワーク：FastAPI)
#### データ管理
Firebase Firestore
#### センサー利用
加速度センサーとジャイロスコープのデータを活用します。

# 設計
### UI
#### 1. ホーム画面
- **スタートボタン**: このボタンをタップすると、スイングの録音が開始されます。
- **履歴閲覧ボタン**: 過去のスイングデータとフィードバックを閲覧できます。
- **設定ボタン**: アプリの設定（音量調整、ユーザー情報の編集など）を変更できます。

#### 2. スイング録音画面
- **インストラクション表示**: スイングを開始する前に、適切なフォームや注意点が表示されます。
- **「スイング開始」指示**: ユーザーがスイングを始めるための明確な指示。
- **実行中のフィードバック**: スイング中にリアルタイムで音声フィードバックが提供されます。

#### 3. フィードバック画面
- **スイング結果**: スイングの速度、角度、適切さに基づく結果が表示されます。
- **具体的な改善提案**: どの部分が良く、どう改善すべきかの具体的なアドバイス。
- **総合評価**: スイングの総合評価として星評価や数値評価を表示します。

#### 4. 履歴と分析画面
- **カレンダー形式**: どの日にどれだけのスイングが行われたかを確認できます。
- **進捗グラフ**: 時間の経過とともにスイングの改善が視覚的にわかるようなグラフ表示。
- **詳細分析**: 各スイングの詳細データとアドバイスの履歴。

#### 5. 設定画面
- **ユーザープロファイル編集**: 名前や年齢、体格情報などのユーザープロファイルを編集。
- **アプリ設定**: 音の設定、通知のオンオフ、その他のアプリに関する設定。

### UX
#### 1. ホーム画面
- **スタートボタン**: このボタンをタップすると、スイングの録音が開始されます。
- **履歴閲覧ボタン**: 過去のスイングデータとフィードバックを閲覧できます。
- **設定ボタン**: アプリの設定（音量調整、ユーザー情報の編集など）を変更できます。

#### 2. スイング録音画面
- **インストラクション表示**: スイングを開始する前に、適切なフォームや注意点が表示されます。
- **「スイング開始」指示**: ユーザーがスイングを始めるための明確な指示。
- **実行中のフィードバック**: スイング中にリアルタイムで音声フィードバックが提供されます。

#### 3. フィードバック画面
- **スイング結果**: スイングの速度、角度、適切さに基づく結果が表示されます。
- **具体的な改善提案**: どの部分が良く、どう改善すべきかの具体的なアドバイス。
- **総合評価**: スイングの総合評価として星評価や数値評価を表示します。

#### 4. 履歴と分析画面
- **カレンダー形式**: どの日にどれだけのスイングが行われたかを確認できます。
- **進捗グラフ**: 時間の経過とともにスイングの改善が視覚的にわかるようなグラフ表示。
- **詳細分析**: 各スイングの詳細データとアドバイスの履歴。

#### 5. 設定画面
- **ユーザープロファイル編集**: 名前や年齢、体格情報などのユーザープロファイルを編集。
- **アプリ設定**: 音の設定、通知のオンオフ、その他のアプリに関する設定。

### API
#### 1. スイングデータの取得と保存
- **エンドポイント**: `/api/swings`
- **メソッド**:
  - `POST`: 新しいスイングのデータをサーバーに送信し保存します。リクエストボディにはスイングの速度、角度、時間などのデータが含まれます。
  - `GET`: ユーザーのスイング履歴を取得します。クエリパラメータで特定の日付や期間を指定できます。

#### 2. スイング解析
- **エンドポイント**: `/api/swings/analyze`
- **メソッド**:
  - `POST`: 最新のスイングデータを解析し、改善点やフィードバックを返します。リクエストボディには最新のスイングデータが必要です。

#### 3. ユーザープロファイル管理
- **エンドポイント**: `/api/users/{userId}`
- **メソッド**:
  - `GET`: 指定されたユーザーIDのプロファイル情報を取得します。
  - `PUT`: ユーザーのプロファイル情報を更新します。リクエストボディには更新したい情報（名前、年齢など）が含まれます。

#### 4. プログレスと統計データ
- **エンドポイント**: `/api/stats/{userId}`
- **メソッド**:
  - `GET`: 指定されたユーザーの進捗と統計データを取得します。これにはスイングの改善履歴、使用頻度、平均スコアなどが含まれます。

#### 5. 設定
- **エンドポイント**: `/api/settings/{userId}`
- **メソッド**:
  - `GET`: ユーザーの設定を取得します。
  - `PUT`: ユーザーの設定を更新します。リクエストボディには変更したい設定（音の設定、通知設定など）が含まれます。

### テーブル設計
アプリの機能と要件に基づいて、Firestoreを使用したデータベース設計を提案します。FirestoreはNoSQLデータベースで、データはドキュメントとして保存され、ドキュメントはコレクションに整理されます。ここでは、主要なコレクションとそれぞれのドキュメント構造について説明します。

### コレクションとドキュメント設計
#### 1. Users コレクション
- **目的**: ユーザーのプロファイル情報を管理します。
- **ドキュメント構造**:
  - `userId` (string): ユーザーの一意識別子。
  - `name` (string): ユーザーの名前。
  - `age` (number): ユーザーの年齢。
  - `height` (number): ユーザーの身長。
  - `weight` (number): ユーザーの体重。

#### 2. Swings コレクション
- **目的**: ユーザーのスイングデータを保存します。
- **ドキュメント構造**:
  - `swingId` (string): スイングの一意識別子。
  - `userId` (string): ユーザーID。
  - `speed` (number): スイング速度。
  - `angle` (number): スイング角度。
  - `timestamp` (timestamp): スイングが行われた時間。
  - `feedback` (string): スイングのフィードバック。
  - `rating` (number): スイングの評価。

#### 3. Feedbacks コレクション
- **目的**: 各スイングに対する詳細なフィードバックを保存します。
- **ドキュメント構造**:
  - `feedbackId` (string): フィードバックの一意識別子。
  - `swingId` (string): 関連するスイングのID。
  - `details` (string): フィードバックの詳細。
  - `improvementTips` (string): 改善提案。

#### 4. Settings コレクション
- **目的**: ユーザーのアプリ設定を保存します。
- **ドキュメント構造**:
  - `userId` (string): ユーザーID。
  - `soundEnabled` (boolean): 音声フィードバックの有効/無効。
  - `notificationsEnabled` (boolean): 通知の有効/無効。

### セキュリティとアクセス制御
Firestoreのセキュリティルールを設定して、ユーザーデータの安全を確保します。各ユーザーが自分のデータのみにアクセスできるようにし、他のユーザーのデータにはアクセスできないように制限します。

### インデックス設定
Firestoreでは、頻繁にアクセスするクエリのパフォーマンスを向上させるためにインデックスを設定することが重要です。特に、ユーザーIDに基づくクエリや、時間範囲に基づくスイングデータのクエリにインデックスを設定します。


# 開発
## フロントエンド
Flutter(Dart)を使用したフロントエンドのコードの一部です
### ホーム画面
```dart:home.dart
import 'package:flutter/material.dart';

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('ホーム'),
        centerTitle: true,
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            ElevatedButton(
              onPressed: () {
                // スイング録音画面へ遷移
                Navigator.pushNamed(context, '/record');
              },
              child: Text('スイング開始'),
            ),
            SizedBox(height: 20),
            ElevatedButton(
              onPressed: () {
                // 履歴と分析画面へ遷移
                Navigator.pushNamed(context, '/history');
              },
              child: Text('履歴閲覧'),
            ),
            SizedBox(height: 20),
            ElevatedButton(
              onPressed: () {
                // 設定画面へ遷移
                Navigator.pushNamed(context, '/settings');
              },
              child: Text('設定'),
            ),
          ],
        ),
      ),
    );
  }
}
```

### スイング画面
```dart:swing.dart
import 'package:flutter/material.dart';
import 'package:audioplayers/audioplayers.dart';

class RecordPage extends StatefulWidget {
  @override
  _RecordPageState createState() => _RecordPageState();
}

class _RecordPageState extends State<RecordPage> {
  final AudioPlayer audioPlayer = AudioPlayer();

  Future<void> playSound() async {
    await audioPlayer.play(AssetSource('sound.mp3'));
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('スイング録音'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            ElevatedButton(
              onPressed: () async {
                // 音声を再生
                await playSound();
              },
              child: Text('録音開始'),
            ),
          ],
        ),
      ),
    );
  }
}
```

## サーバーサイド
Python(FastAPI)を使用したサーバーサイドのコードの一部です。
### ホーム画面
```python:home.py
from fastapi import APIRouter, Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer

router = APIRouter()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

@router.get("/home")
async def read_home_data(token: str = Depends(oauth2_scheme)):
    # ここでトークンを検証する
    if not token or token != "expected_token":
        raise HTTPException(status_code=401, detail="Unauthorized")
    # 認証成功時、ユーザーデータや最新のスイングサマリーを取得して返す
    return {"message": "User authenticated", "latest_summary": "Some data"}
```

### スイング画面
```python:swing.py
from fastapi import APIRouter, HTTPException, Body
from pydantic import BaseModel
from datetime import datetime

class SwingData(BaseModel):
    speed: float
    angle: float
    timestamp: datetime

router = APIRouter()

@router.post("/swing/record")
async def record_swing(swing_data: SwingData):
    # ここで受け取ったデータをFirestoreに保存
    # 保存成功時のロジック（サンプル）
    return {"message": "Swing recorded", "data": swing_data.dict()}

def analyze_swing(speed: float, angle: float) -> str:
    # スイングデータを分析し、フィードバックを生成するロジック
    if speed > 50 and angle > 45:
        return "Excellent swing!"
    else:
        return "Try again with more speed and better angle."

@router.post("/swing/feedback")
async def swing_feedback(swing_data: SwingData):
    feedback = analyze_swing(swing_data.speed, swing_data.angle)
    return {"feedback": feedback}
```

# テスト
## 単体テスト
### フロントエンド
flutter_testを使用してスイング画面で音声が鳴るかテストします
```dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:audioplayers/audioplayers.dart';
import 'package:mockito/mockito.dart';
import 'package:your_project_path/pages/record_page.dart';  // 適切なパスに置き換えてください。

// Mockクラスの作成
class MockAudioPlayer extends Mock implements AudioPlayer {}

void main() {
  group('RecordPage Tests', () {
    testWidgets('Play Sound Button triggers audio play', (WidgetTester tester) async {
      // Mockオブジェクトの作成
      final mockAudioPlayer = MockAudioPlayer();
      // RecordPageを音声プレイヤーのモックとともにテスト環境に構築
      await tester.pumpWidget(MaterialApp(
        home: RecordPage(audioPlayer: mockAudioPlayer),
      ));

      // ボタンを見つける
      final playButton = find.byType(ElevatedButton);
      expect(playButton, findsOneWidget);

      // ボタンをタップ
      await tester.tap(playButton);
      await tester.pump();

      // 音声再生メソッドが呼ばれたか確認
      verify(mockAudioPlayer.play(any)).called(1);
    });
  });
}
```

### サーバーサイド
pyestを使用してAPIのエンドポイントが機能するかテストします。
```python
from fastapi.testclient import TestClient
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from datetime import datetime

# APIのセットアップ
app = FastAPI()

# リクエストデータのモデル
class SwingData(BaseModel):
    speed: float
    angle: float
    timestamp: datetime

# APIエンドポイント
@app.post("/swing/record")
async def record_swing(swing_data: SwingData):
    return {"message": "Swing recorded", "data": swing_data.dict()}

# テストクライアントの設定
client = TestClient(app)

# テスト関数
def test_record_swing():
    response = client.post(
        "/swing/record",
        json={
            "speed": 30.5,
            "angle": 45,
            "timestamp": "2021-01-01T12:00:00"
        }
    )
    assert response.status_code == 200
    assert response.json() == {
        "message": "Swing recorded",
        "data": {
            "speed": 30.5,
            "angle": 45,
            "timestamp": "2021-01-01T12:00:00"
        }
    }
```

## 結合テスト
省略します

## 総合テスト
省略します

# デプロイ
### CICD
GitHub Actionを使用してパイプラインを構築します
```yml
name: API CI/CD

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v2
    - uses: actions/setup-python@v2
      with:
        python-version: '3.12.2'
    - run: pip install -r requirements.txt
    - run: pytest
```

### アプリストアへの申請
Android(Google Play Store)向けですが、以下のQiitaの記事を見つけましたので参考にしてください。
https://qiita.com/umi_mori/items/201004a5bdacda6237e2

# メンテナンス
ユーザーからのフィードバックを基に、アプリを定期的にアップデートしていきます。

# 備考
スイングした時に鳴る「カキーン」という音を鳴らす方法について、Flutterでは以下の方法があります。
+ 1. ローカルに保存された音声ファイルを再生する
+ 2. サーバーから音声ファイルをダウンロードして再生する

### 1. ローカルに保存された音声ファイルを再生する
#### 利点
音声ファイルがアプリに組み込まれるため、再生時にネットワークリクエストが発生しません。これにより、ネットワークの使用量を節約でき、Firebaseの帯域幅制限内での運用が容易になります。
再生が即時に行われるため、ユーザーエクスペリエンスが向上します。
#### 欠点
アプリのサイズが増加します。これは特に、多くの異なる音声ファイルをアプリに組み込む場合に顕著です。
アプリ更新時に音声ファイルを変更する必要がある場合、新しいバージョンをリリースしなければならないため、更新がやや不便になります。

### 2. サーバーから音声ファイルをダウンロードして再生する方法
#### 利点
音声ファイルをアプリの更新とは独立して更新できるため、柔軟に対応可能です。
アプリの初期ダウンロードサイズを小さく保つことができます。
#### 欠点
音声ファイルのダウンロードにはネットワークを使用するため、Firebaseの無料枠内での帯域幅の制限を超える可能性があります。
ダウンロードに時間がかかる場合があり、ユーザーエクスペリエンスに影響を与える可能性があります。

今回は低コストでの運用のためにーカルに保存された音声ファイルを再生する方法を選択しました。