0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SwitchBot + FastAPI + Flutter で家庭の電力消費を可視化するWebアプリ構築(その4)

Last updated at Posted at 2026-01-19

Flutter Web でフロントエンドを構築する - アプリ構造と API 通信

📝 はじめに

この記事は
SwitchBot + FastAPI + Flutter で家庭の電力消費を可視化するWebアプリ構築(その3) #Python - Qiita
の続きになります。

:white_check_mark: この記事で書くこと

  • Flutter Web でのアプリ構造設計
  • バックエンド API との HTTP 通信実装
  • 環境別エンドポイント切り替え

:white_check_mark: 対象読者

  • Flutter で Web アプリを作りたい方
  • Dart での HTTP 通信を学びたい方
  • Flutter のアプリ構造設計に興味がある方

:white_check_mark: 前提

  • Flutter / Dart の基本知識
  • REST API の基本概念

🎯 背景・動機

なぜ Flutter Web を選んだのか

:thinking: 課題
電力データを時系列グラフで可視化し、デバイス別に表示したい。

:bulb: React/Vue ではなく Flutter を選んだ理由

項目 Flutter Web React/Vue.js
言語 Dart JavaScript
モバイル対応 同一コード 別途必要
Material Design 標準搭載 ライブラリ必要
グラフ fl_chart Chart.js 等

:point_right: 将来的なモバイルアプリ展開を見据えて 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 に置換
    }
  }
}

:white_check_mark: ポイント

  • 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 [];
}

:warning: タイムアウト設定

  • .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/

つまずきポイントと回避策

:x: つまずき1: CORS エラーでデータが取得できない

:white_check_mark: 回避策: バックエンドで CORS を許可

# FastAPI 側
app.add_middleware(CORSMiddleware, allow_origins=["*"])

:x: つまずき2: Dev Container 内から localhost にアクセスできない

:white_check_mark: 回避策: 環境別にエンドポイントを切り替え

if (kDebugMode && Uri.base.host == 'localhost') {
  return 'http://localhost:8000';
}

:x: つまずき3: 非同期処理で setState エラー

:white_check_mark: 回避策: mounted チェックを追加

if (mounted) {
  setState(() { ... });
}

:white_check_mark: PWA の利点

  • ホーム画面に追加可能
  • フルスクリーン表示

📝 まとめ

学んだこと

:white_check_mark: Flutter Web の利点

  • 同一コードでモバイルにも展開可能
  • Material Design で統一感のある UI
  • Hot Reload で高速開発

:white_check_mark: API 通信の設計

  • 環境別エンドポイント切り替えで開発効率 UP
  • タイムアウト設定で無限待機を防止
  • エラーハンドリングで堅牢な通信

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?