🙄

初めてFlutterを触ってみた- Flutter × Laravel 認証- ②

2024/10/04に公開

初めてFlutterを触ってみた②

前回の記事の続きになります。
初めてFlutterを触ってみた- Flutter × Laravel 認証- ①
認証後のMainScreen周りの説明をします。

MainScreen周りのフロー図

以下のフロー図はAuthWrapperから認証・二段階認証済でMainScreenから各画面の遷移の流れを抜粋して表したものです。

メイン画面

MainScreenでは、ホーム (HomeScreen) と設定 (SettingScreen) という2つのタブで構成され、ユーザーは下部のナビケーションバーを使ってタブを切り替えることができます。

screens/main_screen.dart

全体の流れ

  1. 画面初期化: MainScreenが表示されると、初期の画面(initialIndexに基づく)が表示されます。
  2. ナビゲーションバー操作: ナビゲーションバーのアイコンをタップすると_onItemTappedが呼ばれ、currentIndexが更新されます。
  3. 画面切り替え: タブのインデックスに応じて、ホーム画面や設定画面に切り替わります。
  4. ドロワーメニュー: 右側のメニュー(ドロワー)を開くことで、追加のナビゲーションオプションが提供されます。

以下は、MainScreenの実装コードです。

main_screen.dart
import 'package:flutter_sample/src/screens/home/home_screen.dart';
import 'package:flutter_sample/src/screens/setting/setting_screen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_sample/src/widgets/common_app_bar.dart';
import 'package:flutter_sample/src/widgets/common_drawer.dart';
import 'package:flutter_sample/src/widgets/common_navigation_bar.dart';


// 認証後のメイン画面
class MainScreen extends StatefulWidget {
  final int  initialIndex;
  
  const MainScreen({
    super.key,
    this.initialIndex = 0,
  });

  
  State<MainScreen> createState() => _MainScreenState();
}

class _MainScreenState extends State<MainScreen> {
  late int currentIndex;

  
  void initState() {
    super.initState();
    currentIndex = widget.initialIndex;
  }

  // メニューWidgetリスト
  final List<Widget> _screens = [
    const HomeScreen(),
    SettingScreen(),
  ];

  void _onItemTapped(int index) {
    setState(() {
      currentIndex = index;
    });
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: const CommonAppBar(),
      endDrawer: const CommonDrawer(),
      body: _screens[currentIndex],
      bottomNavigationBar: CommonNavigationBar(onTap: _onItemTapped, currentIndex: currentIndex),
    );
  }
}

MainScreenで使用してるWidgetsについて

widgets/common_app_bar.dart

共通して使用されるアプリバーです。titleを指定しないときはFlutter Sampleを表示します。
以下は、CommonAppBarの実装コードです。

common_app_bar.dart
import 'package:flutter/material.dart';

// 共通のアプリバー
class CommonAppBar extends StatelessWidget implements PreferredSizeWidget {
  const CommonAppBar({
    super.key,
    this.title,
  });

  final String? title;

  
  Widget build(BuildContext context) {
    return AppBar(
      title: Text(
        title ?? 'Flutter Sample',  // title が null の場合は 'Flutter Sample' を表示
        style: const TextStyle(
          color: Colors.white, 
          fontWeight: FontWeight.bold,
        ),
      ),
      backgroundColor: Colors.pink,
      elevation: 0,
      iconTheme: const IconThemeData(color: Colors.white),
    );
  }

  
  Size get preferredSize => const Size.fromHeight(50);
}

widgets/common_drawer.dart

共通して使用されるナビゲーションドロワー(スライドメニュー)です。
このドロワーでは、認証後のログアウト機能を実装しています。
以下は、CommonDrawerの実装コードです。

common_drawer.dart
import 'package:flutter_sample/src/providers/auth_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_sample/src/utils/dialog_utils.dart';
import 'package:flutter_sample/src/utils/route_utils.dart';
import 'package:flutter_sample/src/utils/snackbar_utils.dart';
import 'package:provider/provider.dart';

// ドロワーの共通部分
class CommonDrawer extends StatefulWidget {
  const CommonDrawer({ super.key });

  
  State<CommonDrawer> createState() => _CommonDrawerState();
}

class _CommonDrawerState extends State<CommonDrawer> {

