💡

Flutter(Dart)からCSVファイルに書き出す方法

2024/08/08に公開

Flutterにおいてデータベースとのやり取りをする際にFirebaseを利用する人が多いと思いますが、CSV出力をするというマイナー(?)なやり取りを、今回チーム開発を通して初めて実装したのでその経験を簡単にまとめます。

経緯

  • データベースを介するプロダクトにおいて、多言語(Dart, Python, JavaScript)でのやり取りをする必要があった。
  • Flutterでアプリ開発をしたいが時間が限られていて、Firebaseを全員が十分に習得するのにはハードルが高かった。
  • チームメンバーが各自の言語で、短期間で、一律に、データベース操作をすることを考えた結果、CSVを軸に開発を行おうとなりました。

ハードコーディングで簡易的に試してみる

バッケージ等はpubspec.yamlに各自インストールしてください!

pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  csv: 
import 'dart:io';

import 'package:csv/csv.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'CSV Export Example',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatelessWidget {
  Future<void> _generateCsvFile() async {
    // リストでデータを用意
    List<List<dynamic>> data = [
      ["Name", "Age", "City"],
      ["Taro", 23, "New York"],
      ["Bob", 34, "San Francisco"],
      ["Charlie", 29, "Los Angeles"]
    ];

    String csvData = const ListToCsvConverter().convert(data);

    // libディレクトリにdata.csvとして出力を試みる
    final path =
        "/csv_export_example/lib/data.csv";

    final file = File(path);
    await file.writeAsString(csvData);

    print("File saved at: $path");
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("CSV Export Example"),
      ),
      body: Center(
        // ボタンを押すと_generateCsvFileが動作しファイルが作られる
        child: ElevatedButton(
          onPressed: _generateCsvFile,
          child: Text("Generate CSV"),
        ),
      ),
    );
  }
}

結果

以下のように、ちゃんとlibディレクトリにdata.csvが作成されました!

動的にCSVファイルを出力してみる

上記のようなやり方でもいいですが、Flutterで動作させるならモバイルアプリとして動的に値を受け取ってファイルへ出力するケースの方が現実的だと思います。

実装紹介の前に...

Firebaseなどのクラウドデータベースではなく、あえてCSVに出力するということは、CSVで受け取りたい人がいるという点が大きなポイントだと考えられます。
今回、私がこの開発に携わった経緯もそんな感じで、BaaSから読み込むよりもCSVから読み込んだ方がスピーディーに開発できそうという点でした。
Pythonなどによる業務効率化が流行っている中でも「誰もがプログラミングができるわけじゃない」という点は当然考えられるケースだと思います。

これらの点を踏まえ、以下のようなアプリケーションケースとして実装例を紹介します。

実装例紹介

簡単なECサイト的なアプリ

  • ユーザーが商品詳細を選択し、確認が完了次第購入ボタンを押す。
  • 購入された商品と同時にユーザー情報などをCSVファイルに出力する。(フォルダがなければ生成する)
  • CSVによって出力されるため、プログラミング知識のない店長さんなどでも確認できる

→ こんな流れです。

ディレクトリ構成

my_flutter_app/
├── android/
├── build/
├── ios/
├── lib/
│   ├── models/
│   │   └── order_item.dart
│   ├── screens/
│   │   ├── selecting_screen.dart
│   │   ├── confirm_order_screen.dart
│   │   └── input_user_info_screen.dart
│   │   └── order_confirmation_screen.dart
│   ├── main.dart
├── test/
├── assets/
│   ├── images/
│   │   ├── water.jpg
│   │   ├── greentea.jpg
│   │   ├── snacks.jpg
│   │   ├── riceball.jpg
├── pubspec.yaml

1. プロジェクトの設定

まず、Flutterプロジェクトを作成し、必要な依存関係を追加します。pubspec.yaml に以下を追加してください。

pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  uuid: ^3.0.6
  csv: ^5.0.0

2. アプリケーションのエントリーポイント

