🙄

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

2024/10/03に公開

初めてFlutterを触ってみた

Flutterを初めて触りながら、LaravelのSanctumを使って認証機能を実装してみました。調べていると、Firebaseの認証を使う例が多いようですが、今回はLaravelでAPIを用意して、認証しています。
この記事では、Laravelの詳細には触れず、Flutterの実装に焦点を当て、認証機能の内容を共有します。まだFlutterに慣れていないので、皆さんのアドバイスやフィードバックもぜひお待ちしています!

実装した機能

以下の機能を実装しました。

  • アカウント登録/退会
  • 認証機能
  • 二段階認証
  • データ取得/表示
  • アカウント情報の変更

使用したパッケージ

pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.8
  provider: ^6.1.2
  shared_preferences: ^2.3.2
  flutter_dotenv: ^5.1.0
  shimmer: ^3.0.0
  http: ^1.2.2
  flutter_html: ^3.0.0-beta.2

Laravel側では、認証にSanctumを使用しました。Flutterではshared_preferencesproviderを組み合わせて認証情報を管理しています。

ディレクトリ構成

最終的なディレクトリ構成は以下のようになりました。

lib/
├── main.dart                # アプリのエントリーポイント
├── src
│   ├── models/              # データモデルクラス
│   ├── screens/             # 各画面のUIを定義するファイル
│   ├── widgets/             # 再利用可能なウィジェット
│   ├── services/            # APIやデータベースとのやり取りを行うサービス
│   ├── providers/           # 状態管理用のプロバイダー
│   ├── utils/               # ユーティリティ関数やヘルパー
│   ├── themes               # アプリのテーマ
│   ├── constants/           # 定数や共通の値
└── ・・・

認証のフロー図

以下のフロー図は、アプリの起動から認証、各画面への遷移の流れを抜粋して表したものです。

全体はコチラです

よく利用するユーティリティ

ここでは、今後何度も使用するユーティリティクラスを紹介します。

DialogUtils

API接続時などに、ローディングの表示/非表示を行います。

utils/dialog_utils.dart
import 'package:flutter/material.dart';

class DialogUtils {
  // ローディングダイアログを表示
  static void showLoadingDialog(BuildContext context) {
    showDialog(
      context: context,
      barrierDismissible: false, // タップで閉じられないようにする
      builder: (BuildContext context) {
        return const Center(
          child: CircularProgressIndicator(), // ローディングスピナーを表示
        );
      },
    );
  }

  // ローディングダイアログを非表示にする
  static void hideLoadingDialog(BuildContext context) {
    if (Navigator.of(context).canPop()) {
      Navigator.of(context).pop(); // ダイアログを閉じる
    }
  }
}

RouteUtils

認証情報の変更後、AuthWrapperへ遷移する際に使用します。

utils/route_utils.dart
import 'package:flutter/material.dart';
import 'package:flutter_sample/src/widgets/auth_wrapper.dart';

class RouteUtils {
  static void navigateToAuthWrapper(BuildContext context) {
    Navigator.of(context).pushAndRemoveUntil(
      MaterialPageRoute(builder: (context) => const AuthWrapper()),
      (route) => false, // 以前の画面を削除する
    ); 
  }
}

SnackbarUtils

一時的なメッセージ(例:エラーメッセージや成功通知)を表示するために使用します。

utils/snackbar_utils.dart
import 'package:flutter/material.dart';

class SnackbarUtils {
  // 共通のSnackbarを表示するメソッド
  static void showSnackbar(BuildContext context, String message) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text(
          message,
          style: const TextStyle(
            color: Colors.white,
            fontSize: 16,
            fontWeight: FontWeight.bold,
          ),
        ),
        backgroundColor: Colors.black, // 背景色も設定できます
        duration: const Duration(seconds: 1), // 表示時間もカスタマイズ可能
      ),
    );
  }
}

アプリ起動からAuthWrapper遷移まで

以下のコードでは、アプリのエントリーポイントとスプラッシュ画面を定義しています。スプラッシュ画面では、アプリ起動時に認証情報をチェックし、認証状態に応じてAuthWrapperへ遷移させます。

lib/main.dart

アプリ全体のテーマやルート設定、initialRouteをSplashScreenに指定しています。