  void logout () async {
    final authProvider = Provider.of<AuthProvider>(context, listen: false);
    
    DialogUtils.showLoadingDialog(context);
    
    await authProvider.logout();

    if (!mounted) return;

    DialogUtils.hideLoadingDialog(context);
    if (authProvider.message != null) {
      SnackbarUtils.showSnackbar(context, authProvider.message!);
    } else {
      RouteUtils.navigateToAuthWrapper(context);
    }
  }

  
  Widget build(BuildContext context) {
    return SizedBox(
      width: double.infinity,
      child: Drawer(
        child: ListView(
          children: <Widget>[
            DrawerHeader(
              padding:  const EdgeInsets.symmetric(horizontal: 0, vertical: 5),
              decoration: const BoxDecoration(
                color: Colors.pink,
              ),
              child: Row(
                crossAxisAlignment: CrossAxisAlignment.start,
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                children: [
                  const Padding(
                    padding: EdgeInsets.symmetric(horizontal: 10, vertical: 10),
                    child: Text(
                      'Common Drawer',
                      style: TextStyle(
                        color: Colors.white,
                        fontSize: 20,
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                  ),
                  Builder(
                    builder: (BuildContext context) {
                      return IconButton(
                        icon: const Icon(
                          Icons.close,
                          color: Colors.white,
                        ),
                        onPressed: () {
                          Navigator.pop(context);
                        },
                      );
                    },
                  )
                ],
              ),
            ),
            ListTile(
              title: const Text('Logout'),
              onTap: () {
                logout();
              },
            ),
          ],
        )
      )
    );
  }
}

ログアウト処理の流れ

ユーザーがログアウトボタンを押すことで、以下のフローでログアウト処理が進行します。

  1. ローディングダイアログを表示し、authProvider.logoutメソッドでログアウトAPIを呼び出します。
  2. ログアウトが成功すれば、isAuthenticated = falseおよび、isTwoAuthenticated = falseに設定され、ログアウト完了状態になります。
  3. 認証が失敗した場合は、エラーメッセージが表示されます。
  4. 成功時にはAuthWrapperに遷移し、さらに認証状態に基づいて画面が切り替わります。

以下は、common_drawer.dartにおけるログアウト処理のコードです。

common_drawer.dart(抜粋)
void logout () async {
    final authProvider = Provider.of<AuthProvider>(context, listen: false);
    
    DialogUtils.showLoadingDialog(context);
    
    await authProvider.logout();

    if (!mounted) return;

    DialogUtils.hideLoadingDialog(context);
    if (authProvider.message != null) {
      SnackbarUtils.showSnackbar(context, authProvider.message!);
    } else {
      RouteUtils.navigateToAuthWrapper(context);
    }
}

AuthProviderによるログアウトAPIの呼び出し
AuthProviderlogoutメソッドでは、バックエンドのAPIに対してログアウトリクエストを送信し、その結果に基づいて認証状態を更新します。以下はその抜粋です。

auth_provider.dart(抜粋)
  // ログアウト処理
  Future<void> logout() async {
    try {
      _message = null;
      await _authService.logout();
      _isAuthenticated = false;
      _isTwoAuthenticated = false;
    } on ApiException catch (e) {
      _message = e.message;
    } catch (e) {
      _message = 'ログアウトに失敗しました';
    }
  }

logoutメソッド内で、ログアウトが成功するとisAuthenticatedfalseisTwoAuthenticatedfalseに設定されます。その後、AuthWrapperで認証状態をチェックし、LoginScreenに遷移します。

ログアウトフロー図

以下のフロー図は、CommonDrawerからログアウト処理が実行され、ログアウト成功時にAuthWrapperに遷移する流れを視覚的に表しています。

widgets/common_navigation_bar.dart

共通のボトムナビゲーションバー(画面下に表示されるメニュー)です。
受け取ったonTapの処理とcurrentIndexの情報を元に該当タブのアイコンの色を適切に表示します。

以下は、CommonNavigationBarの実装コードです。

common_navigation_bar.dart
import 'package:flutter/material.dart';

// 共通のナビゲーションバー
class CommonNavigationBar extends StatefulWidget {
  const CommonNavigationBar({
    super.key,
    required this.onTap,
    this.currentIndex = 0,
  });

  final ValueChanged<int> onTap;
  final int currentIndex;
  
  State<CommonNavigationBar> createState() => _CommonNavigationBarState();
}

class  _CommonNavigationBarState extends State<CommonNavigationBar> {

  // BottomNavigationBar のアイテムリスト
  final List<BottomNavigationBarItem> _items = const [
    BottomNavigationBarItem(
      icon: Icon(Icons.home),
      label: 'Home',
    ),
    BottomNavigationBarItem(
      icon: Icon(Icons.settings),
      label: 'Setting',
    ),
  ];

  
  Widget build(BuildContext context) {
    return BottomNavigationBar(
      items: _items,
      currentIndex: widget.currentIndex,
      type: BottomNavigationBarType.fixed,
      selectedItemColor: Colors.pink,
      unselectedItemColor: Colors.grey,
      showSelectedLabels: true,
      showUnselectedLabels: true,
      onTap: (index) {
        widget.onTap(index);
      },
    );
  }
}

MainScreenのbodyにしてる各画面

ホーム画面

HomeScreenでは、リストアイテムの取得、表示、及びページネーションを管理します。

screens/home/home_screen.dart

ホーム画面の処理の流れ

  1. initStateメソッド内でスクロールリスナーを追加し、初回のデータ取得APIを呼び出します。
  2. 取得が成功すれば、リストにアイテムを追加して、リスト形式でアイテムを表示します。
  3. ユーザーがリストの最下部に到達すると、次のページのデータ取得APIを呼び出します。取得に成功すれば、リストに追加します。
  4. アイテムをタップすることで詳細画面が表示されます。

変数の定義
以下は、home_screen.dartにおける変数の定義です。

home_screen.dart(抜粋)
  HomeService homeService = HomeService();
  bool _isLoading = false; 
  bool _isLoadingMore = false; 
  List<Item> data = [];
  final ScrollController _scrollController = ScrollController();
  int currentPage = 1; 
  • homeService: HomeServiceクラスのインスタンス。APIからデータを取得するために使用します。
  • _isLoading: 初回データ取得中かどうかを示すフラグ。
  • _isLoadingMore: 追加データ取得中かどうかを示すフラグ。
  • data: 取得したItemオブジェクトのリスト。
  • _scrollController: スクロールイベントを監視するためのコントローラー。
  • currentPage: 現在のページ番号。データのページネーションに使用。

データ取得処理のコード

  1. _isLoadingtrueにして、データ取得中であることを示します。
  2. バックエンドのAPIに対してアイテム取得リクエストを送信し、現在のページに応じたデータを取得します。
  3. データ取得に成功したら、newDataを既存のdataに追加、currentPageを1つ進め、_isLoadingfalseにして、データ取得終了であることを示します。
  4. データ取得に失敗した場合は、_isLoadingfalseにして、データ取得終了であることを示します。

以下は、home_screen.dartにおけるデータ取得処理のコードです。

home_screen.dart(抜粋)
  Future<void> _fetchData() async {
    setState(() {
      _isLoading = true;
    });

    try {
      // サービスクラスから新しいデータを取得し、リストに追加
      List<Item> newData = await homeService.getItems(currentPage);
      if (!mounted) return;
      setState(() {
        data.addAll(newData); // 新しいデータを既存のリストに追加
        currentPage++; // ページを進める
        _isLoading = false;
      });
    } on ApiException catch (_) {
      setState(() {
        _isLoading = false;
      });
    } catch (e) {
      setState(() {
        _isLoading = false;
      });
    }

  }

screens/home/home_detail.dart

詳細画面の流れ

  1. HomeScreenでアイテムカードのIconButtonをタップすると、BottomSheetUtils.showBottomSheetメソッドが呼び出され、HomeDetailScreenがボトムシートに表示されます。
    home_screen.dart(抜粋)
    IconButton(
      onPressed: () {
        BottomSheetUtils.showBottomSheet(
          context,
          HomeDetailScreen(id: id),
        );
       },
       icon: const Icon(Icons.arrow_forward_ios),
      )
    
  2. HomeDetailScreenの表示時に、initStateメソッドを呼び出し、詳細データ取得APIを呼び出します。
  3. データ取得中はローディングスピナーを表示します。
  4. データ取得に成功したら、取得したデータを画面に表示します。
  5. ユーザーはボトムシートの閉じるボタンをタップすることで、ボトムシートを閉じることができます。

変数の定義

以下はhome_detail_screen.dartにおける変数の定義です。

home_detail_screen.dart(抜粋)
  HomeService homeService = HomeService();
  bool _isLoading = false;
  Item _data = Item(id: 0, title: '', description: '', image: '');
  • homeService: HomeServiceクラスのインスタンス。APIからデータを取得するために使用します。
  • _isLoading: 初回データ取得中かどうかを示すフラグ。
  • _data: APIから取得したアイテムのデータを格納する変数。初期値として空のアイテムオブジェクトを設定しています。

データ取得処理のコード

  1. _isLoadingtrueにして、データ取得中であることを示します。
  2. バックエンドのAPIに対して、指定されたIDのアイテム取得リクエストを送信し、アイテム詳細を取得します。
  3. データ取得に成功したら_dataに格納し、_isLoadingfalseにして、データ取得終了であることを示します。
  4. データ取得に失敗した場合は、_isLoadingfalseにして、データ取得終了であることを示します。

以下は、home_detail_screen.dartにおけるデータ取得処理のコードです。

home_detail_screen.dart(抜粋)
  void _fetchData() async {
    setState(() {
      _isLoading = true;
    });
    try {
      _data = await homeService.getItemDetail(widget.id);

      setState(() {
        _isLoading = false;
      });
    } on ApiException catch (_) {
      setState(() {
        _isLoading = false;
      });
    } catch (e) {
      setState(() {
        _isLoading = false;
      });
    } 
  }

Setting画面

SettingScreenでは、アカウントにまつわるメニューの一覧を表示します。

screens/setting/setting_screen.dart

全体の流れ

  1. _itemsに定義されているメニューをリスト形式で表示します。
  2. リストをタップすると定義している画面に遷移します。

以下は、setting_screen.dartの実装コードです。

setting_screen.dart
import 'package:flutter/material.dart';

// 設定画面
class SettingScreen extends StatelessWidget {
  SettingScreen({super.key});

  final List _items = [
    {
      'title': 'アカウント情報変更',
      'route': '/setting/account/edit',
    },
    {
      'title': '電話番号変更',
      'route': '/setting/phone/edit',
    },
    {
      'title': '退会',
      'route': '/setting/withdraw',
    }
  ];

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: Padding(
        padding: EdgeInsets.all(10),
        child: Center(
          child: ListView.builder(
            itemCount: _items.length,
            itemBuilder: (BuildContext context, int index) {
              return Card(
                color: Colors.grey[100],
                child: ListTile(
                  title: Center(child: Text(_items[index]['title'], style: const TextStyle(fontWeight: FontWeight.bold))),
                  onTap: () {
                    Navigator.of(context).pushNamed(_items[index]['route']);
                  },
                )
              );
            },
          ),
        ),
      )
    );
  }
}

