🔰基礎技術

【Flutter実践】iPhoneアプリのデータをクラウドに保存する|Cloud FirestoreでToDoリストを作る手順

前回の記事で、iOSアプリに「Googleログイン」の鍵をかけることができました。
今回は、いよいよユーザーごとのデータをクラウドに保存する「Cloud Firestore」を連携させます。

「Androidの時に書いたコードと違うの?」と心配になるかもしれませんが、安心してください。Flutterの力により、DartのコードはAndroid版と全く同じものが使えます。

しかし、iOS特有の「裏側の仕組み」や「キーの守り方」は知っておく必要があります。一つずつ確認していきましょう。

1. FirestoreのおさらいとiOS版の違い

Android編の記事でも解説した通り、モバイル版のFirestoreには強力な機能が備わっています。

  • オフライン対応: 電波がなくてもアプリが動き、ネットに繋がった瞬間に自動同期する。
  • リアルタイム更新: StreamBuilder を使うことで、クラウド上のデータが変わるとスマホの画面が「勝手に」書き換わる。

iOSとAndroidの決定的な違い

コードは同じですが、裏側で動いている「部品集め(パッケージ管理)」の仕組みが違います。

  • Android: Gradle という仕組みで部品を集めます。
  • iOS: CocoaPods(ココアポッズ) という仕組みが動きます。

FirestoreのiOS用部品(SDK)は非常に巨大なため、パッケージを追加した直後の「初めてのビルド(実行)」は、Macのファンが唸るほど時間がかかります(5分〜15分程度)。
エラーで止まったわけではないので、コーヒーでも淹れて気長に待ちましょう。

2. iOS特有のファイルとキーの保護(超重要)

前回のログイン実装時に、Xcodeを使って GoogleService-Info.plist というファイルを入れましたね。この中にFirestoreに接続するための API_KEY が含まれています。

Androidの google-services.json と同様、このファイルはGitにコミットされるため、キー自体を隠すことはできません。
したがって、Google Cloud Console側で「このAPIキーは、私のiPhoneアプリからしか使えない」というロック(制限)をかけるのが絶対のルールです。

🔒 iOSアプリのAPIキー制限手順

Firebaseの画面からではなく、裏側のGoogle Cloud Consoleで設定します。

  1. Google Cloud Consoleの「APIとサービス」>「認証情報」を開く。
  2. iOS用のAPIキー(iOS key (auto created by Firebase) など)をクリック。
  3. 「アプリケーションの制限」で「iOS アプリ」を選択。
  4. 「バンドルID」の欄に、Xcodeで設定したあなたのアプリのID(例: tech.simplekits.habitTracker)を入力して保存。

これで、万が一 GoogleService-Info.plist が他人に漏れても、他人のアプリからはあなたのFirestoreを勝手に操作できなくなります。

3. 【準備】パッケージの追加

VS Codeのターミナルを開き、Firestoreのパッケージを追加します。
※Android編ですでに追加している場合は、スキップしても大丈夫です。

flutter pub add cloud_firestore

4. 【実装】iOS版 習慣トラッカーの完成

いよいよコードを書きます。
前回のログイン機能のコードに、Android編で作った StreamBuilder を使ったFirestoreの読み書き機能を合体させます。

lib/main.dart を以下のコードにまるごと書き換えてください。(データベースのセキュリティルールは、Android編で設定したものがそのまま適用されます)

import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:cloud_firestore/cloud_firestore.dart'; // 追加

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Habit Tracker',
      theme: ThemeData(primarySwatch: Colors.indigo, useMaterial3: true),
      // ログイン状態を監視
      home: StreamBuilder<User?>(
        stream: FirebaseAuth.instance.authStateChanges(),
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.waiting) {
            return const Center(child: CircularProgressIndicator());
          }
          if (snapshot.hasData) {
            return const HabitListScreen(); // ログイン済みならリストへ
          }
          return const LoginScreen(); // 未ログインならログイン画面へ
        },
      ),
    );
  }
}

// === ログイン画面 ===
class LoginScreen extends StatelessWidget {
  const LoginScreen({super.key});

  Future<void> _signInWithGoogle() async {
    try {
      final GoogleSignInAccount? googleUser = await GoogleSignIn().signIn();
      if (googleUser == null) return;
      final GoogleSignInAuthentication googleAuth = await googleUser.authentication;
      final credential = GoogleAuthProvider.credential(
        accessToken: googleAuth.accessToken,
        idToken: googleAuth.idToken,
      );
      await FirebaseAuth.instance.signInWithCredential(credential);
    } catch (e) {
      print("ログインエラー: $e");
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: ElevatedButton.icon(
          icon: const Icon(Icons.login),
          label: const Text('Googleでログイン'),
          onPressed: _signInWithGoogle,
        ),
      ),
    );
  }
}

// === 習慣リスト画面 ===
class HabitListScreen extends StatefulWidget {
  const HabitListScreen({super.key});
  @override
  State<HabitListScreen> createState() => _HabitListScreenState();
}

class _HabitListScreenState extends State<HabitListScreen> {
  // ログイン中のユーザーIDを取得
  final String uid = FirebaseAuth.instance.currentUser!.uid;