main.dart
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_sample/src/providers/auth_provider.dart';
import 'package:flutter_sample/src/screens/auth/login_screen.dart';
import 'package:flutter_sample/src/screens/auth/password_reset_screen.dart';
import 'package:flutter_sample/src/screens/auth/register/register_confirm_screen.dart';
import 'package:flutter_sample/src/screens/auth/register/register_input_screen.dart';
import 'package:flutter_sample/src/screens/home/home_detail_screen.dart';
import 'package:flutter_sample/src/screens/home/home_screen.dart';
import 'package:flutter_sample/src/screens/setting/setting_account_edit_screen.dart';
import 'package:flutter_sample/src/screens/setting/setting_phone_number_edit_screen.dart';
import 'package:flutter_sample/src/screens/setting/setting_screen.dart';
import 'package:flutter_sample/src/screens/setting/setting_withidraw_screen.dart';
import 'package:flutter_sample/src/screens/splash_screen.dart';
import 'package:flutter_sample/src/themes/app_theme.dart';
import 'package:provider/provider.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await dotenv.load(fileName: ".env");
  runApp(ChangeNotifierProvider(
    create: (conntext) => AuthProvider(),
    child: const MyApp(),
  ));
}

// ルート定義
class MyApp extends StatelessWidget {
  const MyApp({super.key});
  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: AppTheme.lightTheme,
      darkTheme: AppTheme.darkTheme,
      themeMode: ThemeMode.system,
      initialRoute: '/splash',
      routes: {
        '/splash': (context) => const SplashScreen(),
        ...
      }
    );
  }
}

screens/splash_screen.dart

スプラッシュ画面では、アニメーションとともに認証情報をチェックし、AuthWrapperに遷移します。

splash_screen.dart
import 'dart:async';
import 'package:flutter_sample/src/utils/route_utils.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_sample/src/providers/auth_provider.dart';

// スプラッシュ画面
class SplashScreen extends StatefulWidget {
  const SplashScreen({super.key});

  
  State<SplashScreen> createState() => _SplashScreenState();
}

class _SplashScreenState extends State<SplashScreen>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  int _counter = 4;
  double _opacity = 0.0;

  
  void initState() {
    super.initState();

    // AnimationControllerの初期化
    _controller = AnimationController(
      duration: const Duration(seconds: 2),
      vsync: this,
    );
    _controller.forward();

    _startSplashSequence();
  }

  // スプラッシュ画面のシーケンス処理
  Future<void> _startSplashSequence() async {
    while (_counter > 0) {
      await Future.delayed(const Duration(seconds: 1));
      if (mounted) {
        setState(() {
          _counter--;
          _opacity = _counter == 3 ? 0.5 : 1.0;
        });
      }
    }

    // 認証状態をチェックして画面遷移
    await _checkAuthAndNavigate();
  }

  // 認証状態をチェックして画面遷移
  Future<void> _checkAuthAndNavigate() async {
    // mountedチェックしてからcontextを使ってProviderを呼び出す
    if (!mounted) return;

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

    // 再度mountedチェックしてから画面遷移
    if (mounted) {
      RouteUtils.navigateToAuthWrapper(context);
    }
  }

  
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: AnimatedOpacity(
          opacity: _opacity,
          duration: const Duration(seconds: 1),
          child: const Text(
            'Flutter Sample',
            style: TextStyle(
              fontSize: 50,
              fontWeight: FontWeight.bold,
            ),
          ),
        ),
      ),
    );
  }
}

AuthProviderによる認証チェックAPIの呼び出し
AuthProvidercheckAuthenticationメソッドでは、isAuthenticatedの確認と設定行い、isAuthenticatedtrueなら、バックエンドのAPIに対して二段階認証確認リクエストを送信し、その結果に基づいて認証状態を更新します。以下はその抜粋です。

auth_provider.dart(抜粋)
  Future<void> checkAuthentication() async {
    _isAuthenticated = await _authService.isAuthenticated();
    if (_isAuthenticated) {
      try {
        _isTwoAuthenticated = await _authService.isTwoAuthenticated();
      } on ApiException catch (e) {
        _isAuthenticated = false;
        _isTwoAuthenticated = false;
      } catch (e) {
        _isAuthenticated = false;
        _isTwoAuthenticated = false;
      }
    }
    notifyListeners();
  }

checkAuthenticationメソッド内で、isAuthenticatedisTwoAuthenticated確認及び、設定を行います。その後、AuthWrapperで認証状態をチェックし、適切な画面に遷移します。

認証状態に応じた画面切り替え

AuthWrapperウィジェットは、ユーザーの認証状態に基づいて異なる画面へ遷移させる役割を持っています。例えば、認証されていない場合はログイン画面を表示し、認証済みであればメイン画面、二段階認証が未完了の場合は二段階認証画面を表示します。

widgets/auth_wrapper.dart

以下は、AuthWrapperの実装コードです。AuthProviderから現在の認証状態を取得し、isAuthenticatedおよびisTwoAuthenticatedの値に基づいて画面を切り替えます。

