T-CREATOR

Flutter で業務用管理画面:テーブル・フィルタ・エクスポート機能の実装指針

Flutter で業務用管理画面:テーブル・フィルタ・エクスポート機能の実装指針

業務システムを開発する際、管理画面は避けて通れない重要な要素です。特に大量のデータを扱うテーブル表示、柔軟なフィルタ機能、そしてデータエクスポート機能は、管理者にとって必須の機能となっています。

Flutter はモバイルアプリ開発のイメージが強いですが、実は Web やデスクトップアプリにも対応しており、業務用管理画面の開発にも適しています。本記事では、Flutter を使って業務用管理画面を構築する際の、テーブル・フィルタ・エクスポート機能の実装指針をご紹介します。

実務で使える具体的なコード例とともに、パフォーマンスや保守性を考慮した設計方法をお伝えしますので、ぜひ最後までご覧ください。

背景

業務用管理画面の重要性

現代のビジネスにおいて、データは最も重要な資産の一つです。顧客情報、売上データ、在庫管理など、あらゆる業務データを効率的に管理・分析するために、管理画面は欠かせません。

管理画面には以下のような要件が求められます。

  • 大量のデータを一覧表示できるテーブル機能
  • データを素早く探せる検索・フィルタ機能
  • レポート作成のためのエクスポート機能
  • 直感的で使いやすい UI/UX
  • レスポンシブなデザイン

Flutter が業務用管理画面に適している理由

Flutter は以下の特徴により、業務用管理画面の開発に適しています。

まず、クロスプラットフォーム対応により、一つのコードベースで Web・デスクトップ・モバイルに対応できます。これにより開発コストを大幅に削減できるでしょう。

次に、高速な動作とスムーズなアニメーションが実現できます。Flutter は独自のレンダリングエンジンを持つため、ネイティブアプリに匹敵するパフォーマンスを発揮します。

さらに、豊富なパッケージエコシステムがあります。pub.dev には業務アプリ開発に役立つパッケージが数多く公開されています。

下の図は、Flutter で構築する管理画面の基本的なアーキテクチャを示しています。

mermaidflowchart TB
  ui["UI Layer<br/>(Widgets)"]
  state["State Management<br/>(Provider/Riverpod)"]
  repo["Repository Layer<br/>(データ取得・加工)"]
  api["API/Backend<br/>(REST/GraphQL)"]

  ui -->|ユーザー操作| state
  state -->|状態変更| ui
  state -->|データ要求| repo
  repo -->|API呼び出し| api
  api -->|JSON/データ| repo
  repo -->|整形済みデータ| state

この図から、UI とビジネスロジックが明確に分離されていることがわかります。このような設計により、保守性と拡張性が向上するのです。

管理画面で扱うデータの特徴

業務用管理画面で扱うデータには、以下のような特徴があります。

データ量が多く、数千から数万件のレコードを扱うことも珍しくありません。そのため、パフォーマンスを考慮した実装が必要になります。

また、データ構造が複雑で、リレーションを持つテーブルや、ネストされた JSON データを扱うこともあります。

さらに、リアルタイム性が求められる場合もあり、在庫管理や注文管理では、データの即時反映が重要になるでしょう。

課題

大量データ表示におけるパフォーマンス問題

業務用管理画面で最も大きな課題は、大量のデータを表示する際のパフォーマンスです。

Flutter の標準的な DataTable ウィジェットは、シンプルな実装には適していますが、数千件のデータを表示しようとするとパフォーマンスが低下します。すべての行を一度にレンダリングするため、メモリ使用量が増大し、スクロールが遅くなってしまうのです。

また、データの読み込み時間も課題となります。API からすべてのデータを一度に取得すると、初期表示に時間がかかり、ユーザー体験が悪化します。

フィルタ機能の複雑性

次に、フィルタ機能の実装には複雑さが伴います。

単純な検索機能だけでなく、複数条件での絞り込み、日付範囲指定、カテゴリー選択など、多様なフィルタ要件に対応する必要があります。これらの状態管理をどのように行うかが重要なポイントとなるでしょう。

また、フィルタを適用した状態でソート機能と組み合わせると、実装の複雑度がさらに増します。

下の図は、フィルタ処理のフローを示しています。

mermaidflowchart LR
  raw["元データ"]
  filter1["検索フィルタ"]
  filter2["カテゴリーフィルタ"]
  filter3["日付範囲フィルタ"]
  sort["ソート処理"]
  result["表示データ"]

  raw --> filter1
  filter1 --> filter2
  filter2 --> filter3
  filter3 --> sort
  sort --> result

