Flutter Web でフロントエンドを構築する - アプリ構造と API 通信
📝 はじめに
この記事は
SwitchBot + FastAPI + Flutter で家庭の電力消費を可視化するWebアプリ構築(その3) #Python - Qiita
の続きになります。
この記事で書くこと
- Flutter Web でのアプリ構造設計
- バックエンド API との HTTP 通信実装
- 環境別エンドポイント切り替え
対象読者
- Flutter で Web アプリを作りたい方
- Dart での HTTP 通信を学びたい方
- Flutter のアプリ構造設計に興味がある方
前提
- Flutter / Dart の基本知識
- REST API の基本概念
🎯 背景・動機
なぜ Flutter Web を選んだのか
課題
電力データを時系列グラフで可視化し、デバイス別に表示したい。
React/Vue ではなく Flutter を選んだ理由
| 項目 | Flutter Web | React/Vue.js |
|---|---|---|
| 言語 | Dart | JavaScript |
| モバイル対応 | 同一コード | 別途必要 |
| Material Design | 標準搭載 | ライブラリ必要 |
| グラフ | fl_chart | Chart.js 等 |
将来的なモバイルアプリ展開を見据えて Flutter を選択しました。
🛠️ 手順/解説
プロジェクト構成
frontend/
├── lib/
│ ├── main.dart # アプリエントリーポイント
│ ├── chart_page.dart # グラフ表示ページ
│ └── supabase_service.dart # API 通信サービス
├── web/
│ └── index.html # HTML エントリーポイント
└── pubspec.yaml # 依存関係
依存パッケージ
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
fl_chart: ^0.68.0 # グラフライブラリ
http: ^1.1.0 # HTTP 通信
アプリ構造
MyApp (MaterialApp)
└── MyHomePage (BottomNavigationBar)
├── HomeWidget # 接続状態表示
├── ChartPage # グラフ表示
└── SettingsWidget # 設定
ナビゲーションの実装
class _MyHomePageState extends State<MyHomePage> {
int _selectedIndex = 0;
final List<Widget> _pages = [
HomeWidget(),
ChartPage(),
SettingsWidget(),
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('SwitchBot Power Monitor'),
backgroundColor: Colors.blue.shade700,
),
body: _pages[_selectedIndex],
bottomNavigationBar: BottomNavigationBar(
currentIndex: _selectedIndex,
onTap: (index) {
setState(() {
_selectedIndex = index;
});
},
items: [
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'ホーム'),
BottomNavigationBarItem(icon: Icon(Icons.bar_chart), label: 'グラフ'),
BottomNavigationBarItem(icon: Icon(Icons.settings), label: '設定'),
],
),
);
}
}
API 通信サービスの実装
環境別エンドポイント切り替え
import 'package:flutter/foundation.dart';
class SupabaseService {
static String get baseApiUrl {
if (kDebugMode && Uri.base.host == 'localhost') {
// Dev Container 環境
return 'http://localhost:8000';
} else if (kDebugMode) {
// その他のデバッグ環境
return 'http://${Uri.base.host}:8000';
} else {
// 本番環境
return 'http://localhost:8000'; // 実際の本番 URL に置換
}
}
}
ポイント
-
kDebugMode: デバッグビルドかどうか -
Uri.base.host: 現在のホスト名
HTTP リクエストの基本実装
import 'dart:convert';
import 'package:http/http.dart' as http;
static Future<List<Map<String, dynamic>>> fetchLatestPowerData({
int limit = 10
}) async {
try {
final response = await http.get(
Uri.parse('$baseApiUrl/data/latest?limit=$limit'),
headers: {'Content-Type': 'application/json'},
).timeout(Duration(seconds: 10));
if (response.statusCode == 200) {
final jsonData = json.decode(response.body);
return List<Map<String, dynamic>>.from(jsonData['data']);
}
} catch (e) {
print('Error: $e');
}
return [];
}
タイムアウト設定
-
.timeout(Duration(seconds: 10))で無限待機を防止
API 通信メソッド一覧
// デバイス一覧を取得
static Future<List<Map<String, dynamic>>> fetchDevices() async {
final response = await http.get(
Uri.parse('$baseApiUrl/devices'),
headers: {'Content-Type': 'application/json'},
).timeout(Duration(seconds: 10));
// ...
}
// 接続テスト
static Future<bool> testApiConnection() async {
try {
final response = await http.get(
Uri.parse('$baseApiUrl/'),
).timeout(Duration(seconds: 5));
return response.statusCode == 200;
} catch (e) {
return false;
}
}
// ヘルスチェック
static Future<Map<String, dynamic>?> getHealthStatus() async {
final response = await http.get(
Uri.parse('$baseApiUrl/health'),
).timeout(Duration(seconds: 10));
// ...
}
ホーム画面の実装
class HomeWidget extends StatefulWidget {
@override
_HomeWidgetState createState() => _HomeWidgetState();
}
class _HomeWidgetState extends State<HomeWidget> {
bool _isTestingConnection = false;
bool? _connectionStatus;
Map<String, dynamic>? _healthData;
Future<void> _testConnection() async {
setState(() => _isTestingConnection = true);
final isConnected = await SupabaseService.testApiConnection();
final healthData = await SupabaseService.getHealthStatus();
setState(() {
_connectionStatus = isConnected;
_healthData = healthData;
_isTestingConnection = false;
});
}
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.electric_bolt, size: 80, color: Colors.blue.shade700),
SizedBox(height: 20),
Text('API: ${SupabaseService.baseApiUrl}'),
SizedBox(height: 20),
ElevatedButton(
onPressed: _isTestingConnection ? null : _testConnection,
child: _isTestingConnection
? CircularProgressIndicator()
: Text('接続テスト'),
),
if (_connectionStatus != null)
Text(
_connectionStatus! ? '✅ 接続成功' : '❌ 接続失敗',
style: TextStyle(
color: _connectionStatus! ? Colors.green : Colors.red,
),
),
],
),
);
}
}
ローディング状態の管理
class _ChartPageState extends State<ChartPage> {
bool isLoading = true;
List<Map<String, dynamic>> powerData = [];
@override
void initState() {
super.initState();
loadPowerData();
}
Future<void> loadPowerData() async {
setState(() => isLoading = true);
final data = await SupabaseService.fetchLatestPowerData(limit: 100);
setState(() {
powerData = data;
isLoading = false;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: isLoading
? Center(child: CircularProgressIndicator())
: _buildContent(),
floatingActionButton: FloatingActionButton(
onPressed: loadPowerData,
child: Icon(Icons.refresh),
),
);
}
}
📊 実行結果 & コツ
開発コマンド
cd frontend
flutter run -d chrome
本番ビルド
flutter build web --release
# 成果物: build/web/
つまずきポイントと回避策
つまずき1: CORS エラーでデータが取得できない
回避策: バックエンドで CORS を許可
# FastAPI 側
app.add_middleware(CORSMiddleware, allow_origins=["*"])
つまずき2: Dev Container 内から localhost にアクセスできない
回避策: 環境別にエンドポイントを切り替え
if (kDebugMode && Uri.base.host == 'localhost') {
return 'http://localhost:8000';
}
つまずき3: 非同期処理で setState エラー
回避策: mounted チェックを追加
if (mounted) {
setState(() { ... });
}
PWA の利点
- ホーム画面に追加可能
- フルスクリーン表示
📝 まとめ
学んだこと
Flutter Web の利点
- 同一コードでモバイルにも展開可能
- Material Design で統一感のある UI
- Hot Reload で高速開発
API 通信の設計
- 環境別エンドポイント切り替えで開発効率 UP
- タイムアウト設定で無限待機を防止
- エラーハンドリングで堅牢な通信