# Amplify Flutter Workshop ## Prerequisites ### CLI 설치와 업데이트 * Update the AWS CLI ```bash pip install --user --upgrade awscli ``` * Install the AWS Amplify CLI ```bash npm install -g @aws-amplify/cli ``` * Install and use Node.js v12.x (to match AWS Lambda) ```bash nvm ls-remote | grep v12 nvm install v12.11.0 nvm alias default v12.11.0 ``` * Flutter version 2.0.0 or higher (make sure you are using a stable version of flutter) ### 기본 region 설정하기 가장 좋은 방법은 인프라를 고객과 가까운 지역에 구성하는 것입니다. (Amplify는 서울 리전도 지원합니다.) **AWS config file이 없다면 Workshop 진행의 편의를 위해서 생성**합니다. 원하는 리전을 선택해서 하나만 진행하시면 됩니다. * us-east-1(버지니아)를 사용하는 경우 ```bash cat <<END > ~/.aws/config [default] region=us-east-1 END ``` * ap-northeast-2(한국)를 사용하는 경우 ```bash cat <<END > ~/.aws/config [default] region=ap-northeast-2 END ``` AWS Amplify CLI는 모바일과 웹 어플리케이션을 개발을 심플하게 해주는 강력한 기능들을 제공하는 툴체인 입니다. 위의 단계에서는 설치만 진행했기 때문에 설정 단계가 추가적으로 필요합니다. AWS Amplify CLI는 ~/.aws/config을 찾아 작업할 Region 정보를 판별합니다. Cloud9은 유효한 Administrator credentials이 ~/.aws/credentials 파일안에 있는지 확인만 할 뿐 ~/.aws/config을 생성하지 않습니다. ## Flutter 프로젝트 * Flutter CLI를 이용하여 새 프로젝트를 시작합니다. ```bash flutter create amplified_todo ``` ### 애플리케이션에 Amplify를 추가합니다. Amplify for Flutter는 pub.dev를 통해 배포됩니다. 프로젝트 루트 디렉터리에서 **pubspec.yaml**을 찾아 수정하고 Amplify 플러그인을 프로젝트 종속성에 추가합니다. ```yaml environment: sdk: ">=2.12.0 <3.0.0" dependencies: flutter: sdk: flutter amplify_flutter: ^0.2.0 amplify_auth_cognito: ^0.2.0 amplify_analytics_pinpoint: ^0.2.0 amplify_storage_s3: ^0.2.0 file_picker: ^3.0.0-nullsafety.2 ``` 다음 명령을 실행하여 종속성을 설치합니다. 개발 환경에 따라 IDE를 통해 이 단계를 수행할 수 있습니다.(또는 자동으로 수행될 수도 있음) ```bash flutter pub get ``` ### iOS 플랫폼 업데이트 프로젝트 루트에서 **ios/** 디렉터리로 이동하고 선택한 텍스트 편집기를 사용하여 **Podfile**을 수정하고 대상 iOS 플랫폼을 13.0 이상으로 업데이트합니다. ```yaml platform :ios, '13.0' ``` ### Android SDK 버전 업데이트 프로젝트 루트에서 **android/app/** 디렉터리로 이동하고 원하는 텍스트 편집기를 사용하여 **build.gradle**을 수정하고 대상 Android SDK 버전을 21 이상으로 업데이트합니다. ``` minSdkVersion 21 ``` 축하합니다🎉 Amplify로 구축을 시작할 준비가 되었습니다. --- ## Amplify CLI를 이용하여 백앤드 프로비저닝 백엔드에서 리소스 프로비저닝을 시작하려면 디렉터리를 프로젝트 디렉터리로 변경하고 amplify init를 실행합니다. ```bash # in future, make sure you have Amplify CLI v4.28 and above for Flutter support # npm install -g @aws-amplify/cli amplify init ``` 메시지가 표시되면 다음을 입력합니다. ``` ? Enter a name for the environment `dev` ? Choose your default editor: `IntelliJ IDEA` ? Choose the type of app that you're building: 'flutter' ? Where do you want to store your configuration file? ./lib/ ? Do you want to use an AWS profile? `Yes` ? Please choose the profile you want to use `default` ``` amplifyconfiguration.dart. amplify init를 성공적으로 실행하면 ./lib/에 amplifyconfiguration.dart라는 구성 파일이 생성된 것을 볼 수 있습니다. 이 파일은 Amplify 라이브러리가 프로비저닝된 백엔드 리소스에 도달하는 방법을 알 수 있도록 애플리케이션에 번들로 제공됩니다. :::warning :information_source: 팁! iOS 오류 해결 * iOS에서 테스트를 하는 동안 아래와 같은 오류가 발생하였다면, ``` Error running pod install Error launching application on iPhone 11. ``` * 다음과 같은 명령을 통해서 해결이 가능합니다. ```bash cd ios && pod update ``` ::: --- ## Amplify * 이 프로젝트에서 사용할 Amplify 기능들을 추가합니다. * Amplify 카테고리 추가(이 예에서는 기본값 선택) ```bash amplify add auth amplify add analytics amplify add storage ``` * 클라우드에 변경 사항을 푸시하여 백엔드 리소스를 프로비저닝합니다. ```bash amplify push ``` --- ## lib/main.dart * Amplify 패키지 추가합니다. ```java= import 'package:amplify_flutter/amplify.dart'; import 'package:amplify_analytics_pinpoint/amplify_analytics_pinpoint.dart'; import 'package:amplify_auth_cognito/amplify_auth_cognito.dart'; import 'package:amplify_storage_s3/amplify_storage_s3.dart'; import 'package:flutter/material.dart'; import 'amplifyconfiguration.dart'; void main() { runApp(MyApp()); } class MyApp extends StatefulWidget { @override State<StatefulWidget> createState() => _MyAppState(); } class _MyAppState extends State<MyApp> { bool _isAmplifyConfigured = false; @override initState() { super.initState(); _initAmplifyFlutter(); } void _initAmplifyFlutter() async { Amplify.addPlugins( [AmplifyAuthCognito(), AmplifyStorageS3(), AmplifyAnalyticsPinpoint()]); // Initialize AmplifyFlutter try { await Amplify.configure(amplifyconfig); print('Successfully configured Amplify 🎉'); } on AmplifyAlreadyConfiguredException { print( "Amplify was already configured. Looks like app restarted on android."); } setState(() { _isAmplifyConfigured = true; }); } // This widget is the root of your application. @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Amplify App', theme: ThemeData( primarySwatch: Colors.blue, visualDensity: VisualDensity.adaptivePlatformDensity, ), home: new Scaffold( appBar: new AppBar( title: new Text("Hello Flutter App"), ), body: new Center( child: new Text("Hello Flutter"), ), ), ); } } ``` ### landing / loading page * /lib/Pages 폴더를 생성합니다. * Amplify SDK 가 로딩되지 않았을때, 표시해줄 페이지를 만듭니다. * /lib/Pages/LoadingPage.dart ```java= import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; class LoadingPage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text("Landing Page")), body: Center(child: Text("Please Wait. Configuring Amplify Flutter SDK")), ); } } ``` * 첫 페이지를 작성합니다. * /lib/Pages/LandingPage.dart ```java= import 'package:flutter/material.dart'; class LandingPage extends StatefulWidget { LandingPage({Key? key}) : super(key: key); @override _LandingPageState createState() => _LandingPageState(); } class _LandingPageState extends State<LandingPage> { Future<Null> _showDialogForResult( String text, Function onSuccess, Widget dialogWidget) async { bool result = await showDialog( context: context, builder: (BuildContext context) { return new SimpleDialog(title: Text(text), children: [ dialogWidget, ElevatedButton( child: const Text("Cancel"), onPressed: () { Navigator.pop(context, false); }, ), ]); }); if (result) onSuccess(); } // dialogWidget must return true or false Widget openDialogButton( String text, Function onSuccess, Widget dialogWidget) { return ElevatedButton( child: Text(text), onPressed: () { _showDialogForResult(text, onSuccess, dialogWidget); }); } void onSignInSuccess() { print('Sign in Success'); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text("Landing Page"), ), body: Center( child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ Center(child: Text("This is a Landing Page!")), ], ))); } } ``` * main.dart파일에서 import를 추가 합니다. ```java= import 'Pages/LoadingPage.dart'; import 'Pages/LandingPage.dart'; ``` * _display 함수를 추가합니다. ```java= Widget _display() { if (_isAmplifyConfigured) { return LandingPage(); } else { return LoadingPage(); } } ``` * build 함수를 업데이트 합니다. ```java= @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Amplify App', theme: ThemeData( primarySwatch: Colors.blue, visualDensity: VisualDensity.adaptivePlatformDensity, ), home: _display()); } ``` --- ## 뷰 작성 * 뷰를 위한 폴더를 생성합니다. : /lib/Views ### 오류 표기를 위한 페이지 작성 * 오류 페이지를 작성합니다. /lib/Views/ErrorView.dart ```java= import 'package:flutter/material.dart'; class ErrorView extends StatelessWidget { final String error; ErrorView(this.error); @override Widget build(BuildContext context) { if (error.isNotEmpty) { return Column(children: <Widget>[ Text('Error: $error', textAlign: TextAlign.center, overflow: TextOverflow.visible, style: TextStyle(fontWeight: FontWeight.bold)), ]); } else { return Container(); } } } ``` ### 회원 가입과 로그인 뷰 만들기 * 회원 가입 뷰를 생성합니다. * /lib/Views/SignUpView.dart ```java= import 'package:amplify_auth_cognito/amplify_auth_cognito.dart'; import 'package:amplify_flutter/amplify.dart'; import 'package:flutter/material.dart'; import 'ErrorView.dart'; class SignUpView extends StatefulWidget { @override _SignUpViewState createState() => _SignUpViewState(); } class _SignUpViewState extends State<SignUpView> { final usernameController = TextEditingController(); final passwordController = TextEditingController(); final emailController = TextEditingController(); final confirmationCodeController = TextEditingController(); String _signUpError = ""; bool _isSignedUp = false; @override void initState() { super.initState(); } void _signUp() async { setState(() { _signUpError = ""; }); Map<String, String> userAttributes = { "email": emailController.text, }; try { SignUpResult res = await Amplify.Auth.signUp( username: usernameController.text.trim(), password: passwordController.text.trim(), options: CognitoSignUpOptions(userAttributes: userAttributes)); setState(() { _isSignedUp = true; }); } on AuthException catch (error) { _setError(error); } } void _confirmSignUp() async { setState(() { _signUpError = ""; }); try { SignUpResult res = await Amplify.Auth.confirmSignUp( username: usernameController.text.trim(), confirmationCode: confirmationCodeController.text.trim()); Navigator.pop(context, true); } on AuthException catch (error) { _setError(error); } } void _setError(AuthException error) { setState(() { _signUpError = error.message; }); } @override Widget build(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: <Widget>[ Expanded( // wrap your Column in Expanded child: Padding( padding: EdgeInsets.all(10.0), child: Column( children: [ Visibility( visible: !_isSignedUp, child: Column(children: [ TextFormField( controller: usernameController, decoration: const InputDecoration( icon: Icon(Icons.person), hintText: 'Username', labelText: 'Username *', ), ), TextFormField( obscureText: true, controller: passwordController, decoration: const InputDecoration( icon: Icon(Icons.lock), hintText: 'Password', labelText: 'Password *', ), ), TextFormField( controller: emailController, decoration: const InputDecoration( icon: Icon(Icons.email), hintText: 'Email', labelText: 'Email *', ), ), ElevatedButton( onPressed: _signUp, child: const Text('Sign Up'), ), ]), ), Visibility( visible: _isSignedUp, child: Column(children: [ TextFormField( controller: confirmationCodeController, decoration: const InputDecoration( icon: Icon(Icons.confirmation_number), hintText: 'The code we sent you', labelText: 'Confirmation Code *', )), ElevatedButton( onPressed: _confirmSignUp, child: const Text('Confirm Sign Up'), ), ])), const Padding(padding: EdgeInsets.all(10.0)), ErrorView(_signUpError) ], ), ), ), ], ); } } ``` * 로그인 뷰를 생성합니다. * /lib/Views/SignInView.dart ```java import 'package:amplify_auth_cognito/amplify_auth_cognito.dart'; import 'package:amplify_flutter/amplify.dart'; import 'package:flutter/material.dart'; import 'ErrorView.dart'; class SignInView extends StatefulWidget { @override _SignInViewState createState() => _SignInViewState(); } class _SignInViewState extends State<SignInView> { final usernameController = TextEditingController(); final passwordController = TextEditingController(); String _signUpError = ""; @override void initState() { super.initState(); } void _signIn() async { // Sign out before in case a user is already signed in // If a user is already signed in - Amplify.Auth.signIn will throw an exception try { await Amplify.Auth.signOut(); } on AuthException catch (e) { print(e); } try { SignInResult res = await Amplify.Auth.signIn( username: usernameController.text.trim(), password: passwordController.text.trim()); Navigator.pop(context, true); } on AuthException catch (e) { setState(() { _signUpError = e.message; }); } } @override Widget build(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: <Widget>[ Expanded( // wrap your Column in Expanded child: Padding( padding: EdgeInsets.all(10.0), child: Column( children: [ TextFormField( controller: usernameController, decoration: const InputDecoration( icon: Icon(Icons.person), hintText: 'Enter your username', labelText: 'Username *', ), ), TextFormField( obscureText: true, controller: passwordController, decoration: const InputDecoration( icon: Icon(Icons.lock), hintText: 'Enter your password', labelText: 'Password *', ), ), const Padding(padding: EdgeInsets.all(10.0)), ElevatedButton( onPressed: _signIn, child: const Text('Sign In'), ), ErrorView(_signUpError) ], ), ), ), ], ); } } ``` * 로그인과 회원가입 기능(버튼)을 추가합니다. * /lib/Pages/LandingPage.dart ```java= import '../Views/SignInView.dart'; import '../Views/SignUpView.dart'; body: Center( child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ openDialogButton("Sign In", onSignInSuccess, SignInView()), openDialogButton( "Sign Up", () => {print("sign up success")}, SignUpView()) ], ))); ``` --- ### MainPage * /lib/Pages/MainPage.dart 파일을 생성합니다. ```java= import 'package:amplify_flutter/amplify.dart'; import 'package:amplify_storage_s3/amplify_storage_s3.dart'; import 'package:flutter/material.dart'; class MainPage extends StatefulWidget { @override _MainPageState createState() => _MainPageState(); } class _MainPageState extends State<MainPage> { List<String> itemKeys = []; @override void initState() { super.initState(); _loadImages(); } void _loadImages() async { try { print('In list'); S3ListOptions options = S3ListOptions(accessLevel: StorageAccessLevel.guest); ListResult result = await Amplify.Storage.list(options: options); var newList = itemKeys.toList(); for (StorageItem item in result.items) { newList.add(item.key); } setState(() { itemKeys = newList; }); } catch (e) { print('List Err: ' + e.toString()); } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [Text("Main Page")])), body: new Center( child: new Text("Hello Flutter. This is main page."), ), floatingActionButton: FloatingActionButton( onPressed: () {}, tooltip: 'Increment', child: Icon(Icons.add), ), ); } } ``` * 로그인후 페이지 이동 * /lib/Pages/LandingPage.dart ```java= import 'MainPage.dart'; void onSignInSuccess() { Navigator.pushAndRemoveUntil(context, MaterialPageRoute(builder: (context) => MainPage()), (route) => false); } ... ``` * 사용자 로그아웃을 위한 뷰를 생성합니다. * /lib/Views/UserView.dart ```java= import 'package:amplify_auth_cognito/amplify_auth_cognito.dart'; import 'package:amplify_flutter/amplify.dart'; import 'package:flutter/material.dart'; import '../Pages/LandingPage.dart'; class UserView extends StatefulWidget { @override _UserProfileState createState() => _UserProfileState(); } class _UserProfileState extends State<UserView> { @override void initState() { super.initState(); } void _signOut() async { try { SignOutResult res = await Amplify.Auth.signOut(); Navigator.pushAndRemoveUntil( context, MaterialPageRoute(builder: (context) => LandingPage()), (route) => false); } on AuthException catch (e) { print(e); } } @override Widget build(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: <Widget>[ ElevatedButton(onPressed: _signOut, child: const Text("Log Out")), ], ); } } ``` * /lib/Pages/MainPage.dart 파일을 생성합니다. ```java= import '../Views/UserView.dart'; ... appBar: AppBar( title: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [Text("Main Page"), UserView()])), ``` --- ## 업로드된 이미지 보기 기능 추가 ### 이미지 보기 뷰 추가 * /lib/Views/ImageLineItem.dart ```java= import 'package:flutter/material.dart'; class ImageLineItem extends StatelessWidget { final String storageKey; const ImageLineItem({ Key? key, required this.storageKey, }) : super(key: key); @override Widget build(BuildContext context) { return Padding( padding: EdgeInsets.all(5.0), child: Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ Text(storageKey), Spacer(), ])); } } ``` ### 메인 페이지에 기능 추가 * /lib/Pages/MainPage.dart 파일을 수정합니다. * 업로드 이미지 보기 ```java= import '../Views/ImageLineItem.dart'; body: ListView.builder( itemCount: itemKeys.length, itemBuilder: (context, index) { return ImageLineItem(storageKey: itemKeys[index]); }), ``` --- ## 이미지 업로드 기능 추가 ### 이미지 업로드 뷰 추가 * /lib/Views/ImageUploader.dart ```java= import 'package:flutter/material.dart'; import 'dart:io'; import 'package:file_picker/file_picker.dart'; import 'package:amplify_flutter/amplify.dart'; import 'package:amplify_storage_s3/amplify_storage_s3.dart'; class ImageUploader extends StatelessWidget { void _upload(BuildContext context) async { List<PlatformFile>? _paths; try { print('In upload'); // Uploading the file with options _paths = (await FilePicker.platform.pickFiles(type: FileType.image))?.files; File local = File(_paths!.single.path!); local.existsSync(); final key = new DateTime.now().toString(); Map<String, String> metadata = <String, String>{}; metadata['name'] = 'filename'; metadata['desc'] = 'A test file'; S3UploadFileOptions options = S3UploadFileOptions( accessLevel: StorageAccessLevel.guest, metadata: metadata); UploadFileResult result = await Amplify.Storage.uploadFile( key: key, local: local, options: options); print('File uploaded. Key: ' + result.key); Navigator.pop(context, result.key); } catch (e) { print('UploadFile Err: ' + e.toString()); } } @override Widget build(BuildContext context) { return Column(children: [ ElevatedButton( child: const Text("Upload Image"), onPressed: () { _upload(context); }, ) ]); } } ``` ### 메인 페이지에 기능 추가 * /lib/Pages/MainPage.dart 파일을 수정합니다. * 이미지 업로드 기능 추가 ```java= import '../Views/ImageUploader.dart'; ... void _showImageUploader() async { try { String key; key = await showDialog( context: context, builder: (BuildContext context) { return new SimpleDialog( title: Text("Upload Image"), children: [ImageUploader()]); }); if (key.isNotEmpty) { var newList = itemKeys.toList(); newList.add(key); setState(() { itemKeys = newList; }); } } catch (e) { print('Dialog Err: ' + e.toString()); } } ... floatingActionButton: FloatingActionButton( onPressed: () { _showImageUploader(); }, tooltip: 'Increment', child: Icon(Icons.add), ``` --- ## 이미지 보기 기능 추가 ### 이미지 보기 뷰 추가 * /lib/Views/ImagePreview.dart ```java= import 'package:amplify_analytics_pinpoint/amplify_analytics_pinpoint.dart'; import 'package:amplify_flutter/amplify.dart'; import 'package:amplify_storage_s3/amplify_storage_s3.dart'; import 'package:flutter/material.dart'; class ImagePreview extends StatefulWidget { final String storageKey; ImagePreview({Key? key, required this.storageKey}) : super(key: key); @override _ImagePreviewState createState() => _ImagePreviewState(); } class _ImagePreviewState extends State<ImagePreview> { String _imageURL = ''; @override void initState() { super.initState(); _getUrl(widget.storageKey); } void _getUrl(String storageKey) async { try { print('In getUrl'); String key = storageKey; S3GetUrlOptions options = S3GetUrlOptions( accessLevel: StorageAccessLevel.guest, expires: 10000); GetUrlResult result = await Amplify.Storage.getUrl(key: key, options: options); setState(() { _imageURL = result.url; }); print('URL: ' + _imageURL); AnalyticsEvent event = AnalyticsEvent("image_url_retrieved"); event.properties.addStringProperty("file_key", storageKey); Amplify.Analytics.recordEvent(event: event); } catch (e) { print('GetUrl Err: ' + e.toString()); } } @override Widget build(BuildContext context) { return Column(children: [ //Image(image: AssetImage('images/image.png')), Center(child: Image.network(_imageURL)) ]); } } ``` ### 이미지 리스트 뷰에 기능 추가 * /lib/Views/ImageLineItem.dart ```java= import 'ImagePreview.dart'; ... return Padding( padding: EdgeInsets.all(5.0), child: Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ Text(storageKey), Spacer(), ElevatedButton( child: const Text("open"), onPressed: () => { showDialog( context: context, builder: (BuildContext context) { return new SimpleDialog( title: Text(storageKey), children: [ImagePreview(storageKey: storageKey)]); }) }) ])); ``` --- ## References * https://github.com/aws-amplify/amplify-flutter/tree/main/example * https://docs.amplify.aws/start/q/integration/flutter * https://docs.amplify.aws/lib/q/platform/flutter * https://aws.amazon.com/ko/blogs/korea/amplify-flutter-is-now-generally-available-build-beautiful-cross-platform-apps/ * https://aws.amazon.com/ko/getting-started/hands-on/build-flutter-app-amplify/