このように、複数のフィルタを順次適用していく必要があり、各ステップでのパフォーマンスを考慮しなければなりません。

エクスポート機能の要件

エクスポート機能にも様々な課題があります。

まず、フォーマット対応です。CSV だけでなく、Excel(XLSX)形式での出力を求められることが多く、それぞれに適したライブラリの選定が必要になります。

次に、大量データのエクスポート時のメモリ管理も重要です。数万件のデータを一度にメモリに展開すると、アプリがクラッシュする可能性があります。

さらに、エクスポート処理中の UI フリーズを防ぐため、非同期処理の実装も必要でしょう。

UI/UX の課題

業務用管理画面では、機能が豊富な分、UI が複雑になりがちです。

限られた画面スペースに、テーブル、フィルタ UI、アクションボタンなどを配置する必要があり、レイアウト設計が難しくなります。特にモバイル対応を考慮すると、レスポンシブなデザインが求められます。

また、操作性の面でも、キーボードショートカット、ページネーション、無限スクロールなど、ユーザーの効率を上げる工夫が必要です。

解決策

適切なパッケージの選定

Flutter で業務用管理画面を構築する際は、適切なパッケージを選定することが成功の鍵となります。

テーブル表示パッケージ

テーブル表示には data_table_2 パッケージの使用を強く推奨します。このパッケージは、Flutter 標準の DataTable を拡張したもので、以下の機能を提供します。

#機能説明
1固定ヘッダースクロール時もヘッダーが固定される
2固定カラム左端の列を固定できる
3ページネーション大量データを分割表示できる
4ソート機能各列でのソートに対応
5レスポンシブ対応画面サイズに応じた表示調整

エクスポート関連パッケージ

エクスポート機能には、以下のパッケージを組み合わせて使用します。

#パッケージ用途
1csvCSV ファイルの生成
2excelExcel (XLSX) ファイルの生成
3path_providerファイル保存先の取得
4file_pickerファイル保存ダイアログの表示

状態管理パッケージ

状態管理には riverpod または provider の使用をおすすめします。これらは Flutter 公式が推奨する状態管理手法であり、テストも容易です。

アーキテクチャ設計

保守性と拡張性を確保するため、レイヤーを明確に分離した設計を採用しましょう。

レイヤー構成

以下の 3 層構造を基本とします。

**Presentation Layer(UI 層)**では、ウィジェットのみを配置し、ビジネスロジックは含めません。ユーザーの操作を受け取り、State Management Layer に通知する役割を担います。

**State Management Layer(状態管理層)**では、アプリケーションの状態を管理し、データの取得・加工・フィルタリングを行います。Repository Layer からデータを取得し、UI に必要な形式に整形します。

**Repository Layer(データ層)**では、API との通信やローカルストレージへのアクセスを担当し、データソースの詳細を隠蔽します。

下の図は、各層の責務と相互作用を示しています。

mermaidflowchart TB
  subgraph presentation["Presentation Layer"]
    table["TableWidget"]
    filter["FilterWidget"]
    export["ExportButton"]
  end

  subgraph state["State Management"]
    provider["TableStateProvider"]
    filterState["FilterState"]
  end

  subgraph repository["Repository Layer"]
    dataRepo["DataRepository"]
    exportService["ExportService"]
  end

  table -->|イベント| provider
  filter -->|フィルタ変更| filterState
  export -->|エクスポート要求| provider

  provider -->|データ取得| dataRepo
  provider -->|エクスポート実行| exportService
  filterState -->|フィルタ条件| provider

このように責務を分離することで、各層を独立してテストでき、仕様変更にも柔軟に対応できます。

パフォーマンス最適化戦略

大量データを扱う際は、以下の戦略を採用しましょう。

ページネーション

サーバー側でページネーションを実装し、必要なデータのみを取得します。一度に表示する件数は、50〜100 件程度が適切でしょう。

仮想スクロール

data_table_2 パッケージは内部的に仮想スクロールを実装しており、画面に表示されている行のみをレンダリングします。これにより、数千件のデータでもスムーズにスクロールできます。

データのキャッシング

一度取得したデータはメモリにキャッシュし、同じデータへのアクセス時は API 呼び出しを省略します。ただし、キャッシュの有効期限を設定し、古いデータが表示され続けないよう注意が必要です。

非同期処理の活用

データ取得やエクスポート処理は、必ず非同期で実行し、UI スレッドをブロックしないようにします。Dart の async/await を活用することで、読みやすいコードを維持できるでしょう。

