Zenn
👏

【Flutter】Laravel TODOアプリのAPIとの連携

2025/03/29に公開

はじめに

前回作成した Laravel TODO リスト管理 API と連携する Flutter アプリの実装方法を解説します。API 連携の部分だけに絞って記載するため、余分なコードは省略しています。

今回実装したコードです。

https://github.com/muranakar/todo_laravel

必要なパッケージ

まず、API 連携に必要なパッケージをインストールします。

flutter pub add dio freezed freezed_annotation json_annotation
flutter pub add --dev build_runner json_serializable
  • dio: HTTP 通信用のパッケージ
  • freezed: イミュータブルなデータクラス生成
  • json_annotation: JSON シリアライズ/デシリアライズ

モデルクラスの実装

API から受け取るデータを Dart オブジェクトに変換するためのモデルクラスを実装します。

実装例
lib/models/todo.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:json_annotation/json_annotation.dart';

part 'todo.freezed.dart';
part 'todo.g.dart';


class Todo with _$Todo {
  const factory Todo({
    required int id,
    required String title,
    String? description,
    (name: 'priority') required PriorityInfo priority,
    (name: 'due_date') String? dueDate,
    required bool completed,
    (name: 'is_overdue') required bool isOverdue,
    (name: 'created_at') required String createdAt,
    (name: 'updated_at') required String updatedAt,
  }) = _Todo;

  factory Todo.fromJson(Map<String, dynamic> json) => _$TodoFromJson(json);
}


class PriorityInfo with _$PriorityInfo {
  const factory PriorityInfo({
    required int value,
    required String label,
  }) = _PriorityInfo;

  factory PriorityInfo.fromJson(Map<String, dynamic> json) => _$PriorityInfoFromJson(json);
}

// データ転送オブジェクト

class TodoCreateDto with _$TodoCreateDto {
  const factory TodoCreateDto({
    required String title,
    String? description,
    int? priority,
    (name: 'due_date') String? dueDate,
    bool? completed,
  }) = _TodoCreateDto;

  factory TodoCreateDto.fromJson(Map<String, dynamic> json) => _$TodoCreateDtoFromJson(json);
}


class TodoUpdateDto with _$TodoUpdateDto {
  const factory TodoUpdateDto({
    String? title,
    String? description,
    int? priority,
    (name: 'due_date') String? dueDate,
    bool? completed,
  }) = _TodoUpdateDto;

  factory TodoUpdateDto.fromJson(Map<String, dynamic> json) => _$TodoUpdateDtoFromJson(json);
}

コード生成を実行します:

flutter pub run build_runner build

API リポジトリの実装

API との通信を行うリポジトリクラスを実装します。

lib/repositories/todo_repository.dart
import 'package:dio/dio.dart';
import '../models/todo.dart';

class TodoRepository {
  final String baseUrl = 'http://127.0.0.1:8000/api/v1';
  late final Dio _dio;

  TodoRepository() {
    _dio = Dio(BaseOptions(
      baseUrl: baseUrl,
      headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/json',
      },
      connectTimeout: const Duration(seconds: 5),
      receiveTimeout: const Duration(seconds: 3),
    ));

