Flutterでログイン状態を保持しようとしたら苦戦した話


## 毎回ログインするのめんどくさい。

ログイン画面を作って、認証できるようにした結果、デバッグを再起動する際に毎回ログインするのがめんどくさい…

そういえば、普通のアプリは毎回ログインしないよなと思ってログイン状態を保持する方法を調べてみました。

## とりあえずここを見とけば間違いない

調べた結果、Firebaseの機能でできるみたい。
[こちらのサイト](https://zenn.dev/flutteruniv_dev/articles/902b184146ea94)と[こちらの記事](https://qiita.com/KosukeSaigusa/items/b19ec19379c5a2e4ceb2)
でほぼやりたいことはできていました。
大変助かりました。ありがとうございます。

## やってること
### firebase_authを使う
元々Firebaseの認証を使っていたので、ここはそのままでOK。
なければ`pubspec.yaml`に追加。
```
dependencies:
  firebase_auth: ^4.17.4

```

### FirebaseAuth.instance.authStateChanges()でログイン状態を確認
Firebaseのドキュメントを確認すると、
```
Firebase Auth では、Stream を使用して、この状態をリアルタイムで取得できます。Stream が呼び出されると、ユーザーの現在の認証状態を即時に提供します。また、認証状態が変更されるたびに、後続のイベントを提供します。
```
[公式ドキュメント](https://firebase.google.com/docs/auth/flutter/start?hl=ja)

で、`authStateChanges ()`メソッドを使用すると、ユーザーがログインしてるかどうかわかるということでした。
### StreamBuilder を使って上記のメソッドを呼び出す 

とりあえず参考先と同じように。

```
main.dart


class App extends StatelessWidget { 
  @override
    Widget build(BuildContext context) => MaterialApp(
    title: 'Flutter app',
    home: StreamBuilder<User?>(
    stream: FirebaseAuth.instance.authStateChanges(),
    builder: (context, snapshot) {
    if (snapshot.connectionState == ConnectionState.waiting) { // スプラッシュ画面など
    return const SizedBox();
    }
    if (snapshot.hasData) {
    // User が null でなないのでログイン済みの画面へ
    return Home();
    }
    // User が null である、つまり未サインインのサインイン画面へ
    return LoginPage();
     },
   ),
  );
}
```

## なんとなくできた。けど…

ユーザーの認証状態に応じて、ログインページとログイン後のページを切り替えることはできました。
ただ、ログイン時にユーザーを取得して、ログイン後のページに反映させたい場合、上記のユーザーがnullじゃない状態のときにユーザーの情報を取得する内容を追加しなければなりませんでした。

### とりあえず追加してみる
もとからあるgetUser関数
```
class UserFirestore{
  static Future<dynamic> getUser(String uid) async {  
  try {  
    DocumentSnapshot documentSnapshot = await users.doc(uid).get();  
    if (documentSnapshot.data() != null) {  
      Map<String, dynamic> data = documentSnapshot.data() as Map<String, dynamic>;  
      Account myAccount = Account(  
        id: uid,  
        name: data['name'],  
        email: data['email'],  
        imagePath: data['image_path'],  
        createdTime: data['created_time'],  
      );  
      Authentication.myAccount = myAccount;  
      print('ユーザー取得完了');  
      return true;  
    } else {  
      return false;  
    }  
  } on FirebaseException catch (e) {  
    print('ユーザー取得エラー: $e');  
    return false;  
  }  
}
}

```

snapshotがデータを持っていた時に動くようにする
```
main.dart 


if (snapshot.hasdata) {
  UserFirestore .getUser(snapshot.data!.uid);
  return const Home();
  
}

```

だめでした。到達はしますが、一回`Null check operator used on a null value.`のエラー画面を挟んでしまいます。

nullを許容してみますが、ユーザーが取得できていない状態でもログイン後のページに遷移してしまうようになり断念。

というか、よくよく観察していると、どうもStreamBuilderのstreamが2回走っている？？

### StreamBuilder を学ぶ

なんでかよくわからないので、一旦StreamBuilder の公式を読み、サンプルを取得して動かしてみました。

```
class StreamBuilderExample extends StatefulWidget {  
  const StreamBuilderExample({super.key});  
  
  @override  
  State<StreamBuilderExample> createState() => _StreamBuilderExampleState();  
}  
  
class _StreamBuilderExampleState extends State<StreamBuilderExample> {  
  final Stream<int> _bids = (() {  
    late final StreamController<int> controller;  
    controller = StreamController<int>(  
      onListen: () async {  
        await Future<void>.delayed(const Duration(seconds: 3));  
        print('seconds 3');  
        controller.add(3);  
        await Future<void>.delayed(const Duration(seconds: 1));  
        print('seconds 1');  
        await controller.close();  
        print('close');  
      },  
    );  
    return controller.stream;  
  })();  
  
  @override  
  Widget build(BuildContext context) {  
    return DefaultTextStyle(  
      style: Theme.of(context).textTheme.displayMedium!,  
      textAlign: TextAlign.center,  
      child: Container(  
        alignment: FractionalOffset.center,  
        color: Colors.white,  
        child: StreamBuilder<int>(  
          stream: _bids,  
          builder: (BuildContext context, AsyncSnapshot<int> snapshot) {  
            List<Widget> children;  
            print('start');  
            if (snapshot.hasError) {  
              children = <Widget>[  
                const Icon(  
                  Icons.error_outline,  
                  color: Colors.red,  
                  size: 60,  
                ),  
                Padding(  
                  padding: const EdgeInsets.only(top: 16),  
                  child: Text('Error: ${snapshot.error}'),  
                ),  
                Padding(  
                  padding: const EdgeInsets.only(top: 8),  
                  child: Text('Stack trace: ${snapshot.stackTrace}'),  
                ),  
              ];  
            } else {  
              switch (snapshot.connectionState) {  
                case ConnectionState.none:  
                  print('none');  
                  children = const <Widget>[  
                    Icon(  
                      Icons.info,  
                      color: Colors.blue,  
                      size: 60,  
                    ),  
                    Padding(  
                      padding: EdgeInsets.only(top: 16),  
                      child: Text('Select a lot'),  
                    ),  
                  ];  
                case ConnectionState.waiting:  
                  print('waiting');  
                  children = const <Widget>[  
                    SizedBox(  
                      width: 60,  
                      height: 60,  
                      child: CircularProgressIndicator(),  
                    ),  
                    Padding(  
                      padding: EdgeInsets.only(top: 16),  
                      child: Text('Awaiting bids...'),  
                    ),  
                  ];  
                case ConnectionState.active:  
                  print('active');  
                  children = <Widget>[  
                    const Icon(  
                      Icons.check_circle_outline,  
                      color: Colors.green,  
                      size: 60,  
                    ),  
                    Padding(  
                      padding: const EdgeInsets.only(top: 16),  
                      child: Text('\$${snapshot.data}'),  
                    ),  
                  ];  
                case ConnectionState.done:  
                  print('done');  
                  children = <Widget>[  
                    const Icon(  
                      Icons.info,  
                      color: Colors.blue,  
                      size: 60,  
                    ),  
                    Padding(  
                      padding: const EdgeInsets.only(top: 16),  
                      child: Text('\$${snapshot.data} (closed)'),  
                    ),  
                  ];  
              }  
            }  
  
            return Column(  
              mainAxisAlignment: MainAxisAlignment.center,  
              children: children,  
            );  
          },  
        ),  
      ),  
    );  
  }  
}
```

switchを使ってそれぞれのConnectionStateごとに分けてる方法が紹介されていました。

わかりやすいようにそれぞれのcaseごとに何をしているかprintを追加しておきます。

2回走るのはFirebaseAuth のくせなのかと思いましたが、こちらも`done`になるまで繰り返します。

なるほど。streamの処理をこなすごとに最初からビルドされる。で、`done`になるまで動いてる、と。

ということは、FirebaseAuthのConnectionState を見ればいいわけか？と仮説をたてました。

### 修正してみる
[StremaBuilderドキュメント](https://api.flutter.dev/flutter/widgets/StreamBuilder-class.html)のサンプルを参考にswitch文にし、ConnectionState の値を見て`done`になったらログイン後のページに遷移するように修正してみましたが、`done`に到達しません。

どうも`active`の状態で止まっているようです。

FirebaseAuth のauthStateChanges は状態を監視しているため、完了せずずっと`active`になっているのではないかと思い、active判定しているcaseに処理を書いたらその通りでした。

ただ、その状態だと結局2回以上処理が走ってしまうのは変わりませんでした。

なので、サンプルを参考に処理の関数を作成します。
```
final Stream<User?> _stream = (() {  
  late final StreamController<User?> controller;  
  controller = StreamController<User?> (  
    onListen: () async {  
      await Future<void>.delayed(const Duration(seconds: 3));  
      print('duration 3seconds');  
      FirebaseAuth.instance.authStateChanges().listen((User? user) {  
        if (user == null) {  
          print('user is null');  
          controller.close();  
        } else {  
          print('user is signe in');  
          controller.add(user);  
          controller.close();  
        }  
      });  
    }  
  );  
  return controller.stream;  
})();
```

これでConnectionState が`done`の状態になるとStreamBuilderのsnapshotにuserのデータが入ります。

あとは`casa ConnectionState.done`に以下の処理を追加。
```
StreamBuilder <User?> (
  stream: _stream,
  builder: (BuildContext context, AsyncSnapshot<User?> snapshot) {
    Widget nextPage = const SizedBox();
    if (snapshot.hasError) {
      // errorの画面 
    } else {
      switch (snapshot.connectionState ) {
        // ConnectionState ごとの処理 
        case ConnectionState.done:  
  print('done');  
  if (snapshot.hasData) {  
    nextPage = FutureBuilder(  
        future: UserFirestore.getUser(snapshot.data!.uid),  
        builder: (BuildContext context, futureSnapshot) {  
          if (futureSnapshot.connectionState == ConnectionState.waiting) {  
            return const Center(  
              child: SizedBox(  
                width: 60,  
                height: 60,  
                child: CircularProgressIndicator(color: Colors.deepOrangeAccent, ),  
              ),  
            );  
          }  
          // return const CalendarPage();  
          print(futureSnapshot.connectionState);  
          if (futureSnapshot.hasData) {  
            return const Home();  
          } else {  
            return Container(  
              color: Colors.white,  
            );  
          }  
        });  
  } else {  
    return const LoginPage();  
  }
      }
    }
    return nextPage;
  }
)

```

これでユーザー取得が何回も動くことなく、かつエラー画面やログイン画面を挟むことなくアカウント情報を持ったログインページに進むことができました。

## 解決
今回何気なく使っていたStreamBuilder の動きがわからなくて、右往左往してしまいました。まだ全然理解できてませんが。
それでも思った通りの結果が得られたのは嬉しかったです。

ただ、`_state`関数の中でDurationを外すと処理の順番がごちゃごちゃになって処理が2回動いてしまうのが謎として残っています。

また、Flutter初心者なため、もっといい書き方やここはこうしたほうがいいなどありましたら教えていただけると助かります。
読んでいただいた方、最後までありがとうございました。


参考 

https://zenn.dev/flutteruniv_dev/articles/902b184146ea94

https://qiita.com/KosukeSaigusa/items/b19ec19379c5a2e4ceb2