Setting画面から遷移する各画面

screens/setting/setting_account_edit_screen.dart

アカウント情報変更画面です。ユーザーのメールアドレスとパスワードの変更ができます。

アカウント情報変更処理の流れ

ユーザーが新しいメールアドレスとパスワードを入力後、変更ボタンを押すことで、以下のフローでアカウント情報変更処理が進行します。

  1. 入力値のバリデーションが行われます。
  2. ローディングダイアログを表示し、アカウント情報変更APIを呼び出します。
  3. アカウント情報変更処理が成功すれば、「アカウント情報を更新しました」の成功メッセージを表示して、SettingScreenに戻ります。
  4. アカウント情報変更処理が失敗した場合は、エラーメッセージが表示されます。

アカウント情報変更処理のコード
以下は、setting_account_edit_screen.dartにおけるアカウント情報変更処理のコードです。

setting_account_edit_screen.dart
  void updateAccountInfo() async {
    final isValid = _formKey.currentState!.validate();
    if (!isValid) return;

    // ローディングダイアログを表示
    DialogUtils.showLoadingDialog(context);

    try {
      await settingService.updateUser(_emailController.text, _passwordController.text);
      if (!mounted) return;
      SnackbarUtils.showSnackbar(context, 'アカウント情報を更新しました');
      DialogUtils.hideLoadingDialog(context);
      Navigator.of(context).pop();

    } on ApiException catch (e) {
      DialogUtils.hideLoadingDialog(context);
      SnackbarUtils.showSnackbar(context, e.message);
    
    } catch (e) {
      DialogUtils.hideLoadingDialog(context);
      SnackbarUtils.showSnackbar(context, 'アカウント情報の更新に失敗しました');
    }
  }