具体例

ここからは、実際の実装例を段階的に説明します。

プロジェクトのセットアップ

まず、必要なパッケージをインストールしましょう。

pubspec.yaml への依存関係追加

プロジェクトの pubspec.yaml ファイルに、必要なパッケージを追加します。

yamldependencies:
  flutter:
    sdk: flutter

  # 状態管理
  flutter_riverpod: ^2.4.0

  # テーブル表示
  data_table_2: ^2.5.0

  # エクスポート機能
  csv: ^5.1.0
  excel: ^4.0.0

  # ファイル操作
  path_provider: ^2.1.0
  file_picker: ^6.0.0

  # HTTP通信
  dio: ^5.3.0

パッケージを追加したら、ターミナルで以下のコマンドを実行してインストールします。

bashyarn install
# または
flutter pub get

データモデルの定義

管理画面で扱うデータのモデルを定義します。ここでは、ユーザー管理画面を例に説明しましょう。

User モデルクラス

以下は、ユーザーデータを表すモデルクラスです。JSON からのデシリアライズとシリアライズをサポートしています。

dart// models/user.dart

class User {
  final String id;
  final String name;
  final String email;
  final String role;
  final DateTime createdAt;
  final bool isActive;

  User({
    required this.id,
    required this.name,
    required this.email,
    required this.role,
    required this.createdAt,
    required this.isActive,
  });

JSON デシリアライズ

API から取得した JSON データを User オブジェクトに変換するファクトリーコンストラクタを定義します。

dart  // JSONからUserオブジェクトを生成
  factory User.fromJson(Map<String, dynamic> json) {
    return User(
      id: json['id'] as String,
      name: json['name'] as String,
      email: json['email'] as String,
      role: json['role'] as String,
      createdAt: DateTime.parse(json['created_at'] as String),
      isActive: json['is_active'] as bool,
    );
  }

JSON シリアライズ

User オブジェクトを JSON に変換するメソッドも定義します。エクスポート機能で使用します。

dart  // UserオブジェクトをJSONに変換
  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'name': name,
      'email': email,
      'role': role,
      'created_at': createdAt.toIso8601String(),
      'is_active': isActive,
    };
  }
}

これでデータモデルの準備が完了しました。次に、データ取得のための Repository を実装します。

Repository Layer の実装

Repository Layer は、API との通信を担当します。

UserRepository クラスの基本構造

以下は、ユーザーデータを取得する Repository クラスです。

dart// repositories/user_repository.dart
import 'package:dio/dio.dart';
import '../models/user.dart';

class UserRepository {
  final Dio _dio;
  final String _baseUrl = 'https://api.example.com';

  UserRepository({Dio? dio}) : _dio = dio ?? Dio();

ページネーション付きデータ取得

ページネーションをサポートしたデータ取得メソッドを実装します。

dart  // ページネーション付きでユーザー一覧を取得
  Future<PaginatedUsers> getUsers({
    required int page,
    required int limit,
    String? searchQuery,
    String? roleFilter,
  }) async {
    try {
      // クエリパラメータの構築
      final queryParams = {
        'page': page,
        'limit': limit,
        if (searchQuery != null && searchQuery.isNotEmpty)
          'search': searchQuery,
        if (roleFilter != null && roleFilter.isNotEmpty)
          'role': roleFilter,
      };

レスポンス処理

API からのレスポンスを処理し、User オブジェクトのリストに変換します。

dart      // API呼び出し
      final response = await _dio.get(
        '$_baseUrl/users',
        queryParameters: queryParams,
      );

      // レスポンスデータの変換
      final data = response.data as Map<String, dynamic>;
      final users = (data['users'] as List)
          .map((json) => User.fromJson(json))
          .toList();

      // ページネーション情報とともに返却
      return PaginatedUsers(
        users: users,
        total: data['total'] as int,
        page: page,
        limit: limit,
      );
    } catch (e) {
      throw Exception('Failed to fetch users: $e');
    }
  }
}

ページネーション情報クラス

ページネーション情報を保持するクラスも定義しておきます。

dart// models/paginated_users.dart

class PaginatedUsers {
  final List<User> users;
  final int total;      // 総件数
  final int page;       // 現在のページ
  final int limit;      // 1ページあたりの件数

  PaginatedUsers({
    required this.users,
    required this.total,
    required this.page,
    required this.limit,
  });

  // 総ページ数を計算
  int get totalPages => (total / limit).ceil();
}

State Management の実装

Riverpod を使用して、テーブルの状態を管理します。

フィルタ状態の定義