    _dio.interceptors.add(LogInterceptor(
      requestBody: true,
      responseBody: true,
    ));
  }

  // TODOリスト取得
  Future<List<Todo>> fetchTodos({
    bool? completed,
    int? priority,
    String? sortBy,
    String? sortDirection,
  }) async {
    try {
      final Map<String, dynamic> queryParams = {};
      if (completed != null) queryParams['completed'] = completed;
      if (priority != null) queryParams['priority'] = priority;
      if (sortBy != null) queryParams['sort_by'] = sortBy;
      if (sortDirection != null) queryParams['sort_direction'] = sortDirection;

      final response = await _dio.get('/todos', queryParameters: queryParams);
      final List<dynamic> data = response.data['data'];
      return data.map((json) => Todo.fromJson(json)).toList();
    } catch (e) {
      throw _handleError(e);
    }
  }

  // 単一TODO取得
  Future<Todo> fetchTodo(int id) async {
    try {
      final response = await _dio.get('/todos/$id');
      return Todo.fromJson(response.data['data']);
    } catch (e) {
      throw _handleError(e);
    }
  }

  // TODO作成
  Future<Todo> createTodo(TodoCreateDto todo) async {
    try {
      final response = await _dio.post(
        '/todos',
        data: todo.toJson(),
      );
      return Todo.fromJson(response.data['data']);
    } catch (e) {
      throw _handleError(e);
    }
  }

  // TODO更新
  Future<Todo> updateTodo(int id, TodoUpdateDto todo) async {
    try {
      final response = await _dio.put(
        '/todos/$id',
        data: todo.toJson(),
      );
      return Todo.fromJson(response.data['data']);
    } catch (e) {
      throw _handleError(e);
    }
  }

  // TODO削除
  Future<void> deleteTodo(int id) async {
    try {
      await _dio.delete('/todos/$id');
    } catch (e) {
      throw _handleError(e);
    }
  }

  // 完了状態切り替え
  Future<Todo> toggleComplete(int id) async {
    try {
      final response = await _dio.patch('/todos/$id/toggle-complete');
      return Todo.fromJson(response.data['data']);
    } catch (e) {
      throw _handleError(e);
    }
  }

  // エラーハンドリング
  Exception _handleError(dynamic error) {
    if (error is DioException) {
      if (error.response != null) {
        // バリデーションエラー処理
        if (error.response?.statusCode == 422 &&
            error.response?.data['errors'] != null) {
          final errorMessages = error.response?.data['errors'].entries
              .map((e) => '${e.key}: ${e.value.join(', ')}')
              .join('\n');
          return Exception('入力データエラー:\n$errorMessages');
        }
        return Exception(
            'APIエラー: ${error.response?.statusCode} ${error.response?.statusMessage}');
      } else if (error.type == DioExceptionType.connectionTimeout) {
        return Exception('接続タイムアウト: サーバーに接続できませんでした');
      } else if (error.type == DioExceptionType.receiveTimeout) {
        return Exception('受信タイムアウト: サーバーからの応答が遅すぎます');
      } else if (error.type == DioExceptionType.connectionError) {
        return Exception('接続エラー: インターネット接続を確認してください');
      }
      return Exception('ネットワークエラー: ${error.message}');
    }
    return Exception('不明なエラー: $error');
  }
}

API 呼び出し解説

1. リポジトリの初期化

final repository = TodoRepository();

2. TODO リストの取得

// 全てのTODOを取得
final todos = await repository.fetchTodos();

// 未完了のTODOのみ取得
final pendingTodos = await repository.fetchTodos(completed: false);

// 優先度高のTODOを期限日順で取得
final highPriorityTodos = await repository.fetchTodos(
  priority: 3,
  sortBy: 'due_date',
  sortDirection: 'asc',
);
細かい呼び出し

3. TODO 詳細の取得

// ID指定でTODOを取得
final todo = await repository.fetchTodo(1);
print('取得したTODO: ${todo.title}, 優先度: ${todo.priority.label}');

4. 新規 TODO の作成

// 新規TODO作成
final newTodo = TodoCreateDto(
  title: 'Flutterアプリ開発',
  description: 'TODOリストアプリを作成する',
  priority: 2,
  dueDate: '2025-04-10',
);

final createdTodo = await repository.createTodo(newTodo);
print('作成されたTODO ID: ${createdTodo.id}');

5. TODO の更新

// 既存TODOの更新
final todoUpdate = TodoUpdateDto(
  title: '修正:Flutterアプリ開発',
  priority: 3,
  dueDate: '2025-04-05',
);

final updatedTodo = await repository.updateTodo(1, todoUpdate);
print('更新されたTODO: ${updatedTodo.title}');

6. 完了状態の切り替え

// 完了状態の切り替え
final toggledTodo = await repository.toggleComplete(1);
print('完了状態: ${toggledTodo.completed ? "完了" : "未完了"}');

7. TODO の削除

// TODOの削除
await repository.deleteTodo(1);
print('TODOを削除しました');

8. エラーハンドリング

try {
  final todos = await repository.fetchTodos();
  // 成功処理
} catch (e) {
  print('エラーが発生しました: $e');
  // エラー表示やリトライ処理
}

実装のポイント

1. API クライアント設定

_dio = Dio(BaseOptions(
  baseUrl: baseUrl,
  headers: {
    'Content-Type': 'application/json',
    'Accept': 'application/json',
  },
  connectTimeout: const Duration(seconds: 5),
  receiveTimeout: const Duration(seconds: 3),
));
  • ベース URL: 127.0.0.1または10.0.2.2を使用
    iOS Android によって、変更の必要があります。
  • ヘッダー: JSON データの送受信を指定
  • タイムアウト: 接続遅延対策