screens/setting/setting_phone_number_edit_screen.dart

電話番号変更画面です。二段階認証で使用する電話番号の変更ができます。

電話番号変更処理の流れ

ユーザーが新しい電話番号を入力後、変更ボタンを押すことで、以下のフローで電話番号変更処理が進行します。

  1. 入力値のバリデーションが行われます。
  2. ローディングダイアログを表示し、電話番号変更APIを呼び出します。
  3. 電話番号変更処理が成功すれば、authProvider.successPhoneNumberメソッドを呼び出します。
  4. isAuthenticated = trueおよびisTwoAuthenticated = falseに設定され、認証済、二段階認証未認証状態になります。
  5. 電話番号変更処理が失敗した場合は、エラーメッセージが表示されます。
  6. 電話番号変更処理成功時には、「電話番号を変更しました」の成功メッセージを表示して、AuthWrapperに遷移し、認証情報に基づいて画面が切り替わります。

電話番号変更処理のコード
以下は、setting_phone_number_edit_screen.dartにおける電話番号変更処理のコードです。

setting_phone_number_edit_screen.dart(抜粋)
  Future<void> updatePhoneNumber() async {
    final isValid = _formKey.currentState!.validate();
    if (!isValid) return;

    final authProvider = Provider.of<AuthProvider>(context, listen: false);
    DialogUtils.showLoadingDialog(context);

    try {
      await settingService.updatePhoneNumber(_phoneNumberController.text);
      await authProvider.successPhoneNumber();
      if (!mounted) return;
      SnackbarUtils.showSnackbar(context, '電話番号を変更しました');
      DialogUtils.hideLoadingDialog(context);
      RouteUtils.navigateToAuthWrapper(context);

    } on ApiException catch (e) {
      DialogUtils.hideLoadingDialog(context);
      SnackbarUtils.showSnackbar(context, e.message);
    } catch (e) {
      SnackbarUtils.showSnackbar(context, '電話番号の変更に失敗しました');
      DialogUtils.hideLoadingDialog(context);
    }
  }