auth_wrapper.dart
import 'package:flutter_sample/src/providers/auth_provider.dart';
import 'package:flutter_sample/src/screens/auth/login_screen.dart';
import 'package:flutter_sample/src/screens/auth/two_factor_screen.dart';
import 'package:flutter_sample/src/screens/main_screen.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

// 認証状態に応じて画面を切り替えるウィジェット
class AuthWrapper extends StatelessWidget {
  const AuthWrapper({super.key});

  
  Widget build(BuildContext context) {
    final authProvider = Provider.of<AuthProvider>(context);

    if (authProvider.isAuthenticated) {
      return authProvider.isTwoAuthenticated
        ? const MainScreen() // ログインしている場合
        : const TwoFactorScreen(); // 二要素認証が必要な場合
    } else {
      return LoginScreen(); // ログイン画面
    }
  }
}

認証状態を保持するAuthProvider

AuthProviderでは、以下の3つのプロパティを使って認証状態を管理しています。

  • isAuthenticated:認証済みかどうかを示すブール値
  • isTwoAuthenticated:二段階認証が完了しているかどうかを示すブール値
  • message:エラーメッセージなどを格納するオプションの文字列
auth_provider.dart(抜粋)
class AuthProvider with ChangeNotifier {
  final AuthService _authService = AuthService(); // 認証サービスを使用
  bool _isAuthenticated = false;
  bool _isTwoAuthenticated = false;
  String? _message;

  bool get isAuthenticated => _isAuthenticated;
  bool get isTwoAuthenticated => _isTwoAuthenticated;
  String? get message => _message;
  
  // 認証や二段階認証の処理をここで行う
  ...
}

認証フローの流れ

以下のフロー図は、AuthWrapperで認証状態に応じた画面切り替えの処理を表しています。認証状態によってメイン画面やログイン画面、二段階認証画面に遷移します。

ログイン画面

LoginScreenでは、ユーザーがログイン情報を入力し、ログインボタンを押すことでログイン処理を実行します。ログインが成功すれば、AuthWrapperに遷移し、認証状態に応じて適切な画面が表示されます。ここでは、その流れを説明します。

screens/auth/login_screen.dart

ログイン処理の流れ

ユーザーがメールアドレスとパスワードを入力後、ログインボタンを押すことで、以下のフローでログイン処理が進行します。

  1. 入力値のバリデーションが行われます。
  2. ローディングダイアログを表示し、authProvider.loginメソッドで認証APIを呼び出します。
  3. 認証が成功すれば、isAuthenticated = trueおよびisTwoAuthenticated = falseに設定され、認証完了状態になります。
  4. 認証が失敗した場合は、エラーメッセージが表示されます。
  5. 成功時にはAuthWrapperに遷移し、さらに認証状態に基づいて画面が切り替わります(例: 二段階認証が必要な場合はTwoFactorScreenが表示される)。

ログイン処理のコード

以下は、login_screen.dartにおけるログイン処理のコードです。

login_screen.dart(抜粋)
    void login() async {
      // バリデーション
      final isValid = _formKey.currentState!.validate();
      if (!isValid) return;

      final authProvider = Provider.of<AuthProvider>(context, listen: false);
      final email = _emailController.text;
      final password = _passwordController.text;
      // ローディングダイアログを表示
      DialogUtils.showLoadingDialog(context);

      // ログインメソッドを呼び出す
      await authProvider.login(email, password);

      if (!context.mounted) return;
      // ローディングダイアログを非表示
      DialogUtils.hideLoadingDialog(context);

      // メッセージがあれば、エラーメッセージを表示
      if (authProvider.message != null) {
        SnackbarUtils.showSnackbar(context, authProvider.message!); 
      // メッセージがなければ、AuthWrapperに遷移。
      } else {
        RouteUtils.navigateToAuthWrapper(context);
      }
    }

AuthProviderによるログインAPIの呼び出し

AuthProviderloginメソッドでは、バックエンドのAPIに対して認証リクエストを送信し、その結果に基づいて認証状態を更新します。以下はその抜粋です。

auth_provider.dart(抜粋)
  // ログイン処理
  Future<void> login(String email, String password) async {
    try { 
      _message = null;
      await _authService.login(email, password); // ログインAPIを実行
      _isAuthenticated = true; // 認証成功時
      _isTwoAuthenticated = false; // 二段階認証はまだ完了していない
    } on ApiException catch (e) {
      _message = e.message; // APIからのエラーメッセージを格納
    } catch (e) {
      _message = 'ログインに失敗しました'; // 一般的なエラーメッセージ
    }
  }

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

認証フロー図

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

