👏
【Flutter】Laravel TODOアプリのAPIとの連携
はじめに
前回作成した Laravel TODO リスト管理 API と連携する Flutter アプリの実装方法を解説します。API 連携の部分だけに絞って記載するため、余分なコードは省略しています。
今回実装したコードです。
必要なパッケージ
まず、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 フロントエンドの連携には、以下のポイントが重要です:
- Dio などの高機能 HTTP クライアントの活用
- Freezed を使用したモデルクラスの実装
- リポジトリパターンでデータアクセスを分離
- 適切なエラーハンドリングでユーザー体験を向上
これらを実装することで、Laravel バックエンドとスムーズに連携する Flutter アプリを構築できます。
Discussion