まず、フィルタの状態を表すクラスを定義します。

dart// providers/filter_state.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';

class FilterState {
  final String searchQuery;     // 検索キーワード
  final String? roleFilter;     // ロールフィルタ
  final int currentPage;        // 現在のページ
  final int itemsPerPage;       // 1ページの件数

  FilterState({
    this.searchQuery = '',
    this.roleFilter,
    this.currentPage = 1,
    this.itemsPerPage = 50,
  });

フィルタ状態のコピーメソッド

イミュータブルな状態更新のため、copyWith メソッドを実装します。

dart  // 状態の一部を更新した新しいインスタンスを生成
  FilterState copyWith({
    String? searchQuery,
    String? roleFilter,
    int? currentPage,
    int? itemsPerPage,
  }) {
    return FilterState(
      searchQuery: searchQuery ?? this.searchQuery,
      roleFilter: roleFilter ?? this.roleFilter,
      currentPage: currentPage ?? this.currentPage,
      itemsPerPage: itemsPerPage ?? this.itemsPerPage,
    );
  }
}

StateNotifier の実装

フィルタ状態を管理する StateNotifier を実装します。

dart// フィルタ状態を管理するStateNotifier
class FilterStateNotifier extends StateNotifier<FilterState> {
  FilterStateNotifier() : super(FilterState());

  // 検索キーワードを更新
  void updateSearchQuery(String query) {
    state = state.copyWith(searchQuery: query, currentPage: 1);
  }

  // ロールフィルタを更新
  void updateRoleFilter(String? role) {
    state = state.copyWith(roleFilter: role, currentPage: 1);
  }

  // ページを変更
  void changePage(int page) {
    state = state.copyWith(currentPage: page);
  }
}

Provider の定義

フィルタ状態とユーザーデータ取得の Provider を定義します。

dart// providers/user_providers.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';

// フィルタ状態のProvider
final filterStateProvider =
    StateNotifierProvider<FilterStateNotifier, FilterState>(
  (ref) => FilterStateNotifier(),
);

// UserRepositoryのProvider
final userRepositoryProvider = Provider<UserRepository>(
  (ref) => UserRepository(),
);

非同期データ取得 Provider

フィルタ状態に基づいてユーザーデータを取得する FutureProvider を定義します。

dart// ユーザーデータ取得のFutureProvider
final usersProvider = FutureProvider<PaginatedUsers>((ref) async {
  // フィルタ状態を監視
  final filterState = ref.watch(filterStateProvider);
  final repository = ref.watch(userRepositoryProvider);

  // フィルタ条件に基づいてデータを取得
  return repository.getUsers(
    page: filterState.currentPage,
    limit: filterState.itemsPerPage,
    searchQuery: filterState.searchQuery.isNotEmpty
        ? filterState.searchQuery
        : null,
    roleFilter: filterState.roleFilter,
  );
});

このように Provider を定義することで、フィルタ条件が変更されると自動的にデータが再取得されます。

テーブル UI の実装

data_table_2 パッケージを使用して、テーブル UI を実装します。

UserTableWidget の基本構造

以下は、ユーザー一覧テーブルのウィジェットです。

dart// widgets/user_table_widget.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:data_table_2/data_table_2.dart';

