前回の記事で、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で設定します。
- Google Cloud Consoleの「APIとサービス」>「認証情報」を開く。
- iOS用のAPIキー(
iOS key (auto created by Firebase)など)をクリック。 - 「アプリケーションの制限」で「iOS アプリ」を選択。
- 「バンドル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ログインを済ませたら、以下のテストを行ってみましょう。
- PCのブラウザでFirebaseコンソールの「Firestore Database」を開きます。
- Macの画面に、FirebaseコンソールとiPhoneシミュレーターを横に並べて配置します。
- iPhoneシミュレーターから、右下の「+」ボタンで新しい習慣を追加します。
- 追加した瞬間、ブラウザのFirebaseコンソールにデータが「スッ」と表示されます!
- 今度は逆に、ブラウザのFirebaseコンソールから、追加したデータの
isDoneをtrueに手動で書き換えてみてください。 - iPhoneシミュレーターのチェックボックスに、触ってもいないのに勝手にチェックが入ります!
この「サーバーとスマホが常に直結している感覚」が、Firestore最大の魅力です。
まとめ:1つのコード、2つのOS、1つのデータベース
お疲れ様でした!
これで、あなたの作ったFlutterアプリは、「AndroidとiOSの両方で動き」「Googleログインでユーザーを識別し」「クラウド上でデータをリアルタイム共有する」という、立派なモダンアプリに成長しました。
同じコードベース(Dart)を書きながら、Androidは google-services.json、iOSは GoogleService-Info.plist といった各OSごとの設定ファイルで「鍵穴」を作り、1つのFirebaseプロジェクトに繋ぎ込む。
これがクロスプラットフォーム開発におけるバックエンド構築の王道パターンです。