はじめに
「あれ、どこに置いたっけ?」という経験は誰にでもあるのではないでしょうか。
この記事では、Flutter Web と Firebase を使用して、持ち物の保管場所を管理するWebアプリケーションを実装した経験を共有します。
アプリの概要
「AreDoko」は、以下の機能を持つ持ち物管理Webアプリです:
- アイテムの登録・編集・削除
- バーコードスキャンによる商品情報取得
- 画像アップロードと AI による自動分類
- 賞味期限管理とプッシュ通知
- カテゴリー別の管理機能
技術スタック
フロントエンド(Flutter Web)
- Flutter 3.24.3
- 状態管理: flutter_bloc
- データクラス生成: freezed
- JSON シリアライズ: json_serializable
バックエンド(Firebase)
- Authentication: ユーザー認証
- Cloud Firestore: データベース
- Cloud Storage: 画像保存
- Cloud Functions: バッチ処理
- Cloud Messaging: プッシュ通知
外部API
- Rakuten Product API: 商品情報取得
- Gemini 1.5 Flash: 画像分析
アーキテクチャ設計
フロントエンド
Clean Architecture を採用し、以下のレイヤー構成としました:
lib/
├── bloc/ # Presentation層(状態管理)
├── data/ # Data層(Data Provider, Repository実装)
├── domain/ # Domain層(Entity, Repository interface)
├── screen/ # Presentation層(UI)
├── usecase/ # UseCase層
└── util/ # ユーティリティ
バックエンド
Cloud Functions は TypeScript で実装し、以下の構成としました:
src/
├── common/ # 共通ユーティリティ
├── data/ # Data層(Data Provider, Repository実装)
├── domain/ # Domain層(Entity, Repository interface)
├── usecase/ # UseCase層
└── util/ # ユーティリティ関数
主要な実装ポイント
1. BLoCパターンによる状態管理
@freezed
class ItemState with _$ItemState {
const factory ItemState.initial() = _Initial;
const factory ItemState.loading() = _Loading;
const factory ItemState.loaded(List<Item> items) = _Loaded;
const factory ItemState.error(String message) = _Error;
}
2. バーコードスキャン機能
import 'package:simple_barcode_scanner/simple_barcode_scanner.dart';
// バーコードから商品情報を取得する機能
Future<void> scanBarcode() async {
try {
// SimpleBarcodeScannerPageを使用してバーコードをスキャン
final result = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const SimpleBarcodeScannerPage(),
),
);
// スキャンが成功した場合(-1はキャンセル時の戻り値)
if (result is String && result != '-1') {
// 楽天商品検索APIを使用して商品情報を取得
final product = await _rakutenApiProvider.fetchProductInfo(result);
if (product.items.isNotEmpty) {
final item = product.items.first;
// 取得した商品情報を使用して処理を実行
await _updateItemInfo(
name: item.itemName,
imageUrl: item.mediumImageUrls.firstOrNull ?? '',
productUrl: item.itemUrl,
);
}
}
} catch (e) {
log('バーコードスキャン中にエラーが発生: $e');
// エラーハンドリング(UIへの通知など)
_showError('商品情報の取得に失敗しました');
}
}
3. 賞味期限通知機能(Cloud Functions)
export const expirationDateNotification = functions.pubsub
.schedule('0 8 * * *')
.timeZone('Asia/Tokyo')
.onRun(async () => {
try {
const usecase = new ExpirationNotificationUsecase();
await usecase.checkExpiringItems();
} catch (error) {
console.error("賞味期限通知の処理中にエラーが発生しました:", error);
throw error;
}
return null;
});
なお、アプリのUI上では、ハザードマークアイコン(⚠️)をタップすることで、賞味期限が近い順にアイテムをソートする機能を実装しています。これにより、ユーザーは期限切れが近づいているアイテムを素早く確認することができます。
// 賞味期限でのソート機能
if (_showExpirationDateItems) {
filteredItems.sort((a, b) {
if (a.expirationDate == null) return 1;
if (b.expirationDate == null) return -1;
return a.expirationDate!.compareTo(b.expirationDate!);
});
}
4. Gemini AIによる画像分析
(1)アイテム名推論およびカテゴリー分析
Future<String> analyzeImage(File imageFile) async {
try {
// 画像をBase64に変換
final bytes = await imageFile.readAsBytes();
final base64Image = base64Encode(bytes);
// Geminiモデルの初期化
final model = GenerativeModel(
model: 'gemini-1.5-flash',
apiKey: environment.geminiApiKey,
);
// プロンプトの設定
final prompt = '''
この画像に写っている場所や収納スペースの特徴を分析し、
最も適切なカテゴリー名を提案してください。
例:キッチン、リビング、クローゼット、本棚、引き出し等
''';
// 画像分析の実行
final response = await model.generateContent([
Content.text(prompt),
Content.image(base64Image),
]);
// カテゴリー名の抽出と返却
return _extractCategoryName(response.text);
} catch (e) {
throw Exception('画像分析中にエラーが発生しました: $e');
}
}
(2)場所カテゴリーの推論
Future<void> analyzeImageLocation(File imageFile) async {
try {
// 画像をバイト配列に変換
final bytes = await imageFile.readAsBytes();
// プロンプトの設定
final prompt = TextPart('''
この画像に写っている場所や収納スペースの特徴を分析し、
最も適切なカテゴリー名を提案してください。
例:キッチン、リビング、クローゼット、本棚、引き出し等
回答フォーマット:
カテゴリー名:[提案するカテゴリー名]
''');
// 画像データの準備
final imagePart = DataPart('image/jpeg', bytes);
// Geminiモデルによる分析実行
final response = await _model.generateContent([
Content.multi([prompt, imagePart]),
]);
// カテゴリー名の抽出
final categoryName = _extractCategoryName(response.text);
if (categoryName.isNotEmpty) {
// 抽出したカテゴリー名を設定
_categoryNameController.text = categoryName;
} else {
throw Exception('カテゴリー名の抽出に失敗しました');
}
} catch (e) {
throw Exception('場所の分析中にエラーが発生しました: $e');
}
}
5. デプロイの容易さ
以下のコマンド1つで、アプリをFirebase Hostingにデプロイし、即座にユーザーに公開できます:
flutter build web --web-renderer html --release; firebase deploy --only hosting
これは個人開発者にとって大きなメリットです。実際、このアプリの開発を決意した最大の理由の一つがこれでした。従来のiOSアプリ開発では、Apple Developer Program(年間99ドル≒10,000円)への加入が必須で、App Store審査も必要でした。さらに、開発したiOSアプリを個人で普段利用するだけでも、プロビジョニングプロファイルの有効期限が1週間に1回失効し、毎週手動で発行作業が必要で面倒でした。
一方、Firebase HostingとPWAの組み合わせなら、プッシュ通知などのネイティブアプリ同等の機能を持つアプリを、無料で即座にリリースできます。さらに、Gemini 1.5 FlashなどのGoogle製生成AIも一定量まで無料で利用可能で、Cursor Proなどの生成AI搭載IDEに月額$20程度課金すれば、コーディングの効率を大幅に向上させることができます。また、2023年3月リリースのiOS 16.4からSafariでもWebプッシュ通知が利用可能になり、PWAの実用性が大きく向上しました。開発者体験(DX)の観点から見ても、この手軽さは画期的です。
実装時の工夫点
-
オフライン対応
- Firestoreの永続化設定を有効化
- キャッシュサイズを無制限に設定
-
UX改善
- ローディング状態の適切な表示
- エラーハンドリングの統一
- レスポンシブデザインの実装
-
入力の効率化
- バーコードスキャン、写真撮影によるAI分析、複数アイテムの一括登録、連続登録など、様々な入力方法を実装し、ユーザーの手間を最小限に抑制
- 検索条件がない場合は10件ずつページネーション表示し、スクロール時に追加フェッチすることでパフォーマンスを最適化
- Firestoreの制限(部分一致検索非対応)に対する工夫として、検索処理を2段階に分けて実装:
(1) まず前方一致検索を試行
(2) 前方一致で結果が得られない場合、該当ユーザーのアイテム全件を取得し、メモリ上でcontainsによる部分一致検索を実行
これにより、ユーザーにストレスのない検索体験を提供
苦労した点・解決方法
-
Web版でのカメラ対応
- iOS Safariでの制限対応
- PWA対応時の問題解決
-
プッシュ通知の実装
- Service Worker の設定
- FCMトークンの管理方法
-
状態管理の設計
- BLoCパターンの適切な粒度
- 状態の永続化方法
今後の展望
-
機能追加
- 検索機能の強化
- AI機能の強化
- 課金機能追加
- 家族共有機能の実装
-
技術的改善
- ユニットテスト実装
- 細かなバグ修正
- パフォーマンスの最適化
- アーキテクチャー周りのリファクタ
-
UI/UX改善
- アプリアイコン作成
- その他UI/UX改善
まとめ
Flutter Web と Firebase を組み合わせることで、比較的少ない工数でリッチな機能を持つWebアプリケーションを実装することができました。特に、BLoCパターンによる状態管理とClean Architectureの採用により、保守性の高いコードベースを維持できています。
参考リンク
著者について
普段はモバイルアプリ開発をしているエンジニアです。
Flutter/Dart、Firebase、TypeScriptなどの技術に興味があります。