class UserTableWidget extends ConsumerWidget {
  const UserTableWidget({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // usersProviderを監視
    final usersAsync = ref.watch(usersProvider);

データ読み込み状態の処理

非同期データの読み込み状態に応じて、適切な UI を表示します。

dart    return usersAsync.when(
      // データ読み込み中
      loading: () => const Center(
        child: CircularProgressIndicator(),
      ),

      // エラー発生時
      error: (error, stack) => Center(
        child: Text('Error: $error'),
      ),

      // データ取得成功時
      data: (paginatedUsers) => _buildTable(context, ref, paginatedUsers),
    );
  }

テーブルの構築

DataTable2 ウィジェットを使用してテーブルを構築します。

dart  // テーブルを構築するメソッド
  Widget _buildTable(
    BuildContext context,
    WidgetRef ref,
    PaginatedUsers paginatedUsers,
  ) {
    return Column(
      children: [
        // テーブル本体
        Expanded(
          child: DataTable2(
            columnSpacing: 12,
            horizontalMargin: 12,
            minWidth: 900,
            columns: _buildColumns(),
            rows: _buildRows(paginatedUsers.users),
          ),
        ),

カラム定義

テーブルのカラムを定義します。ソート機能も有効にします。

dart  // テーブルのカラムを定義
  List<DataColumn2> _buildColumns() {
    return [
      const DataColumn2(
        label: Text('ID'),
        size: ColumnSize.S,
      ),
      const DataColumn2(
        label: Text('名前'),
        size: ColumnSize.L,
      ),
      const DataColumn2(
        label: Text('メールアドレス'),
        size: ColumnSize.L,
      ),
      const DataColumn2(
        label: Text('ロール'),
        size: ColumnSize.M,
      ),
      const DataColumn2(
        label: Text('登録日'),
        size: ColumnSize.M,
      ),
      const DataColumn2(
        label: Text('ステータス'),
        size: ColumnSize.S,
      ),
    ];
  }

行データの構築

ユーザーデータから DataRow を生成します。

dart  // ユーザーデータからDataRowを生成
  List<DataRow2> _buildRows(List<User> users) {
    return users.map((user) {
      return DataRow2(
        cells: [
          DataCell(Text(user.id)),
          DataCell(Text(user.name)),
          DataCell(Text(user.email)),
          DataCell(_buildRoleBadge(user.role)),
          DataCell(Text(_formatDate(user.createdAt))),
          DataCell(_buildStatusBadge(user.isActive)),
        ],
        onTap: () => _onUserTap(user),
      );
    }).toList();
  }

バッジ UI の実装

ロールやステータスを視覚的に表示するバッジを作成します。

dart  // ロールバッジの作成
  Widget _buildRoleBadge(String role) {
    Color color;
    switch (role) {
      case 'admin':
        color = Colors.red;
        break;
      case 'editor':
        color = Colors.blue;
        break;
      default:
        color = Colors.grey;
    }

    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
      decoration: BoxDecoration(
        color: color.withOpacity(0.1),
        borderRadius: BorderRadius.circular(4),
      ),
      child: Text(
        role,
        style: TextStyle(color: color, fontWeight: FontWeight.bold),
      ),
    );
  }

日付フォーマット

日付を読みやすい形式にフォーマットします。

dart  // 日付をフォーマット
  String _formatDate(DateTime date) {
    return '${date.year}/${date.month.toString().padLeft(2, '0')}/'
           '${date.day.toString().padLeft(2, '0')}';
  }
}

これでテーブルの基本的な表示が完成しました。

フィルタ UI の実装

次に、検索とフィルタリングの UI を実装します。

FilterBarWidget の作成

テーブルの上部に配置するフィルタバーを作成します。

dart// widgets/filter_bar_widget.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class FilterBarWidget extends ConsumerWidget {
  const FilterBarWidget({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Container(
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: Colors.white,
        boxShadow: [
          BoxShadow(
            color: Colors.grey.withOpacity(0.1),
            blurRadius: 4,
            offset: const Offset(0, 2),
          ),
        ],
      ),

検索ボックスの実装

ユーザー名やメールアドレスで検索できる検索ボックスを実装します。

dart      child: Row(
        children: [
          // 検索ボックス
          Expanded(
            child: TextField(
              decoration: const InputDecoration(
                labelText: '検索',
                hintText: '名前またはメールアドレスで検索',
                prefixIcon: Icon(Icons.search),
                border: OutlineInputBorder(),
              ),
              onChanged: (value) {
                // 検索キーワードが変更されたらStateを更新
                ref.read(filterStateProvider.notifier)
                   .updateSearchQuery(value);
              },
            ),
          ),
          const SizedBox(width: 16),

ロールフィルタのドロップダウン

ロールで絞り込むためのドロップダウンメニューを実装します。

dart          // ロールフィルタ
          SizedBox(
            width: 200,
            child: DropdownButtonFormField<String>(
              decoration: const InputDecoration(
                labelText: 'ロール',
                border: OutlineInputBorder(),
              ),
              value: ref.watch(filterStateProvider).roleFilter,
              items: const [
                DropdownMenuItem(
                  value: null,
                  child: Text('すべて'),
                ),
                DropdownMenuItem(
                  value: 'admin',
                  child: Text('管理者'),
                ),
                DropdownMenuItem(
                  value: 'editor',
                  child: Text('編集者'),
                ),
                DropdownMenuItem(
                  value: 'viewer',
                  child: Text('閲覧者'),
                ),
              ],
              onChanged: (value) {
                // ロールフィルタが変更されたらStateを更新
                ref.read(filterStateProvider.notifier)
                   .updateRoleFilter(value);
              },
            ),
          ),
        ],
      ),
    );
  }
}

これで、検索キーワードやロールフィルタを変更すると、自動的にテーブルのデータが更新されます。

ページネーション UI の実装

大量データを扱う際に必須のページネーション UI を実装します。

PaginationWidget の作成

ページ番号表示と前後ページへの移動ボタンを持つウィジェットを作成します。

dart// widgets/pagination_widget.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class PaginationWidget extends ConsumerWidget {
  final PaginatedUsers paginatedUsers;

  const PaginationWidget({
    Key? key,
    required this.paginatedUsers,
  }) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final currentPage = paginatedUsers.page;
    final totalPages = paginatedUsers.totalPages;

ページネーション情報の表示

現在のページと総ページ数、データ件数を表示します。

dart    return Container(
      padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: [
          // データ件数の表示
          Text(
            '全 ${paginatedUsers.total} 件中 '
            '${(currentPage - 1) * paginatedUsers.limit + 1} - '
            '${(currentPage * paginatedUsers.limit).clamp(0, paginatedUsers.total)} 件を表示',
            style: const TextStyle(fontSize: 14),
          ),

ページ移動ボタンの実装

前のページ・次のページへ移動するボタンを実装します。

dart          // ページ移動ボタン
          Row(
            children: [
              // 前のページボタン
              IconButton(
                icon: const Icon(Icons.chevron_left),
                onPressed: currentPage > 1
                    ? () {
                        ref.read(filterStateProvider.notifier)
                           .changePage(currentPage - 1);
                      }
                    : null,
              ),

              // ページ番号表示
              Text(
                'ページ $currentPage / $totalPages',
                style: const TextStyle(fontSize: 14),
              ),

              // 次のページボタン
              IconButton(
                icon: const Icon(Icons.chevron_right),
                onPressed: currentPage < totalPages
                    ? () {
                        ref.read(filterStateProvider.notifier)
                           .changePage(currentPage + 1);
                      }
                    : null,
              ),
            ],
          ),
        ],
      ),
    );
  }
}