次に、アプリのエントリーポイント main.dart を作成します。このファイルは、アプリ全体のスタートポイントを定義します。

main.dart
import 'package:flutter/material.dart';
import 'screens/selecting_screen.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Order App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const SelectingScreen(),
    );
  }
}

3. 商品選択画面

ユーザーが注文する商品を選択する画面を作成します。
selecting_screen.dart では、商品リストを表示し、各商品の数量を選択する機能を提供します。

selecting_screen.dart
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
import 'confirm_order_screen.dart';
import '../models/order_item.dart';

class SelectingScreen extends StatefulWidget {
  const SelectingScreen({super.key});

  
  State<SelectingScreen> createState() => _SelectingScreenState();
}

class _SelectingScreenState extends State<SelectingScreen> {
  final List<OrderItem> _orderItems = [];
  final Uuid uuid = const Uuid();

  final Map<String, String> productIds = {
    "Water 500mL": const Uuid().v4(),
    "Green Tea 500mL": const Uuid().v4(),
    "Snacks": const Uuid().v4(),
    "Rice Ball (Plum)": const Uuid().v4(),
  };

  void _updateOrderItem(String productId, String productName, int count, String imageUrl) {
    setState(() {
      _orderItems.removeWhere((item) => item.productId == productId);
      if (count > 0) {
        _orderItems.add(OrderItem(
          productId: productId,
          productName: productName,
          count: count,
          imageUrl: imageUrl,
        ));
      }
    });
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      body: SafeArea(
        child: ListView(
          padding: const EdgeInsets.fromLTRB(16, 16, 16, 80),
          children: [
            ProductCounter(
              productId: productIds["Water 500mL"]!,
              productName: "Water 500mL",
              maxValue: "Max: 5",
              maxCount: 5,
              imageUrl: 'assets/images/water.jpg',
              onCountChanged: _updateOrderItem,
            ),
            ProductCounter(
              productId: productIds["Green Tea 500mL"]!,
              productName: "Green Tea 500mL",
              maxValue: "Max: 5",
              maxCount: 5,
              imageUrl: 'assets/images/greentea.jpg',
              onCountChanged: _updateOrderItem,
            ),
            ProductCounter(
              productId: productIds["Snacks"]!,
              productName: "Snacks",
              maxValue: "Max: 3",
              maxCount: 3,
              imageUrl: 'assets/images/snacks.jpg',
              onCountChanged: _updateOrderItem,
            ),
            ProductCounter(
              productId: productIds["Rice Ball (Plum)"]!,
              productName: "Rice Ball (Plum)",
              maxValue: "Max: 5",
              maxCount: 5,
              imageUrl: 'assets/images/riceball.jpg',
              onCountChanged: _updateOrderItem,
            ),
          ],
        ),
      ),
      floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
      floatingActionButton: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 16.0),
        child: SizedBox(
          width: double.infinity,
          height: 46.0,
          child: ElevatedButton(
            onPressed: () {
              Navigator.push(
                context,
                MaterialPageRoute(
                  builder: (context) => ConfirmOrderScreen(orderItems: _orderItems),
                ),
              );
            },
            style: ElevatedButton.styleFrom(
              backgroundColor: Colors.blue,
              shape: RoundedRectangleBorder(
                borderRadius: BorderRadius.circular(12.0),
              ),
            ),
            child: const Text(
              'Confirm Order',
              style: TextStyle(
                  fontSize: 18.0,
                  fontWeight: FontWeight.bold,
                  color: Colors.white),
            ),
          ),
        ),
      ),
    );
  }
}

class ProductCounter extends StatefulWidget {
  final String productId;
  final String productName;
  final String maxValue;
  final int maxCount;
  final String imageUrl;
  final void Function(String productId, String productName, int count, String imageUrl) onCountChanged;

  const ProductCounter({
    super.key,
    required this.productId,
    required this.productName,
    required this.maxValue,
    required this.maxCount,
    required this.imageUrl,
    required this.onCountChanged,
  });

  
  _ProductCounterState createState() => _ProductCounterState();
}