  // 習慣を追加する関数
  void _addHabit() {
    showDialog(
      context: context,
      builder: (context) {
        String newHabit = '';
        return AlertDialog(
          title: const Text('新しい習慣を追加'),
          content: TextField(
            autofocus: true,
            onChanged: (value) => newHabit = value,
          ),
          actions: [
            TextButton(
              onPressed: () async {
                if (newHabit.isNotEmpty) {
                  // Firestoreに保存
                  await FirebaseFirestore.instance
                      .collection('users')
                      .doc(uid)
                      .collection('habits')
                      .add({
                    'title': newHabit,
                    'createdAt': FieldValue.serverTimestamp(),
                    'isDone': false,
                  });
                }
                if (context.mounted) Navigator.pop(context);
              },
              child: const Text('追加'),
            ),
          ],
        );
      },
    );
  }

  // 習慣を削除する関数
  void _deleteHabit(String docId) {
    FirebaseFirestore.instance
        .collection('users')
        .doc(uid)
        .collection('habits')
        .doc(docId)
        .delete();
  }

  // チェックボックスを切り替える関数
  void _toggleHabit(String docId, bool currentStatus) {
    FirebaseFirestore.instance
        .collection('users')
        .doc(uid)
        .collection('habits')
        .doc(docId)
        .update({'isDone': !currentStatus});
  }

  @override
  Widget build(BuildContext context) {
    final user = FirebaseAuth.instance.currentUser;

    return Scaffold(
      appBar: AppBar(
        title: Text('${user?.displayName ?? "自分"}の習慣'),
        actions: [
          IconButton(
            icon: const Icon(Icons.logout),
            onPressed: () => FirebaseAuth.instance.signOut(),
          )
        ],
      ),
      // ▼ ここが魔法のパーツ「StreamBuilder」
      body: StreamBuilder<QuerySnapshot>(
        stream: FirebaseFirestore.instance
            .collection('users')
            .doc(uid)
            .collection('habits')
            .orderBy('createdAt', descending: true)
            .snapshots(),
        builder: (context, snapshot) {
          if (snapshot.hasError) return const Center(child: Text('エラーが発生しました'));
          if (snapshot.connectionState == ConnectionState.waiting) {
            return const Center(child: CircularProgressIndicator());
          }

          final docs = snapshot.data!.docs;
          if (docs.isEmpty) return const Center(child: Text('+ボタンから習慣を追加しましょう!'));

          return ListView.builder(
            itemCount: docs.length,
            itemBuilder: (context, index) {
              final data = docs[index].data() as Map<String, dynamic>;
              final docId = docs[index].id;

              return Card(
                margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
                child: ListTile(
                  leading: Checkbox(
                    value: data['isDone'] ?? false,
                    onChanged: (val) => _toggleHabit(docId, data['isDone'] ?? false),
                  ),
                  title: Text(data['title'] ?? ''),
                  trailing: IconButton(
                    icon: const Icon(Icons.delete, color: Colors.grey),
                    onPressed: () => _deleteHabit(docId),
                  ),
                ),
              );
            },
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _addHabit,
        child: const Icon(Icons.add),
      ),
    );
  }
}

5. 動作確認(リアルタイム同期の感動を再び!)

VS Codeから「Start iOS Simulator」を選び、F5キーでデバッグ実行を開始します。

⚠️ 覚悟しておいてください:初回ビルドの壁

ここで初めてFirestoreを入れたiOSビルドを走らせると、裏側で CocoaPods が大量のFirestore用C++ファイルをダウンロード・コンパイルします。
Macのスペックによっては5分〜15分ほど画面が止まったように見えますが、異常ではありません。コンソールにエラー(赤文字)が出ない限り、ひたすら待ってください。2回目以降は一瞬で起動するようになります。

リアルタイム同期を試す

無事にシミュレーターが立ち上がり、Googleログインを済ませたら、以下のテストを行ってみましょう。

  1. PCのブラウザでFirebaseコンソールの「Firestore Database」を開きます。
  2. Macの画面に、FirebaseコンソールとiPhoneシミュレーターを横に並べて配置します。
  3. iPhoneシミュレーターから、右下の「+」ボタンで新しい習慣を追加します。
  4. 追加した瞬間、ブラウザのFirebaseコンソールにデータが「スッ」と表示されます!
  5. 今度は逆に、ブラウザのFirebaseコンソールから、追加したデータの isDonetrue に手動で書き換えてみてください。
  6. iPhoneシミュレーターのチェックボックスに、触ってもいないのに勝手にチェックが入ります!

この「サーバーとスマホが常に直結している感覚」が、Firestore最大の魅力です。

まとめ:1つのコード、2つのOS、1つのデータベース

お疲れ様でした!
これで、あなたの作ったFlutterアプリは、「AndroidとiOSの両方で動き」「Googleログインでユーザーを識別し」「クラウド上でデータをリアルタイム共有する」という、立派なモダンアプリに成長しました。

同じコードベース(Dart)を書きながら、Androidは google-services.json、iOSは GoogleService-Info.plist といった各OSごとの設定ファイルで「鍵穴」を作り、1つのFirebaseプロジェクトに繋ぎ込む。
これがクロスプラットフォーム開発におけるバックエンド構築の王道パターンです。

-🔰基礎技術