メイン画面を作る。Laravelを使ってダイエットアプリを作るカロリーバスターズ

次はメインのページを作成していく。


イメージはこれ
![スクリーンショット 2025-02-02 11.26.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3978958/c26e2eaf-a81a-8658-ad5e-29dc06454bab.png)

摂取カロリー、消費カロリーを保存していくrecordsを作成。

    php artisan make:migration create_records_table

マイグレーションファイルの中身を修正

    use Illuminate\Database\Migrations\Migration;
    use Illuminate\Database\Schema\Blueprint;
    use Illuminate\Support\Facades\Schema;
    
        return new class extends Migration {
        /**
         * Run the migrations.
         */
        public function up(): void
        {
            Schema::create('records', function (Blueprint $table) {
                $table->id();
                $table->unsignedBigInteger('user_id'); // ユーザーID
                $table->date('date'); // 記録日
                $table->float('intake_calories'); // 摂取カロリー
                $table->float('burned_calories'); // 消費カロリー
                $table->timestamps();
    
                // 外部キー設定
                $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
            });
        }
    
        /**
         * Reverse the migrations.
         */
        public function down(): void
        {
            Schema::dropIfExists('records');
        }
    };


今更ながら

public function down(): void
    {
        Schema::dropIfExists('records');
    }

はロールバックした時の処理の内容。
dropIfExistsはテーブルの存在があればテーブルを削除する処理。

### モデルの作成

    php artisan make:model Record

モデルファイルの中の修正

    namespace App\Models;
    
    use Illuminate\Database\Eloquent\Factories\HasFactory;
    use Illuminate\Database\Eloquent\Model;
    
    class Record extends Model
    {
        use HasFactory;
    
        protected $fillable = [
            'user_id',
            'date',
            'intake_calories',
            'burned_calories',
        ];
    
        // ユーザーとのリレーション
        public function user()
        {
            return $this->belongsTo(User::class);
        }
    }

復習
protected $fillableはカラムに保存する物を指定するもの。
この中に書かれていないカラムは保存できないようにする。

belongsTo(User::class)は多対1を示す。

use HasFactory;はテスト用のダミーデータを自動で作るときに使う。
けど使ったことないな。

tinkerを使って実行すれば行けるみたい。
tinkerはLaravel用のターミナル。

Laravelのコードを実行したり動作チェックをできる。
tinkerは全然使ったことないな。

### コントローラーの作成

    php artisan make:controller RecordController

中身を修正

    namespace App\Http\Controllers;
    
    use App\Models\Record;
    use Illuminate\Http\Request;
    use Illuminate\Support\Facades\Auth;
    
    class RecordController extends Controller
    {
        /**
         * 記録を保存する。
         */
        public function store(Request $request)
        {
            $request->validate([
                'date' => ['required', 'date'],
                'intake_calories' => ['required', 'numeric', 'min:0'],
                'burned_calories' => ['required', 'numeric', 'min:0'],
            ]);
    
            Record::create([
                'user_id' => Auth::id(),
                'date' => $request->date,
                'intake_calories' => $request->intake_calories,
                'burned_calories' => $request->burned_calories,
            ]);
    
            return redirect()->back()->with('status', 'カロリー記録を保存しました！');
        }
    }

### Viewの作成

    @extends('layouts.app')
    
    @section('content')
    <div class="container">
        <h1>カロリー記録</h1>
    
        @if (session('status'))
            <div class="alert alert-success">
                {{ session('status') }}
            </div>
        @endif
    
        <form method="POST" action="{{ route('record.store') }}">
            @csrf
            <div>
                <label for="date">日付</label>
                <input type="date" id="date" name="date" value="{{ now()->toDateString() }}" required>
            </div>
    
            <div>
                <label for="intake_calories">摂取カロリー</label>
                <input type="number" id="intake_calories" name="intake_calories" required>
            </div>
    
            <div>
                <label for="burned_calories">消費カロリー</label>
                <input type="number" id="burned_calories" name="burned_calories" required>
            </div>
    
            <button type="submit">記録を保存</button>
        </form>
    </div>
    @endsection


@extends()
共通レイアウトのテンプレートの読み込み

@section('content')
@yield(content)の位置に配置する。
なんとなくbodyの中身というイメージ

    @if (session('status'))
        <div class="alert alert-success">
            {{ session('status') }}
        </div>
    @endif

もしsessionにstatusがある場合。
statusの内容を表示する。