登録処理の流れ

  1. ログイン画面からの遷移
    ユーザーは、ログイン画面の「会員登録がまだの方はこちら」をタップして、RegisterInputScreenに遷移します。

    login_screen.dart(抜粋)
    CommonNavigatorText(
      onTap: () {
        Navigator.pushNamed(context, '/register/input');
      },
      text: '会員登録がまだの方はこちら',
    ),
    
  2. 登録情報入力
    RegisterInputScreenでユーザーは登録情報を入力し、Confirmボタンを押すことで、navigateToRegisterConfirmScreen関数が実行されます。この関数では、重複するメールアドレスのチェックを行い、問題がなければRegisterConfirmScreenに遷移します。

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

    DialogUtils.showLoadingDialog(context);
    try {
      // ユーザーの存在チェック
      await authService.checkUser(_emailController.text);
      if (!mounted) return;
      DialogUtils.hideLoadingDialog(context);
      Navigator.of(context).pushNamed('/register/confirm', arguments: {
        'name': _nameController.text,
        'email': _emailController.text,
        'phone_number': _phoneNumberController.text,
        'password': _passwordController.text,
      });

    } on ApiException catch (e) {
      DialogUtils.hideLoadingDialog(context);
      SnackbarUtils.showSnackbar(context, e.message);
    
    } catch (e) {
      DialogUtils.hideLoadingDialog(context);
      SnackbarUtils.showSnackbar(context, 'エラーが発生しました');
    
    }
  }
  1. 登録内容の確認
    RegisterConfirmScreenでは、入力された情報が表示され、Registerボタンを押すことでregister関数が実行されます。この関数では、登録APIを呼び出して、成功すれば認証情報を更新し、AuthWrapperに遷移します。
register_confirm_dart
    void register() async {
      final authProvider = Provider.of<AuthProvider>(context, listen: false);
      final name = _nameController.text;
      final email = _emailController.text;
      final phoneNumber = _phoneNumberController.text;
      final password = _passwordController.text;

      // ローディングダイアログを表示
      DialogUtils.showLoadingDialog(context);
      // 登録メソッドを呼び出す
      await authProvider.register(name, email, phoneNumber, password);

      if (!context.mounted) return;
      // エラーメッセージがある場合は、SnackBar で表示
      if (authProvider.message != null) {
        SnackbarUtils.showSnackbar(context, authProvider.message!);
      } else {
        RouteUtils.navigateToAuthWrapper(context);
      }
    }

register関数内の登録APIが問題なければ、isAuthenticated = trueisTwoAuthenticated = falseにして、AuthWrapperに遷移する。
AuthWrapperで認証情報を確認して、TwoFactorScreenに遷移します。

auth_provider.dart(抜粋)
  Future<void> register(String name, String email, String phoneNumber, String password) async {
    try {
      _message = null;
      await _authService.register(name, email, phoneNumber, password);
      _isAuthenticated = true;
      _isTwoAuthenticated = false;
    } on ApiException catch (e) {
      _message = e.message;
    } catch (e) {
      _message = '登録に失敗しました';
    }
  }

登録フロー図

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

二段階認証画面

TwoFactorScreenでは、ユーザーが4桁の認証コードを入力し、二段階認証するボタンを押すことで二段階認証処理を実行します。二段階認証が成功すれば、AuthWrapperに遷移し、認証状態に応じて適切な画面が表示されます。ここでは、その流れを説明します。

screens/auth/two_factor_screen.dart

二段階認証処理の流れ
ユーザーが4桁の認証コードを入力後、二段階認証するボタンを押すことで、以下のフローで二段階処理が進行します。

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

二段階認証処理のコード

以下は、two_factor_screen.dartにおける二段階認証処理のコードです。

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

    await authProvider.twoFactorAuth(passcode);

    if (!mounted) return;

    DialogUtils.hideLoadingDialog(context);

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

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

auth_provider.dart
  Future<void> twoFactorAuth(String passcode) async {
    try {
      _message = null;
      await _authService.twoFactorAuth(passcode);
      _isTwoAuthenticated = true;
    } on ApiException catch (e) {
      _message = e.message;
    } catch (e) {
      _message = '認証に失敗しました'; 
    }
  }

twoFactorAuthメソッド内で、認証が成功するとisTwoAuthenticatedtrueに設定されます。その後、AuthWrapperで認証状態をチェックし、MainScreenに遷移します。

二段階認証フロー図

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

まとめ

今回、初めてFlutterを触りながら、認証機能を実装した内容をまとめました。
次回は、MainScreenの周りの実装について詳しく説明する予定です。
Flutterに詳しい方や初心者の方からのアドバイスも大歓迎ですので、ぜひコメントいただけると嬉しいです!

Discussion