class _ProductCounterState extends State<ProductCounter> {
  int _count = 0;

  void _increment() {
    setState(() {
      if (_count < widget.maxCount) {
        _count++;
        widget.onCountChanged(widget.productId, widget.productName, _count, widget.imageUrl);
      }
    });
  }

  void _decrement() {
    setState(() {
      if (_count > 0) {
        _count--;
        widget.onCountChanged(widget.productId, widget.productName, _count, widget.imageUrl);
      }
    });
  }

  
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 8.0),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: [
          Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(widget.productName,
                  style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
              Text(widget.maxValue, style: const TextStyle(fontSize: 12)),
            ],
          ),
          Row(
            children: [
              IconButton(
                icon: const Icon(Icons.remove),
                onPressed: _decrement,
              ),
              Container(
                width: 40,
                height: 40,
                alignment: Alignment.center,
                decoration: BoxDecoration(
                  border: Border.all(color: Colors.grey),
                  borderRadius: BorderRadius.circular(8),
                ),
                child: Text('$_count', style: const TextStyle(fontSize: 16)),
              ),
              IconButton(
                icon: const Icon(Icons.add),
                onPressed: _increment,
              ),
              const Text("pcs"),
            ],
          ),
        ],
      ),
    );
  }
}

4. 注文内容の確認画面

注文内容を確認するための画面を作成します。
confirm_order_screen.dart では、ユーザーが選択した商品リストを表示します。

confirm_order_screen.dart
import 'package:flutter/material.dart';
import 'input_user_info_screen.dart';
import '../models/order_item.dart';

class ConfirmOrderScreen extends StatelessWidget {
  final List<OrderItem> orderItems;

  const ConfirmOrderScreen({super.key, required this.orderItems});

  
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      body: SafeArea(
        child: ListView.builder(
          padding: const EdgeInsets.fromLTRB(16, 16, 16, 80),
          itemCount: orderItems.length,
          itemBuilder: (context, index) {
            return ListTile(
              leading: Image.asset(orderItems[index].imageUrl, width: 50, height: 50),
              title: Text(orderItems[index].productName),
              subtitle: Text('Quantity: ${orderItems[index].count}'),
            );
          },
        ),
      ),
      floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
      floatingActionButton: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 16.0),
        child: SizedBox(
          width: double.infinity,
          height: 46.0,
          child: ElevatedButton(
            onPressed: () {
              Navigator.push(
                context,
                MaterialPageRoute(
                  builder: (context) => InputUserInfoScreen(orderItems: orderItems),
                ),
              );
            },
            style: ElevatedButton.styleFrom(
              backgroundColor: Colors.blue,
              shape: RoundedRectangleBorder(
                borderRadius: BorderRadius.circular(12.0),
              ),
            ),
            child: const Text(
              'Input User Info',
              style: TextStyle(
                  fontSize: 18.0,
                  fontWeight: FontWeight.bold,
                  color: Colors.white),
            ),
          ),
        ),
      ),
    );
  }
}

5. ユーザー情報の入力画面

ユーザーの氏名や電話番号などの情報を入力する画面を作成します。input_user_info_screen.dart では、これらの情報を取得します。

input_user_info_screen.dart
import 'package:flutter/material.dart';
import 'order_confirmation_screen.dart';
import '../models/order_item.dart';

class InputUserInfoScreen extends StatefulWidget {
  final List<OrderItem> orderItems;

  const InputUserInfoScreen({super.key, required this.orderItems});

  
  _InputUserInfoScreenState createState() => _InputUserInfoScreenState();
}