### ルートの設定

    use App\Http\Controllers\RecordController;
    
    Route::middleware('auth')->group(function () {
        Route::post('/record', [RecordController::class, 'store'])->name('record.store');
    });

調べた。
    use App\Http\Controllers\RecordController;
これがないとだめなのかどうかと。
そうしたら全然問題なかった。
ただ、パスを全て書かなくてはいけなくなる。

\App\Http\Controllers\RecordController::class
記述的にはどこから取ってるか分かるから初見の人はわかりやすいんじゃないかと思った。

    Route::middleware('auth')->

コントローラーに届く前の認証を確認。
ログインしていたらそのまま処理する。
していなかったらリダイレクトされる。

    group(function () ｛｝）

はこの中のものを一括で適用させるよという意味。

本来は単体適用させる場合、

    Route::post('/record' , [RecordController::class, 'store'])->middleware('auth')->name('record.store');

これをそれぞれ書いていく。
group functionを利用してカッコでまとめるイメージだ。

middleware('')の''内は他にも下記があるみたいだ。
guest　未ログインのユーザーのみアクセス可能
verified　メール認証済み化チェック
auth.basic　HTTP Basic認証


表示するといつもどおりのエラー
とここで気がついたが、
x-app-layoutで作っていたので

$slotが定義されていないよ！
という内容だった。

なので@sectionなどを全て変更していく。
GPTはこれで作っていくほうが多いのかな？
そっちに引っ張られている気がする。

そして修正したのが下記

    <x-app-layout>
        <x-slot name="header">
            <h2>
                {{ __('カロリー記録') }}
            </h2>
        </x-slot>
    
        <div>
            <h3>カロリーを記録しましょう</h3>
    
            @if (session('status'))
                <div>
                    {{ session('status') }}
                </div>
            @endif
    
            <form method="POST" action="{{ route('record.store') }}">
                @csrf
                <div>
                    <label for="date">日付</label>
                    <input type="date" id="date" name="date" value="{{ now()->toDateString() }}" required>
                </div>
    
                <div>
                    <label for="intake_calories">摂取カロリー</label>
                    <input type="number" id="intake_calories" name="intake_calories" required>
                </div>
    
                <div>
                    <label for="burned_calories">消費カロリー</label>
                    <input type="number" id="burned_calories" name="burned_calories" required>
                </div>
    
                <button type="submit">記録を保存</button>
            </form>
        </div>
    </x-app-layout>

これで表示されるようになった。

ここで動作確認。
同じ日付で何度も登録できるようになっていた。
今考えているロジックだと一つのレコードに対して、基礎代謝等を計算するので
1日1回にしたいところ。
そのユニーク制限をつけていく。

### マイグレーションの重ねがけ
最初の頭の中ではマイグレーションファイルを修正して

    php artisan migrate

を行えば良いと思っていたが、現実ではマイグレーションファイルを新しく作って条件を足していくのが正解だった。

理由としては一度マイグレーションされたファイルはファイル名が変わらない限り読み込まれない。

既存のファイルを変えて一人でリフレッシュしてマイグレーションしていくとチーム開発を行う際に他のメンバーも削除する手間が発生する。
そして中身のDBもなくなってしまうため労力がかかってしまう。

だから上乗せ上乗せでルールを足していくらしい。

    php artisan make:migration add_unique_constraint_to_records_table

ファイルの中身

    use Illuminate\Database\Migrations\Migration;
    use Illuminate\Database\Schema\Blueprint;
    use Illuminate\Support\Facades\Schema;
    
    return new class extends Migration {
        /**
         * Run the migrations.
         */
        public function up(): void
        {
            Schema::table('records', function (Blueprint $table) {
                // user_id と date の組み合わせを一意にする
                $table->unique(['user_id', 'date'], 'user_date_unique');
            });
        }
    
        /**
         * Reverse the migrations.
         */
        public function down(): void
        {
            Schema::table('records', function (Blueprint $table) {
                $table->dropUnique('user_date_unique');
            });
        }
    };

unique(['user_id', 'date']
ここでuser_idとdateの組み合わせを単一にする制限をかける。

    php artisan migrate

これでOK。

エラーメッセージを表示するためにコントローラーも修正。

    public function store(Request $request)
    {
        $request->validate([
            'date' => [
                'required', 
                'date', 
                // ユニーク制約を確認するルールを追加
                function ($attribute, $value, $fail) {
                    $exists = Record::where('user_id', Auth::id())
                                    ->where('date', $value)
                                    ->exists();
                    if ($exists) {
                        $fail('この日付の記録は既に存在します。');
                    }
                },
            ],
            'intake_calories' => ['required', 'numeric', 'min:0'],
            'burned_calories' => ['required', 'numeric', 'min:0'],
        ]);
    
        Record::create([
            'user_id' => Auth::id(),
            'date' => $request->date,
            'intake_calories' => $request->intake_calories,
            'burned_calories' => $request->burned_calories,
        ]);
    
        return redirect()->back()->with('status', 'カロリー記録を保存しました！');
    }

validateは入力必須で日付として入力されているかチェック。
function($attribute, $value,$fail)
はLaravelに用意されている引数。

$attribute	バリデーション対象のフィールド名

$value	ユーザーが入力した値

$fail	バリデーションエラーを発生させる関数

これらを呼び出し時に利用する。

    $exists = Record::where('user_id', Auth::id())
                    ->where('date', $value)
                    ->exists();

変数を用意、
ログインしているユーザーと同じuser_idを検索。
$value変数に入っているdateと同じものがないか検索。
exists()に入れる。

    
    if ($exists) {
                        $fail('この日付の記録は既に存在します。');
                    }

$existsの中身はtrueかfalseで返される。
もしtrueならバリデーションエラーを発生させメッセージを出す。

フォームにも

    @if ($errors->any())
        <div>
            <ul>
                @foreach ($errors->all() as $error)
                    <li>{{ $error }}</li>
                @endforeach
            </ul>
        </div>
    @endif

を追加してメッセージが表示されるようにする。

    $errors->any()

エラーが一つでもあるか確認。
あるなら以下を実行。

2つ以上ある可能性を想定して、テーブル化して表示するようにしている。


これでDBとアプリケーション側の両方からバリデーションで制約をかけることに成功した。

### 目標体重の表示

先程の入力画面に目標のカロリー数も表示したい。
ので計算式をDashboardControllerに入れていく。
このコントローラーは用意されていないので

いつも通り

    php artisan make:controller DashboardController

で作成をする。


    namespace App\Http\Controllers;
    
    use App\Models\Record;
    use Illuminate\Support\Facades\Auth;
    
    class DashboardController extends Controller
    {
        public function index()
        {
            $user = Auth::user();
    
            // 必要消費カロリーの計算
            $totalCalories = ($user->initial_weight - $user->target_weight) * 7200;
    
            // 基礎代謝の計算
            if ($user->gender === 'male') {
                $bmr = 88.362 + (13.397 * $user->initial_weight) + (4.799 * $user->height) - (5.677 * $user->age);
            } else {
                $bmr = 447.593 + (9.247 * $user->initial_weight) + (3.098 * $user->height) - (4.330 * $user->age);
            }
    
            // 現在のカロリー収支
            $calorieRecords = Record::where('user_id', $user->id)->get();
            $calorieBalance = $calorieRecords->sum(function ($record) use ($bmr) {
                return $bmr + $record->burned_calories - $record->intake_calories;
            });
    
            // 残りカロリー
            $remainingCalories = $totalCalories - $calorieBalance;
    
            return view('dashboard', compact('totalCalories', 'bmr', 'calorieBalance', 'remainingCalories'));
        }
    }

あまり複雑なことはしていない。indexの中に計算式を入れているだけだ。

ルート設定(web.php)にも下記を追加。

     Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
    });


それをview側に表示されるよう調整。

    <x-app-layout>
        <x-slot name="header">
            <h2>目標管理</h2>
        </x-slot>
    
        <div>
            <h3>目標管理情報</h3>
    
            <p>必要消費カロリー: {{ number_format($totalCalories) }} kcal</p>
            <p>基礎代謝: {{ number_format($bmr) }} kcal</p>
            <p>現在の総カロリー収支: {{ number_format($calorieBalance) }} kcal</p>
            <p>目標達成までの残りカロリー: {{ number_format($remainingCalories) }} kcal</p>
        </div>
    </x-app-layout>

number_formatは桁区切りの関数。
入れることで1,000とわかりやすくしてくれる。

これでひとまずの表示項目の完成だ。
MVCの理解が進んできた。
後は調べながら行っていけばできそう。
な気がしているが、
見栄えが本当に悪い笑

今日はここまで。