2. デバッグログ設定

_dio.interceptors.add(LogInterceptor(
  requestBody: true,
  responseBody: true,
));

開発中に通信内容を確認できるインターセプターを設定。

通信内容の詳細ログ

以下のような詳細な通信内容のログを出力が可能。便利。

flutter: **_ Request _**
flutter: uri: http://127.0.0.1:8000/api/v1/todos
flutter: method: GET
flutter: responseType: ResponseType.json
flutter: followRedirects: true
flutter: persistentConnection: true
flutter: connectTimeout: 0:00:05.000000
flutter: sendTimeout: null
flutter: receiveTimeout: 0:00:03.000000
flutter: receiveDataWhenStatusError: true
flutter: extra: {}
flutter: headers:
flutter: Content-Type: application/json
flutter: Accept: application/json
flutter: data:
flutter: null
flutter:
flutter: **_ Response _**
flutter: uri: http://127.0.0.1:8000/api/v1/todos
flutter: statusCode: 200
flutter: headers:
flutter: connection: close
flutter: x-powered-by: PHP/8.4.4
flutter: cache-control: no-cache, private
flutter: date: Wed, 12 Mar 2025 07:35:38 GMT
flutter: access-control-allow-origin: \*
flutter: host: 127.0.0.1:8000
flutter: content-type: application/json
flutter: Response Text:
flutter: {"data":[{"id":14,"title":"a","description":"a","priority":{"value":1,"label":"低"},"due_date":null,"completed":false,"is_overdue":false,"created_at":"2025-03-12 07:35:38","updated_at":"2025-03-12 07:35:38"},{"id":13,"title":"あ","description":"あ","priority":{"value":1,"label":"低"},"due_date":null,"completed":false,"is_overdue":false,"created_at":"2025-03-12 07:35:14","updated_at":"2025-03-12 07:35:19"},{"id":3,"title":"Laravel の勉強","description":"API の作成方法を学ぶ","priority":{"value":2,"label":"中"},"due_date":"2025-04-15","completed":false,"is_overdue":false,"created_at":"2025-03-12 03:01:52","updated_at":"2025-03-12 07:35:29"},{"id":2,"title":"Laravel の勉強","description":"API の作成方法を学ぶ","priority":{"value":2,"label":"中"},"due_date":"2025-04-15","completed":false,"is_overdue":false,"created_at":"2025-03-12 02:51:13","updated_at":"2025-03-12 07:28:48"},{"id":1,"title":"Laravel の勉強","description":"API の作成方法を学ぶ","priority":{<…>

3. データ変換処理

final List<dynamic> data = response.data['data'];
return data.map((json) => Todo.fromJson(json)).toList();

API のレスポンスから Dart のオブジェクトに変換する処理。

4. エラーハンドリング

if (error.response?.statusCode == 422 && error.response?.data['errors'] != null) {
  final errorMessages = error.response?.data['errors'].entries
      .map((e) => '${e.key}: ${e.value.join(', ')}')
      .join('\n');
  return Exception('入力データエラー:\n$errorMessages');
}

バリデーションエラー(422)を適切に処理。

余談)

今回、フロント側で 500 エラーのみが返却され、詳細なエラー内容がわからないことがありました。その際は Laravel のログを確認するとエラーの詳細内容がわかりました。Laravel 側のstorage/logs/laravel.log を確認すると、以下のエラー出力を認めました。

[previous exception] [object] (PDOException(code: 23502): SQLSTATE[23502]: Not null violation: 7 ERROR:  null value in column \"completed\" of relation \"todos\" violates not-null constraint
DETAIL:  Failing row contains (12, あ, あ, 1, null, null, 2025-03-12 07:34:05, 2025-03-12 07:34:05).

フロント側で Todo 作成時に completed が null であったため、Todo に false の初期値を設定することによりエラーの修正ができました。

まとめ

Laravel バックエンドと Flutter フロントエンドの連携には、以下のポイントが重要です:

  1. Dio などの高機能 HTTP クライアントの活用
  2. Freezed を使用したモデルクラスの実装
  3. リポジトリパターンでデータアクセスを分離
  4. 適切なエラーハンドリングでユーザー体験を向上

これらを実装することで、Laravel バックエンドとスムーズに連携する Flutter アプリを構築できます。

Discussion

ログインするとコメントできます