これでページネーション機能が完成しました。ボタンをクリックすると、自動的に新しいページのデータが取得されます。

エクスポート機能の実装

最後に、テーブルデータを CSV や Excel 形式でエクスポートする機能を実装します。

ExportService クラスの作成

エクスポート処理を担当するサービスクラスを作成します。

dart// services/export_service.dart
import 'dart:io';
import 'package:csv/csv.dart';
import 'package:excel/excel.dart';
import 'package:path_provider/path_provider.dart';
import 'package:file_picker/file_picker.dart';

class ExportService {
  // CSVファイルとしてエクスポート
  Future<void> exportToCSV(List<User> users) async {
    // CSVデータの生成
    final csvData = _generateCSVData(users);

    // CSVフォーマットに変換
    final csv = const ListToCsvConverter().convert(csvData);

CSV データの生成

ユーザーデータを CSV 形式のリストに変換します。

dart    // ファイル保存先の選択
    final outputPath = await FilePicker.platform.saveFile(
      dialogTitle: 'CSVファイルを保存',
      fileName: 'users_${DateTime.now().millisecondsSinceEpoch}.csv',
      type: FileType.custom,
      allowedExtensions: ['csv'],
    );

    if (outputPath != null) {
      // ファイルに書き込み
      final file = File(outputPath);
      await file.writeAsString(csv);
    }
  }

  // CSVデータの生成
  List<List<dynamic>> _generateCSVData(List<User> users) {
    final data = <List<dynamic>>[];

    // ヘッダー行
    data.add(['ID', '名前', 'メールアドレス', 'ロール', '登録日', 'ステータス']);

    // データ行
    for (final user in users) {
      data.add([
        user.id,
        user.name,
        user.email,
        user.role,
        _formatDate(user.createdAt),
        user.isActive ? '有効' : '無効',
      ]);
    }

    return data;
  }

Excel エクスポート機能

Excel 形式でのエクスポート機能も実装します。

dart  // Excelファイルとしてエクスポート
  Future<void> exportToExcel(List<User> users) async {
    // Excelファイルの作成
    final excel = Excel.createExcel();
    final sheet = excel['Users'];

    // ヘッダー行の追加(太字でスタイリング)
    final headers = ['ID', '名前', 'メールアドレス', 'ロール', '登録日', 'ステータス'];
    sheet.appendRow(headers);

Excel へのデータ追加

ユーザーデータを Excel シートに追加します。

dart    // データ行の追加
    for (final user in users) {
      sheet.appendRow([
        user.id,
        user.name,
        user.email,
        user.role,
        _formatDate(user.createdAt),
        user.isActive ? '有効' : '無効',
      ]);
    }

    // 列幅の自動調整
    for (var i = 0; i < headers.length; i++) {
      sheet.setColumnWidth(i, 20);
    }

Excel ファイルの保存

生成した Excel ファイルを保存します。

dart    // ファイル保存先の選択
    final outputPath = await FilePicker.platform.saveFile(
      dialogTitle: 'Excelファイルを保存',
      fileName: 'users_${DateTime.now().millisecondsSinceEpoch}.xlsx',
      type: FileType.custom,
      allowedExtensions: ['xlsx'],
    );

    if (outputPath != null) {
      // ファイルに書き込み
      final bytes = excel.encode();
      if (bytes != null) {
        final file = File(outputPath);
        await file.writeAsBytes(bytes);
      }
    }
  }