class _InputUserInfoScreenState extends State<InputUserInfoScreen> {
  final TextEditingController nameController = TextEditingController();
  final TextEditingController phoneController = TextEditingController();

  
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      body: SafeArea(
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Column(
            children: [
              TextField(
                controller: nameController,
                decoration: const InputDecoration(
                  labelText: 'Name',
                  border: OutlineInputBorder(),
                ),
              ),
              const SizedBox(height: 16.0),
              TextField(
                controller: phoneController,
                decoration: const InputDecoration(
                  labelText: 'Phone',
                  border: OutlineInputBorder(),
                ),
                keyboardType: TextInputType.phone,
              ),
              const SizedBox(height: 32.0),
              SizedBox(
                width: double.infinity,
                child: ElevatedButton(
                  onPressed: () {
                    String name = nameController.text;
                    String phone = phoneController.text;

                    Navigator.push(
                      context,
                      MaterialPageRoute(
                        builder: (context) => OrderConfirmationScreen(
                          name: name,
                          phone: phone,
                          orderItems: widget.orderItems,
                        ),
                      ),
                    );
                  },
                  style: ElevatedButton.styleFrom(
                    backgroundColor: Colors.blue,
                    shape: RoundedRectangleBorder(
                      borderRadius: BorderRadius.circular(12.0)),
                  ),
                  child: const Text(
                    'Confirm Order',
                    style: TextStyle(
                        fontSize: 14.0,
                        fontWeight: FontWeight.bold,
                        color: Colors.white),
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

6. 注文確認画面とCSVファイルの生成

ユーザーの入力した情報と選択した商品をCSVファイルに保存する画面を作成します。order_confirmation_screen.dart では、CSVファイルの生成と保存を行います。

order_confirmation_screen.dart
import 'dart:io';
import 'package:csv/csv.dart';
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
import '../models/order_item.dart';

class OrderConfirmationScreen extends StatelessWidget {
  final String name;
  final String phone;
  final List<OrderItem> orderItems;

  const OrderConfirmationScreen({
    super.key,
    required this.name,
    required this.phone,
    required this.orderItems,
  });

  Future<void> _writeToCsv(BuildContext context) async {
    var uuid = const Uuid();
    String orderId = uuid.v4();
    String timeStamp = DateTime.now().toIso8601String();

    List<List<dynamic>> orderRows = [
      ["OrderID", "Name", "Phone", "TimeStamp"],
      [orderId, name, phone, timeStamp],
    ];

    List<List<dynamic>> orderedItemRows = [
      ["OrderID", "ProductID", "ProductName", "ItemCount"],
      ...orderItems.map((item) => [orderId, item.productId, item.productName, item.count]),
    ];

    const dataDirectory = "/path/to/your/directory";
    final dataDirectory0 = Directory(dataDirectory);

    if (!await dataDirectory0.exists()) {
      await dataDirectory0.create(recursive: true);
    }

    final orderFile = File('$dataDirectory/order.csv');
    if (await orderFile.exists()) {
      final orderCsv = const ListToCsvConverter().convert(orderRows.skip(1).toList());
      await orderFile.writeAsString('$orderCsv\n', mode: FileMode.append);
    } else {
      final orderCsv = const ListToCsvConverter().convert(orderRows);
      await orderFile.writeAsString(orderCsv);
    }

    final orderedItemFile = File('$dataDirectory/ordered_item.csv');
    if (await orderedItemFile.exists()) {
      final orderedItemCsv = const ListToCsvConverter().convert(orderedItemRows.skip(1).toList());
      await orderedItemFile.writeAsString('$orderedItemCsv\n', mode: FileMode.append);
    } else {
      final orderedItemCsv = const ListToCsvConverter().convert(orderedItemRows);
      await orderedItemFile.writeAsString(orderedItemCsv);
    }

    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('Order Confirmation'),
        content: const Text('Order has been successfully processed.'),
        actions: [
          TextButton(
            onPressed: () {
              Navigator.popUntil(context, (route) => route.isFirst);
            },
            child: const Text('OK'),
          ),
        ],
      ),
    );
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      appBar: AppBar(
        title: const Text('Order Confirmation'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text('Name: $name', style: const TextStyle(fontSize: 18)),
            Text('Phone: $phone', style: const TextStyle(fontSize: 18)),
            const SizedBox(height: 16),
            const Text('Ordered Items:', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
            Expanded(
              child: ListView.builder(
                itemCount: orderItems.length,
                itemBuilder: (context, index) {
                  return ListTile(
                    title: Text(orderItems[index].productName),
                    subtitle: Text('Quantity: ${orderItems[index].count}'),
                  );
                },
              ),
            ),
            SizedBox(
              width: double.infinity,
              child: ElevatedButton(
                onPressed: () async {
                  showDialog(
                    context: context,
                    barrierDismissible: false,
                    builder: (BuildContext context) {
                      return const Center(child: CircularProgressIndicator());
                    },
                  );
                  await _writeToCsv(context);
                },
                style: ElevatedButton.styleFrom(
                  backgroundColor: Colors.blue,
                  shape: RoundedRectangleBorder(
                    borderRadius: BorderRadius.circular(12.0),
                  ),
                ),
                child: const Text('Confirm Order', style: TextStyle(fontSize: 14.0, fontWeight: FontWeight.bold, color: Colors.white)),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

7. モデルの作成

最後に、注文情報を管理するモデルを作成します。models/order_item.dart を作成し、注文情報のデータ構造を定義します。

models/order_item.dart
class OrderItem {
  final String productId;
  final String productName;
  final int count;
  final String imageUrl;

  OrderItem({
    required this.productId,
    required this.productName,
    required this.count,
    required this.imageUrl,
  });
}

FirebaseとCSVの比較

記事の最後に、Flutterでよく一緒に利用されるFirebaseとCSVファイル出力の比較と、それぞれのメリット・デメリットについても記載します。
また、今回Firebaseについての詳細は省略させていただきます。

Firebase

Firebaseのメリット

  1. リアルタイムデータベース:
    Firebaseはリアルタイムでデータの読み書きができるため、複数のユーザーが同時にデータにアクセスしても最新の情報を共有できます。

  2. スケーラビリティ:
    FirebaseはGoogleのインフラストラクチャ上に構築されており、大規模なトラフィックやデータ処理に対応できます。

  3. 認証サービス:
    Firebase Authenticationを利用することで、簡単にユーザー認証機能を追加でき、セキュアなアプリケーションを構築できます。

  4. 統合性:
    Firebaseは他のGoogleサービス(AnalyticsCrashlyticsCloud Messagingなど)と統合が容易です。

Firebaseのデメリット

  1. コスト:
    一定の無料利用枠がありますが、データの量やトラフィックが増えるとコストが発生します。

  2. インターネット接続の依存:
    データの保存や取得にはインターネット接続が必要です。オフライン環境での利用には工夫が必要です。

  3. 学習コスト:
    Firebaseの各サービスを理解し、使いこなすためには一定の学習が必要です。

CSV

CSVのメリット

  1. シンプルさ:
    CSVファイルはシンプルなテキストファイルで、どのプログラミング言語でも簡単に読み書きできます。
    データの可搬性が高いです。

  2. オフライン対応:
    CSVファイルはローカルストレージに保存できるため、インターネット接続が不要です。

  3. コスト:
    CSVファイルの作成や保存には追加コストがかかりません。

CSVのデメリット

  1. リアルタイム性の欠如:
    CSVファイルは静的なデータストレージ方法であり、リアルタイムのデータ同期や複数ユーザーの同時アクセスには向きません

  2. データの管理が煩雑:
    大量のデータや頻繁な更新がある場合、CSVファイルの管理が煩雑になる可能性があります。

  3. セキュリティ:
    CSVファイルは基本的に暗号化されていないため、機密データの取り扱いには向きません。

まとめ

今回の記事では、Flutterを使用して簡単にCSVファイルにデータを出力する方法を紹介しました。
CSVはそのシンプルさゆえに、多くのシナリオで有用です。
しかし、リアルタイム性やスケーラビリティが必要な場合は、FirebaseのようなBaaS(Backend as a Service)を検討することをお勧めします。アプリケーションの要件に応じて、適切なデータストレージ方法を選択してください。

Discussion