電話番号変更処理成功時のAuthProviderによる認証状態の変更
AuthProvidersuccessPhoneNumberメソッドでは、認証状態を更新します。以下はその抜粋です。

auth_provider.dart(抜粋)
  Future<void> successPhoneNumber() async {
    _isAuthenticated = true;
    _isTwoAuthenticated = false;
  }

successPhoneNumberメソッドが呼び出されると、isAuthenticatedtrueに、二段階認証が未完了の状態としてisTwoAuthenticatedfalseに設定されます。その後、AuthWrapperで認証状態をチェックし、TwoFactorScreenに遷移します。

電話番号変更フロー図
以下のフロー図は、SettingPhoneNumberEditScreenから電話番号変更処理が実行され、電話番号変更処理成功時にAuthWrapperに遷移する流れを視覚的に表しています。

screens/setting/setting_withdraw_screen.dart

退会画面です。ユーザーの退会ができます。

退会処理の流れ

ユーザーが退会ボタンを押すことで、以下のフローで退会処理が進行します。

  1. ローディングダイアログを表示し、退会APIを呼び出します。
  2. 退会処理が成功すれば、authProvider.successWithdrawメソッドを呼び出します。
  3. isAuthenticated = falseおよびisTwoAuthenticated = falseに設定され、未認証、二段階認証未認証状態になります。
  4. 退会処理が失敗した場合は、エラーメッセージが表示されます。
  5. 退会処理成功時には、「退会しました」の成功メッセージを表示して、AuthWrapperに遷移し、認証情報に基づいて画面が切り替わります。

退会処理のコード
以下は、setting_withdraw_screen.dartにおける退会処理のコードです。

setting_withdraw_screen.dart(抜粋)
  Future<void> withidraw() async {
    final authProvider = Provider.of<AuthProvider>(context, listen: false);
    DialogUtils.showLoadingDialog(context);
    // 退会処理
    try {
      await settingService.withdraw();
      await authProvider.successWithidraw();
      if (!mounted) return;
      SnackbarUtils.showSnackbar(context, '退会しました');
      DialogUtils.hideLoadingDialog(context);
      RouteUtils.navigateToAuthWrapper(context);

    } on ApiException catch (e) {
      SnackbarUtils.showSnackbar(context, e.message);
      DialogUtils.hideLoadingDialog(context);

    } catch (e) {
      SnackbarUtils.showSnackbar(context, '退会に失敗しました');
      DialogUtils.hideLoadingDialog(context);
    }
  }

退会処理成功時のAuthProviderによる認証状態の変更
AuthProvidersuccessWithdrawメソッドでは、認証状態を更新します。以下はその抜粋です。

auth_provider.dart(抜粋)
  Future<void> successWithidraw() async {
    _isAuthenticated = false;
    _isTwoAuthenticated = false;
  }

successWithdrawメソッドが呼び出されると、isAuthenticatedfalseに、二段階認証が未完了の状態としてisTwoAuthenticatedfalseに設定されます。その後、AuthWrapperで認証状態をチェックし、LoginScreenに遷移します。

退会フロー図
以下のフロー図は、SettingWithdrawScreenから退会処理が実行され、退会処理成功時にAuthWrapperに遷移する流れを視覚的に表しています。

まとめ

今回は、MainScreen周りの実装についてまとめました。
次回は、触れていなかったLaravel側との連携について説明する予定です。
Flutterに詳しい方や初心者の方からのアドバイスも大歓迎ですので、ぜひコメントいただけると嬉しいです!

Discussion