こんにちは、株式会社Pentagonでアプリを開発している難波です。
前回、Amplify CLI および Amplify UI を用いず、 Amazon Cognito を用いたソーシャルログイン基盤構築を調査しました。Flutter標準Widgetのみを用いているため、弊社のようにデザインに強みを持つ場合であっても、デザイン性の高いアプリ開発ができることがわかりました。
前回は Googleアカウント を用いたソーシャルログインのみ対応したため、今回は Appleアカウント に対応していきます。
【こんな人に読んで欲しい】
- AWSでの認証機能実装が求められているFlutterアプリ開発エンジニア
- AWSでのソーシャルログイン(SNS認証)が求められているFlutterアプリ開発エンジニア
- デザインにこだわりのある画面が求められるFlutterアプリ開発エンジニア
【この記事を読むメリット】
- Flutter x AWS の環境で認証機能が実現できる
- 認証基盤を独自開発しなくてよくなるため、開発工数が削減できる
- デザインをカスタマイズできるログイン画面が実装できる
【結論】
前回に引き続き、Appleアカウントを用いた認証についても動作確認できました。
Amazon Cognito を用いてソーシャルログインを構築する場合、Googleアカウントなど1つのソーシャルログインへ対応した後は、比較的容易に他のSNS連携ができることがわかりました。
本記事を読み、ソーシャルログイン対応のアカウントを追加していきましょう!
動作イメージ
前提条件
本記事は前回作成した Flutter x Cognito の環境上へ、ソーシャルログイン(Appleアカウント)を実装するまでをゴールとします。
以下は対応済みの前提として説明を進めます。
- 前回記事の環境があること
- Apple ID 登録済みであること
- Apple Developer アカウント登録済みであること
- 本記事ではiOSのみ対応
バックエンド構築
「ソーシャルログインに必要なAppleの設定」については公式デベロッパーガイドに従います。
https://docs.amplify.aws/lib/auth/social/q/platform/flutter/
アプリ構築
パッケージ
Cognito との認証連携を行うため、pubspec.yaml に以下を追加します。(前回 同様)
dependencies:
amplify_auth_cognito: ^1.0.1
amplify_flutter: ^1.0.1
Cognito接続設定
flutterアプリケーションのルートにある amplifyconfiguration.dart ファイルを更新します。
Appleアカウントでのソーシャルログインに必要な内容について追記します。
amplifyconfiguration.dart
前回差分のみ記載
"socialProviders": ["GOOGLE",”APPLE”],
Flutter実装
アプリケーションを実装します。
- 記事投稿用のソースコードにつき、下記の状態となっております。参考にされる際、その点ご留意ください。
- 正常系以外の動作ではエラーが発生します
- ファイル分割をしていません
- リファクタリングしていません
このFlutterアプリは、ユーザー認証機能を持つアプリケーションです。具体的には、Amplifyというバックエンドサービスを使用して、ユーザーのログイン、サインアップ、ログアウト、ソーシャルログイン(GoogleアカウントおよびAppleアカウント)、認証コードの確認などの機能を提供します。
main.dart
import 'package:amplify_auth_cognito/amplify_auth_cognito.dart';
import 'package:amplify_flutter/amplify_flutter.dart';
import 'package:flutter/material.dart';
import 'amplifyconfiguration.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
@override
void initState() {
super.initState();
_configureAmplify();
}
void _configureAmplify() async {
final auth = AmplifyAuthCognito();
await Amplify.addPlugin(auth);
try {
await Amplify.configure(amplifyconfig);
} on AmplifyAlreadyConfiguredException catch (e) {
safePrint(e);
}
}
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
primarySwatch: Colors.grey,
),
home: const HomeScreen(),
);
}
}
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
final tabBarViews = [
const LoginScreen(),
const SignUpScreen(),
];
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: tabBarViews.length,
child: Scaffold(
appBar: AppBar(
title: const Text('HOME'),
bottom: const TabBar(
tabs: [
Tab(text: 'ログイン'),
Tab(text: 'サインアップ'),
],
),
),
body: TabBarView(children: tabBarViews),
),
);
}
}
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _emailTextController = TextEditingController();
final _passwordTextController = TextEditingController();
bool get isValid =>
_emailTextController.text.isNotEmpty &&
_passwordTextController.text.isNotEmpty;
void clear() {
_emailTextController.text = '';
_passwordTextController.text = '';
}
@override
Widget build(BuildContext context) {
return Container(
padding: Padding.all16,
child: Column(
children: [
_textFormField(
controller: _emailTextController,
label: 'email',
),
Space.h16,
_textFormField(
controller: _passwordTextController,
label: 'password',
),
Space.h16,
ElevatedButton(
onPressed: isValid ? () async => await _login(context) : null,
child: const Text('ログイン'),
),
Border.w2,
ElevatedButton(
onPressed: () => _socialLogin(context, AuthProvider.google),
child: const Text('Google アカウント'),
),
ElevatedButton(
onPressed: () => _socialLogin(context, AuthProvider.apple),
child: const Text('Apple アカウント'),
),
],
),
);
}
TextFormField _textFormField({
required TextEditingController controller,
required String label,
}) {
return TextFormField(
controller: controller,
decoration: InputDecoration(
border: const OutlineInputBorder(),
label: Text(label),
),
onChanged: (_) => setState(() {}),
);
}
Future<void> _login(BuildContext context) async {
try {
final result = await Amplify.Auth.signIn(
username: _emailTextController.text,
password: _passwordTextController.text,
);
if (!mounted) return;
_onSuccessLogin(context, result);
} catch (e) {
safePrint(e);
}
}
void _socialLogin(
BuildContext context,
AuthProvider provider,
) async {
try {
final result = await Amplify.Auth.signInWithWebUI(provider: provider);
if (!mounted) return;
_onSuccessLogin(context, result);
} catch (e) {
safePrint(e);
}
}
void _onSuccessLogin(
BuildContext context,
SignInResult result,
) {
if (result.isSignedIn) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('ログインに成功しました!'),
),
);
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (context) => const TopScreen(),
),
);
}
}
}
class SignUpScreen extends StatefulWidget {
const SignUpScreen({super.key});
@override
State<SignUpScreen> createState() => _SignUpScreenState();
}
class _SignUpScreenState extends State<SignUpScreen> {
final _emailTextController = TextEditingController();
final _passwordTextController = TextEditingController();
final _codeTextController = TextEditingController();
bool get isValid =>
_emailTextController.text.isNotEmpty &&
_passwordTextController.text.isNotEmpty;
int _step = 0;
@override
void dispose() {
super.dispose();
_emailTextController.dispose();
_passwordTextController.dispose();
_codeTextController.dispose();
}
@override
Widget build(BuildContext context) {
final steps = [
_signUpStep(context),
_codeConfirmStep(context),
];
return Container(
padding: Padding.all16,
child: steps[_step],
);
}
Widget _signUpStep(BuildContext context) {
return Column(
children: [
_textFormField(controller: _emailTextController, label: 'email'),
Space.h16,
_textFormField(controller: _passwordTextController, label: 'password'),
Space.h16,
ElevatedButton(
onPressed: isValid ? () async => await _signUp(context) : null,
child: const Text('サインアップ'),
),
],
);
}
Future<void> _signUp(BuildContext context) async {
try {
final result = await Amplify.Auth.signUp(
username: _emailTextController.text,
password: _passwordTextController.text,
);
switch (result.nextStep.signUpStep) {
case AuthSignUpStep.confirmSignUp:
setState(() => _step++);
break;
case AuthSignUpStep.done:
if (!mounted) return;
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (context) => const HomeScreen(),
),
);
break;
}
} catch (e) {
safePrint(e);
}
}
TextFormField _textFormField({
required TextEditingController controller,
required String label,
}) {
return TextFormField(
controller: controller,
decoration: InputDecoration(
border: const OutlineInputBorder(),
label: Text(label),
),
onChanged: (_) => setState(() {}),
);
}
Widget _codeConfirmStep(BuildContext context) {
return Column(
children: [
_textFormField(controller: _codeTextController, label: 'code'),
Space.h16,
ElevatedButton(
onPressed: () async => await _codeConfirm(context),
child: const Text('認証'),
),
],
);
}
Future<void> _codeConfirm(BuildContext context) async {
final result = await Amplify.Auth.confirmSignUp(
username: _emailTextController.text,
confirmationCode: _codeTextController.text,
);
if (result.isSignUpComplete) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('認証に成功しました!'),
),
);
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (context) => const HomeScreen(),
),
);
}
}
}
class TopScreen extends StatefulWidget {
const TopScreen({super.key});
@override
State<TopScreen> createState() => _TopScreenState();
}
class _TopScreenState extends State<TopScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('トップページ'),
),
body: Container(
padding: Padding.all16,
alignment: Alignment.center,
child: Column(
children: [
Space.h16,
const Text('ここはログイン後のトップページです'),
Space.h16,
ElevatedButton(
onPressed: () async => await _logout(context),
child: const Text('ログアウト'),
),
],
),
),
);
}
Future<void> _logout(BuildContext context) async {
try {
await Amplify.Auth.signOut();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('ログアウトしました!'),
),
);
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (context) => const HomeScreen(),
),
);
} on AuthException catch (e) {
safePrint(e);
}
}
}
class Space {
static get h16 => const SizedBox(height: 16);
}
class Border {
static get w2 => const Divider(thickness: 2);
}
class Padding {
static get all16 => const EdgeInsets.all(16);
}
アプリの構造は以下のようになっています:
- main.dart:アプリのエントリーポイントで、Amplifyの設定を行います。
- MyApp:ルートウィジェットで、HomeScreenを表示します。
- HomeScreen:ログインとサインアップのタブを持つホーム画面を表示します。
- LoginScreen:ユーザーのログイン情報を入力するための画面です。ログインボタンを押すと、Amplifyを使用してログインを試みます。
- SignUpScreen:新しいユーザーのサインアップ情報を入力するための画面です。サインアップボタンを押すと、Amplifyを使用してサインアップを試みます。必要に応じて認証コードの確認ステップに進みます。
- TopScreen:ログイン後のトップページを表示します。ログアウトボタンを押すとログアウトし、HomeScreenに戻ります。
また、いくつかの補助的なクラスがあります:
- Space:ウィジェット間の垂直方向のスペースを提供します。
- Border:区切り線を提供します。
- Paddng:ウィジェットのパディングを提供します。
まとめ
FlutterアプリからAmazon Cognitoを利用することで、前回の環境へAppleアカウントのソーシャルログイン追加を簡単に実現できました。
ほかにもFacebookアカウント、AmazonアカウントもAmazon Cognitoから連携する手順が用意されているため、必要に応じて実装していくことができますね!
https://docs.amplify.aws/lib/auth/social/q/platform/flutter/
- Facebook Login
- Login with Amazon
上記以外のSNS連携についても機会があれば調査していこうと思います!
おまけ
Flutterに関する知識を深めたい方には、『Flutterの特徴・メリット・デメリットを徹底解説』という記事がおすすめです。
この記事では、Flutter アプリ開発の基本から、flutter とは何か、そして実際のflutter アプリ 事例を通じて、その将来性やメリット、デメリットまで詳しく解説しています。
Flutterを使ったアプリ開発に興味がある方、またはその潜在的な可能性を理解したい方にとって、必見の内容となっています。
ぜひ一度ご覧ください。