BLOG

ブログ

Flutterでカレーパーティの金額計算アプリ作ってみた

はじめに

こんにちは、ますだです。

弊社には月イチカレーパーティという制度(月に一度、勤務終了後にカレーを食べる会を開くと、会社から1人1,000円支給される制度です!)があります。

入社してからいままで、主催の方が開催してくださる会に参加していたのですが、今回初めて主催してみました。(主催っていろいろ大変なのだなと実感し、今まで主催してくださった方に改めて感謝の気持ちです…!)

主催が参加者全員のカレーを一括注文・支払いし、そのあと会社負担の1,000円を引いた差額を皆さんから支払ってもらうのですが、

初めての主催ということもあり、誰がなにを注文していくら支払ってもらうのかあたふたしそうだな〜と思ったので、金額計算をしてくれるアプリを作ってみました。

自分はFlutter初学者なのでほぼChatGPTに丸投げでしたが、根明の優しいギャルみたいな口調でなんでも教えてくれました。(´∀`∩)↑age↑

※この記事ではプロジェクト作成手順など詳細は省略しています。

仕様について

カレーパーティの出欠や注文内容は、調整さんを使っています。

調整さんでは記入してもらった出欠表の内容をCSV形式でダウンロードできるので、これをアップロードして、必要な情報を一覧表示できたらいいな〜という感じで、以下の仕様で作りました。

  • CSVをアップロードしたら、注文した人の名前と注文金額がリスト表示される
  • 注文金額から、会社から支給される1,000円を差し引いて、支払い金額を表示する
  • 実際に払ってもらった金額を入力すると、支払金額との差額(=お釣り)を表示する
  • 自分のiPhoneでちゃちゃっと確認できる

🐺🍛🥄🔥💰️👌🏻🧮

できたアプリの画面はこんな感じになりました。

アプリTOP画面
①アプリを開いたときの画面。CSVアップロードのボタンを押します。
CSVファイル選択画面
②ファイル選択画面が開くので、アップロードしたいCSVファイルを選びます。
ファイル読み込み後一覧表示画面
③CSVファイルを読み込み、注文した人の名前と各種金額、受取額入力欄などが一覧表示されます。
受取金額入力
④受取金額を入力すると、おつりの金額を自動計算して表示します。

コードについて

エントリーポイントおよび表示するCsvUploadPageはこのように記載しています。

importにネイティブプラットフォーム用とWeb判定用インポートとありますが、モバイル/デスクトップアプリの場合と、WEBの場合とでそれぞれアップロードしたファイルの読み込み方法を切り替えるために用意しています。

import 'package:flutter/material.dart';
import 'package:file_picker/file_picker.dart';
import 'dart:io'; // ネイティブプラットフォーム用
import 'dart:convert';
import 'package:csv/csv.dart';
import 'package:flutter/foundation.dart' show kIsWeb; // ★ Web判定用インポート

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

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

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: CsvUploadPage(),
      debugShowCheckedModeBanner: false,
    );
  }
}

_CsvUploadPageStateの中身は下記です。(調整さんで作成する出欠表の形式から取得したいものに照準をあわせたコードなので、汎用性はないです。。。)

_CsvUploadPageStateの中身
class _CsvUploadPageState extends State<CsvUploadPage> {
  List<Map<String, dynamic>> extractedData = [];
  Map<String, String> receivedAmounts = {}; // 受け取った金額を管理するマップ

  // 支払金額計算(計算時にカンマ除去)
  String _calculatePayAmount(String? amountStr) {
    if (amountStr == null) return '0';
    final amount = int.tryParse(amountStr.replaceAll(',', '')) ?? 0;
    final payAmount = amount - 1000;
    return payAmount.toString();
  }

  // おつり計算(計算時にカンマ除去)
  String _calculateChange(String? receivedStr, String? amountStr) {
    final received = int.tryParse(receivedStr ?? '') ?? 0;
    final expected = int.tryParse(_calculatePayAmount(amountStr)) ?? 0;
    final change = received - expected;
    return change.toString();
  }

  Future<void> _pickCsvFile() async {
    FilePickerResult? result = await FilePicker.platform.pickFiles(
      type: FileType.custom,
      allowedExtensions: ['csv'],
    );

    // ★ ファイルが選択され、かつパスまたはバイトデータが存在する場合
    if (result != null && (result.files.single.path != null || result.files.single.bytes != null)) {
      final pickedFile = result.files.single;
      String csvString;

      try {
        // ★ Webかそれ以外のプラットフォームかで読み込み方法を切り替える
        if (kIsWeb) {
          // ★ Webの場合: バイトデータから文字列に変換
          if (pickedFile.bytes != null) {
            csvString = utf8.decode(pickedFile.bytes!);
          } else {
            // バイトデータがない場合は処理を中断
            print('Web: ファイルのバイトデータが利用できません');
            return;
          }
        } else {
          // ★ Web以外(ネイティブ)の場合: ファイルパスから文字列に変換
          if (pickedFile.path != null) {
            final path = pickedFile.path!;
            // stream().transform(utf8.decoder).join() に近い処理を readAsString で行う
            csvString = await File(path).readAsString(encoding: utf8);
          } else {
             // ファイルパスがない場合は処理を中断
             print('Native: ファイルパスが利用できません');
             return;
          }
        }
      } catch (e) {
        print('ファイルの読み込み中にエラーが発生しました: $e');
        return;
      }

      // ★ 読み込んだCSV文字列をパース
      final fields = const CsvToListConverter(eol: '\n').convert(csvString);

      final data = <Map<String, dynamic>>[];

      // ヘッダー行の検出
      int startIndex = -1;
      for (int i = 0; i < fields.length; i++) {
        final row = fields[i];
        if (row.isNotEmpty && row[0]?.toString().trim() == '参加者') {
          startIndex = i + 1;
          break;
        }
      }

      // データ行処理
      for (int i = startIndex; i < fields.length; i++) {
        final row = fields[i];

        // 行または最初のセル(参加者名)が空の場合はスキップ
        if (row.isEmpty || row[0]?.toString().trim().isEmpty == true) continue;
         // 最低限のカラム数チェック
        if (row.length < 2) continue;

        final name = row[0]?.toString().trim() ?? '';

        // コメントを取得(3番目のカラムに存在する場合)
        final comment = row.length >= 3 ? row[2]?.toString().trim() ?? '' : '';

        // コメント記載から金額を抽出
        final match =
            RegExp(r'(¥|¥)\s*([\d,]+)|([\d,]+)\s*(円|¥|¥)').firstMatch(comment);

        String? amount;
        if (match != null) {
           // グループ1 (¥ の後の数字) または グループ2 (数字の後の 円 など) を取得
           final rawAmount = match.group(2) ?? match.group(3);
           amount = rawAmount?.replaceAll(',', ''); // カンマを除去
           print('→ 金額抽出成功: $amount 円');
           // 金額が抽出できた場合のみデータに追加
           if (amount != null) { // null チェック
             data.add({'name': name, 'amount': amount, 'checked': false});
           }
        } else {
          print('→ 金額見つからず');
        }
      }

      setState(() {
        extractedData = data;
        // 新しいデータを読み込んだら受取金額をリセット
        receivedAmounts = {};
      });

    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        toolbarHeight: 100,
        title: const Padding(
          padding: EdgeInsets.symmetric(vertical: 12),
          child: Text(
            '🐺🍛🥄🔥💰️👌🏻🧮',
            style: TextStyle(fontSize: 36),
          ),
        ),
      ),
      body: Center(
        child: Column(
          children: [
            Padding(
              padding: const EdgeInsets.symmetric(vertical: 12),
              child: ElevatedButton(
                onPressed: _pickCsvFile,
                child: const Text('CSVアップロード'),
              ),
            ),
            const SizedBox(height: 20),
            Expanded(
              child: extractedData.isEmpty
                  ? const Text('データがありません')
                  : ListView.builder(
                      itemCount: extractedData.length,
                      itemBuilder: (context, index) {
                        final item = extractedData[index];
                        final itemName = item['name']?.toString().trim() ?? '';
                        final itemAmount = item['amount']?.toString() ?? '0';
                        // 受取金額の取得
                        final currentReceivedAmount = receivedAmounts[itemName] ?? '';

                        return Padding(
                          padding: const EdgeInsets.symmetric(horizontal: 16.0),
                          child: ListTile(
                            title: Row(
                              children: [
                                Text('■ ${itemName}さん',
                                    style: const TextStyle(fontSize: 24)),
                                const SizedBox(width: 8),
                                Text('(注文金額: ${itemAmount}円)',
                                    style: const TextStyle(
                                        fontSize: 14, color: Colors.black45)),
                              ],
                            ),
                            subtitle: Column(
                              crossAxisAlignment: CrossAxisAlignment.start,
                              children: [
                                Text(
                                    '支払金額: ${_calculatePayAmount(itemAmount)}円',
                                    style: const TextStyle(fontSize: 18)),
                                const SizedBox(height: 8),
                                Row(
                                  children: [
                                    const Text('受取:',
                                        style: TextStyle(fontSize: 14)),
                                    const SizedBox(width: 4),
                                    SizedBox(
                                      width: 80,
                                      child: TextFormField(
                                        initialValue: currentReceivedAmount,
                                        keyboardType: TextInputType.number,
                                        onChanged: (value) {
                                          setState(() {
                                            receivedAmounts[itemName] = value;
                                          });
                                        },
                                        decoration: const InputDecoration(
                                          border: OutlineInputBorder(),
                                          isDense: true,
                                          contentPadding: EdgeInsets.symmetric(
                                            vertical: 8,
                                            horizontal: 8,
                                          ),
                                        ),
                                      ),
                                    ),
                                    const SizedBox(width: 12),
                                    Text(
                                      'おつり: ${_calculateChange(currentReceivedAmount, itemAmount)}円',
                                      style: const TextStyle(fontSize: 14),
                                    ),
                                  ],
                                ),
                                const SizedBox(height: 10),
                                const Divider(color: Colors.black12),
                              ],
                            ),
                          ),
                        );
                      },
                    ),
            ),
          ],
        ),
      ),
    );
  }
}

作ってみた感想として、Widgetの使い方に慣れれば組み合わせ次第で簡単にUIの構成と調整ができるのが便利だなと感じました。今回は見た目のみの機能なので、もう少し入り組んだ機能実装などもしてみたいと思いました。

また、マルチプラットフォーム開発ができるフレームワークとして、環境構築が簡単にできるのもいいポイントだと思います。(同じくマルチプラットフォーム開発ができるフレームワークのReactNativeではビルドに手こずりまくった記憶があるため)

作ったのに当日使わなかった…

せっかく作ったアプリでしたが、仕様にも書いた「自分のiPhoneでちゃちゃっと確認できる」が出来なかったため、当日は使えませんでした。

(あと、皆さんおつりが極力ないorすぐ暗算できる金額で支払ってくださった(ネ申)ので、そもそも必要なかった)

アプリとしてでもWEB版でも手元で開けたらよかったのですが、色々とエラーが出たりうまく開けなかったりと直前まで粘ったのですがうまくできなかったので諦めました。。。(余裕を持って準備する大切さを再確認できましたね)

この記事を書くために、エラーやローカルホスト接続がうまくいかなかった原因を調べて対処法を実施してみたら普通にできました、、、このあたりも後ほどまとめたいです。

ではまた!