Flutter WebアプリにCSVダウンロード・画像アップロードを追加した話

〜dart:ioの罠・CORSの壁・Firebase Storageの有料プラン〜

月謝袋アプリ(Flutter Web / PWA)の開発を続けています。

今回は先生向けの管理機能として、
「スタンプ誤登録の削除」
「CSVダウンロード」
「カスタムスタンプ画像のアップロード」の3つを実装しました。

どれもひとつひとつは小さな機能ですが、Flutter Web特有の壁にいくつかぶつかりました。

目次

機能① スタンプ誤登録を削除できるように

スタンプ一覧画面の各カードに、ゴミ箱アイコンを追加しました。先生が月謝スタンプを誤って登録してしまったときに、この画面から直接削除できます。

実装のポイント

  • 確認ダイアログ(「削除しますか?」)を必ず挟む
  • 削除後は _loadStamps() を呼んでリストを自動更新
  • 成功・失敗どちらもSnackBarで即座にフィードバック


生徒側の画面でも削除が即反映されることも確認。シンプルですが実用的な機能になりました。

機能② CSVダウンロード(Web対応)

設定タブの「CSVをダウンロード」ボタン。件数は正しく表示されているのにボタンを押しても何も起きない……という状態でした。原因は2つありました。

原因1:dart:io がWeb非対応

既存の実装は dart:io の File と path_provider、share_plus を使っていました。これらはすべてFlutter Web非対応です。ボタンを押しても何も起きないのはエラーが握りつぶされていたからでした。

原因2:Webでのファイルダウンロード方法が根本的に違う

ネイティブアプリでは「ファイルに書き込んでシェア」ですが、
Web(ブラウザ)では「Blobを作ってAタグをクリック」という方法でダウンロードします。

BLOBは「データベースに入れる、ファイルの中身そのもの」 です!
どんなものがBLOBになる?

🖼️ 画像(JPG、PNGなど)
🎵 音声・動画ファイル
📄 PDFや Wordファイル
その他、あらゆるバイナリファイル

文字や数字では表せない「ファイルそのもの」をデータベースに保存するときに使う入れ物がBLOBです。

解決策:conditional export で Web/ネイティブを自動切り替え
3ファイル構成で対応しました:
download_helper.dart ← export の振り分け役
download_helper_web.dart ← dart:html を使ってブラウザダウンロード
download_helper_stub.dart ← ネイティブ用の空実装

// download_helper.dart
export 'download_helper_stub.dart'
    if (dart.library.html) 'download_helper_web.dart';

これで kIsWeb の分岐も不要になり、Web/ネイティブで自動的に正しい実装が選ばれます。
BOM付きUTF-8で出力しているのでExcelで開いても文字化けしません。

「BOM」って何?
BOM = Byte Order Mark(バイト・オーダー・マーク)
一言で言うと…
「このファイルはUTF-8で書かれていますよ!」という目印をファイルの先頭にこっそり付けたものです。
ファイルの冒頭に EF BB BF という見えない3バイトの印が入っています。

料理で例えると🍱
UTF-8(BOMなし)中身だけ入ったお弁当箱
BOM付きUTF-8「和食です」とラベルが貼ってあるお弁当箱

機能③ カスタムスタンプ画像のアップロード

先生がオリジナルのスタンプ画像(例:自分でデザインしたはんこ画像など)を設定できる機能です。画像を選択するとFirebase Storageに保存し、全生徒のスタンプ表示に即反映されます。

壁1:dart:io の File がWebで使えない

❌ネイティブ用コード(Web非対応)

final file = File(image.path);
await ref.putFile(file);

✅ Web対応コード

final bytes = await image.readAsBytes();
await ref.putData(bytes);

XFile.readAsBytes() はWebでもネイティブでも動きます。putData() に変えるだけでOKでした。

壁2:Firebase StorageのCORSエラー

コードを修正してもアップロードがループする……コンソールを見ると:

Access to XMLHttpRequest blocked by CORS policy:
Response to preflight request doesn't pass access control check

Firebase Storageはデフォルトではどこからでもアクセスできるわけではありません。
localhost:7357 からのアクセスを許可するCORS設定が必要でした。

壁3:Firebase StorageはBlazeプラン(有料)が必要

Firebase Consoleで「Storage」を開くと「料金プランをアップグレードしてください」と表示。無料のSparkプランではStorageが使えないため、Blazeプラン(従量課金)にアップグレードしました。500円の予算アラートも設定。実際の使用料はほぼ0円です。


CORS設定手順

gcloud CLIって何?
簡単に言うと「GoogleのサービスをターミナルからコントロールできるツールAです。
Firebase ConsoleやGoogle Cloudの管理画面でやることを、コマンド1行でできます。
今回はFirebase Storageの「どこからのアクセスを許可するか」という設定をこのツールで書き込んでいます


gcloud CLI をHomebrewでインストール
gcloud auth login でGoogleアカウントにログイン
cors.json を作成(許可するオリジンを記載)


gcloud storage buckets update コマンドで適用
これで「アップロード中…」のぐるぐるが解消され、カスタムスタンプ画像が生徒画面に反映されました🎉

まとめ:

今日学んだことを整理します:

  • ファイル操作・シェア系のコードは dart:io / path_provider / share_plus → Web非対応。
    conditional export でWeb実装を切り替える
  • Firebase StorageはBlazeプラン必須。無料枠(5GB)で十分だが、予算アラートの設定を忘れずに
  • StorageをWebから使うにはCORS設定が必要。gcloud CLI で1コマンドで適用できる
  • putFile() → putData(bytes) に変えるだけでWebでも画像アップロードができる

小さな機能追加でもFlutter Webの罠は至るところにあります。
でも一つ一つ解決していくと確実にアプリが育っていく実感があります。
カスタムスタンプが生徒画面に反映された瞬間はとても嬉しかったです

目次