  // 日付フォーマット
  String _formatDate(DateTime date) {
    return '${date.year}/${date.month.toString().padLeft(2, '0')}/'
           '${date.day.toString().padLeft(2, '0')}';
  }
}

エクスポートボタンの実装

UI にエクスポートボタンを追加します。

dart// widgets/export_button_widget.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class ExportButtonWidget extends ConsumerWidget {
  const ExportButtonWidget({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return PopupMenuButton<String>(
      icon: const Icon(Icons.download),
      tooltip: 'エクスポート',
      onSelected: (value) async {
        // 現在表示中のユーザーデータを取得
        final usersAsync = ref.read(usersProvider);

エクスポート処理の実行

選択されたフォーマットに応じて、エクスポート処理を実行します。

dart        usersAsync.whenData((paginatedUsers) async {
          final exportService = ExportService();

          // ローディング表示
          showDialog(
            context: context,
            barrierDismissible: false,
            builder: (context) => const Center(
              child: CircularProgressIndicator(),
            ),
          );

          try {
            if (value == 'csv') {
              await exportService.exportToCSV(paginatedUsers.users);
            } else if (value == 'excel') {
              await exportService.exportToExcel(paginatedUsers.users);
            }

            // ローディング非表示
            Navigator.of(context).pop();

            // 成功メッセージ表示
            ScaffoldMessenger.of(context).showSnackBar(
              const SnackBar(content: Text('エクスポートが完了しました')),
            );
          } catch (e) {
            // エラー処理
            Navigator.of(context).pop();
            ScaffoldMessenger.of(context).showSnackBar(
              SnackBar(content: Text('エクスポートに失敗しました: $e')),
            );
          }
        });
      },

メニューアイテムの定義

CSV と Excel の選択肢を持つメニューを定義します。

dart      itemBuilder: (context) => [
        const PopupMenuItem(
          value: 'csv',
          child: Row(
            children: [
              Icon(Icons.table_chart),
              SizedBox(width: 8),
              Text('CSV形式でエクスポート'),
            ],
          ),
        ),
        const PopupMenuItem(
          value: 'excel',
          child: Row(
            children: [
              Icon(Icons.file_present),
              SizedBox(width: 8),
              Text('Excel形式でエクスポート'),
            ],
          ),
        ),
      ],
    );
  }
}

メイン画面の統合

最後に、これまで実装したウィジェットを統合してメイン画面を構築します。

UserManagementPage の作成

すべての要素を配置したメイン画面を作成します。

dart// pages/user_management_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class UserManagementPage extends ConsumerWidget {
  const UserManagementPage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final usersAsync = ref.watch(usersProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text('ユーザー管理'),
        actions: [
          // エクスポートボタン
          const ExportButtonWidget(),
        ],
      ),

レイアウトの構築

フィルタバー、テーブル、ページネーションを縦に配置します。

dart      body: Column(
        children: [
          // フィルタバー
          const FilterBarWidget(),

          // テーブル(画面の残りスペースを使用)
          Expanded(
            child: usersAsync.when(
              loading: () => const Center(
                child: CircularProgressIndicator(),
              ),
              error: (error, stack) => Center(
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    const Icon(Icons.error, size: 48, color: Colors.red),
                    const SizedBox(height: 16),
                    Text('エラーが発生しました: $error'),
                  ],
                ),
              ),
              data: (paginatedUsers) => const UserTableWidget(),
            ),
          ),

          // ページネーション
          usersAsync.maybeWhen(
            data: (paginatedUsers) => PaginationWidget(
              paginatedUsers: paginatedUsers,
            ),
            orElse: () => const SizedBox.shrink(),
          ),
        ],
      ),
    );
  }
}

これで、業務用管理画面の基本的な機能がすべて実装できました。

パフォーマンスチューニング

実装が完了したら、パフォーマンスチューニングを行いましょう。

デバウンス処理の追加

検索ボックスへの入力時、毎回 API を呼び出すとサーバーに負荷がかかります。デバウンス処理を追加して、入力が落ち着いてから検索を実行するようにします。

dart// widgets/filter_bar_widget.dart(改良版)
import 'dart:async';

class FilterBarWidget extends ConsumerStatefulWidget {
  const FilterBarWidget({Key? key}) : super(key: key);

  @override
  ConsumerState<FilterBarWidget> createState() => _FilterBarWidgetState();
}

class _FilterBarWidgetState extends ConsumerState<FilterBarWidget> {
  Timer? _debounce;

  @override
  void dispose() {
    _debounce?.cancel();
    super.dispose();
  }

デバウンス付き検索処理

入力から 500 ミリ秒後に検索を実行する処理を実装します。

dart  void _onSearchChanged(String value) {
    // 既存のタイマーをキャンセル
    if (_debounce?.isActive ?? false) _debounce!.cancel();

    // 新しいタイマーを設定(500ms後に実行)
    _debounce = Timer(const Duration(milliseconds: 500), () {
      ref.read(filterStateProvider.notifier).updateSearchQuery(value);
    });
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(16),
      child: TextField(
        decoration: const InputDecoration(
          labelText: '検索',
          hintText: '名前またはメールアドレスで検索',
          prefixIcon: Icon(Icons.search),
        ),
        onChanged: _onSearchChanged,
      ),
    );
  }
}

これにより、ユーザーが入力を終えるまで API 呼び出しを待つことができ、サーバーへの負荷を大幅に削減できます。

まとめ

本記事では、Flutter を使った業務用管理画面の実装方法を、テーブル表示・フィルタ・エクスポート機能を中心に解説しました。

実装のポイント

まず、適切なパッケージの選定が重要です。data_table_2 を使用することで、大量データでもスムーズに動作するテーブルを実装できました。

次に、レイヤーを分離した設計により、保守性と拡張性が向上します。Presentation Layer、State Management Layer、Repository Layer の 3 層構造を採用することで、各層を独立してテストできるようになります。

また、Riverpod を活用した状態管理により、フィルタ条件の変更が自動的にデータ取得をトリガーする仕組みを実現できました。

さらに、ページネーションとデバウンス処理により、パフォーマンスを最適化することが可能です。

最後に、CSV と Excel の両方のエクスポート機能を実装することで、ユーザーの多様なニーズに対応できます。

今後の拡張可能性

本記事で実装した基本機能を土台に、以下のような機能を追加できるでしょう。

ソート機能を追加すれば、各列のヘッダーをクリックして昇順・降順を切り替えられます。data_table_2 は標準でソート機能をサポートしているため、比較的簡単に実装できます。

一括操作機能として、複数のユーザーを選択して一括削除や一括編集を行える機能も有用です。チェックボックスを追加し、選択状態を管理する必要があります。

リアルタイム更新では、WebSocket や Server-Sent Events を使用して、他のユーザーの変更をリアルタイムに反映できます。

詳細表示・編集機能として、行をクリックすると詳細画面に遷移し、編集できる機能も一般的です。

さらに、権限管理により、ユーザーのロールに応じて表示内容や操作可能な機能を制限することも重要でしょう。

Flutter で業務アプリを開発する利点

Flutter で業務用管理画面を開発することには、多くの利点があります。

クロスプラットフォーム対応により、一つのコードベースで複数のプラットフォームに対応できるため、開発コストを大幅に削減できます。

高いパフォーマンスも魅力的です。適切な実装を行えば、ネイティブアプリに匹敵する速度で動作します。

豊富なウィジェットライブラリにより、美しい UI を短期間で構築できます。Material Design や Cupertino スタイルをサポートしているため、プラットフォームに合わせたデザインも可能です。

ホットリロード機能により、開発効率が向上します。コードを変更すると即座に画面に反映されるため、UI の調整が容易です。

そして、成長中のエコシステムとして、pub.dev には日々新しいパッケージが追加されており、コミュニティも活発です。

最後に

業務用管理画面は、企業の業務効率に直結する重要なシステムです。本記事で紹介した実装パターンを参考に、ぜひ皆さんのプロジェクトでも Flutter を活用してみてください。

適切な設計とパフォーマンス最適化により、使いやすく高速な管理画面を構築できるはずです。Flutter の持つポテンシャルを最大限に引き出し、ユーザーに喜ばれるシステムを作りましょう。

関連リンク