はじめに
個人開発のスキルアップの教材としてマッチングアプリは非常に良いお手本かと思うので、マッチングアプリとして最低限必要な機能を備えたMVPを作りました
マッチングアプリ業界はすでにレッドオーシャンのように見えますが、マッチングアプリを使用するユーザーの真の目的は理想のパートナーを見つけることにありますが、実際のマッチングアプリ業界に身を投じたユーザーの阿鼻叫喚具合を観察していると目的を達成しているとはいいがたく、筆者視点ではまだまだ開拓の余地がある領域だと思っています
これらの課題はマッチングアプリとしての機能面では解決できないことがもはや明確であるためどのように運用するかが勝敗を決めると思います
ほぼ供給者のアイデア勝負の領域になっていると思われますし、昨今の技術革新でアプリは誰でも簡単に作ることができるようになっています
理想のパートナーを見つけるというのは人類の根源的な欲求の一つでもあり、この崇高な理想を叶えようとする人が技術面の障壁で躓かないように、誰でもマッチングアプリを作れる記事を今回作成しました
想定する読者としてはプログラミングの知識はある程度持っていてアプリ開発については概要レベルしか知識がない人です
いつか自力で何かしらアプリを作りたいけど具体的には何作ろうかなと思っている感じの人には特に参考になるのではないのかなと思っています
こちらの記事はフロントエンド編です
Flutterを使用してモバイルにもWebにも対応したアプリを作ってみようと思います
また、バックエンドには定番のFirebaseを利用せずにDjangoを利用しているのでFirebaseを利用しない場合にFlutterの実装をどうしようか悩んでいる人の参考になれば幸いです
画面の実装をこれから行っていきますのでフルスタックな技術を身に着けたい人はバックエンドの記事も参照してください
前回までのバックエンドの実装記事はこちらです
1.
マッチングアプリを個人開発する~Djangoバックエンドその1 環境構築~ - アプリ開発ナレッジ
2.
マッチングアプリを個人開発する~Djangoバックエンドその2 モデル作成~ - アプリ開発ナレッジ
3.
マッチングアプリを個人開発する~Djangoバックエンドその3 REST API作成~ - アプリ開発ナレッジ
4.
マッチングアプリを個人開発する~Djangoバックエンドその4 動作確認~ - アプリ開発ナレッジ
5,
マッチングアプリを個人開発する~Djangoバックエンドその5 決済機能実装~ - アプリ開発ナレッジ
また、実装済のコードはこちらです
実装しながら記事を書いていたので、ところどころ食い違いがあります
記事どおりに進めて詰まった時はこちらのリポジトリのコードを参照してください
GitHub - orekyo/matchingapp-oreyome-pub: 公開用リポジトリ
では初めて行きましょう
目次
Flutterとは
Google製のクロスプラットフォーム対応のアプリケーション開発フレームワークです
もう少しかみ砕いていうと1つのコードでiOSアプリとAndroidアプリとWebアプリすべてのアプリを実装することができて最近人気の技術です
Flutter環境構築
Flutterを使用していくので環境を構築してください
ここでは Windows を使用します
フラッターが導入できている前提で話を進めます
Flutterの環境構築に関してはYoutubeなどで構築手順を解説してくれているものがアップロードいたりするのでそちらを参考にするとよいかと思います
開発環境
- OS: Windows 10
- フレームワーク: Flutter v.2.
- エディタ: Android Studio
Flutter プロジェクトを作成する
プロジェクトを新規作成する
プロジェクトを新規作成してください
このブログではmatchingappwebというプロジェクト名にしました
プロジェクト構成のガワを作成する
まずは以下のように空フォルダ・ファイルを作成しましょう
matchingappmobile ┗lib ┗models ┗message_model.dart ┗matching_model.dart ┗profile_model.dart ┗user_model.dart ┗providers ┗login_provider.dart ┗message_provider.dart ┗profiles_provider.dart ┗screens ┗menu.dart ┗login.dart ┗signup.dart ┗他画面...... ┗services ┗utils ┗widgets ┗test ┗providers ┗screens ┗services ┗widgets ┗他......
modelsフォルダのファイル構成は基本的にDjangoで実装したモデル構成に準じています
providersは状態管理のライブラリで、フロントエンド側で扱うためのデータを一元管理します
screensは画面フォルダです
Flutterでコードを実装していく
さあ軽くジャブを打っていきましょう
さて環境構築を終えて最初のプロジェクトを作成するとカウンターのサンプルプログラムがあるのですが、とりあえずサンプルを実行してみましょう
flutter runのコマンドを打つことで実行することができます
なお、デバイスをインストールしていない方は仮想デバイスADVをインストールしましょう
次に、コードを実行することが出来たら軽くコードを眺めてみましょう
Dart言語(Flutterで扱っている言語のこと=ここではDartとFlutterはほとんど同じ意味で使ってます)を初めて見た人はぎょっとするコードかもしれません
特にJavaScriptのコードを少しいじったことがある人は少し違和感を感じるかもしれません
違和感の正体は、HTMLがないところにあろうかと思います
そうです、このDartという言語はHTMLやCSSを使用しません
このDartという言語は処理と画面を一つの言語で記述することができます
初めてアプリ開発を行う人にとっては何が何だかと思う類の話ですが、初心者としては他の言語を学ぶ必要がないうえに、Flutterだけで通常のアプリで作りたいであろう機能はほとんどカバーされているし、さらに作ったアプリは公開できるプラットフォームの幅も格段に大きくて、応用範囲が広いという点で、Flutterはプログラミング初学者に最もおすすめできるアプリ開発フレームワークだと思われます
ただし、チュートリアルや日本語の記事がまだまだ少ないので、そこが難関ではあるのですが......
今のところはプログラミング初心者は、無料のプログラミング学習サイトでJavaScriptやHTML、CSSを一通り習得してからFlutterを始める方が理解が早いかと思います
さて、サンプルのコードは大変ありがたいのですが、今回はプロバイダーを使用して状態管理をするためこの形を書き替えていきます
Providerとは状態管理のFlutterのライブラリです
状態管理パッケージのProviderを使ってみる
静的 Stateless と 動的 Statefull について
そもそもアプリというものは基本的にはデフォルトでは静的なものであって、あれこれ工夫することによってはじめて動的な処理を施すことができます
ちなみに静的(Stateless)というのはユーザーの操作に対してアプリの挙動が変化しないもののことを差し、動的(Statefull )とはその逆です
具体的には静的アプリはWikiペディアや阿部寛のサイト(阿部寛のホームページ)のようなものを想像すればよいと思いますし、動的アプリは私たちが日常的に使っているサイト全般です(例:Amazon などのECサイト)
FlutterアプリはStatefull Widgetを継承することで動的アプリを実装できるようにしておりこれが基本形なのですが、この実装ではアプリの実装が進んでいくにつれて保守性が下がるため、通常はProviderといったデータの状態管理用のライブラリを導入するのが鉄板となっています
Providerの導入
ではProviderを導入していきます
pub.dev
pubspec.yamlファイルにproviderを追記します
なお、yamlファイルはインデントが大事なので、インデントは以下のようになります
dependencies: flutter: sdk: flutter provider: ^6.0.2(最新バージョンを指定)
その後パッケージをインストールします
flutter pub dev
Providerの基本形
そして、pub dev サイトの Example のコードを参考にしながら以下のようなコードを書きます
これがプロバイダーの使用の基本形です
Exampleではプロバイダーの値の取り出しにcontext.read
どちらがベストプラクティスかは不明ですが、Consumerを利用する方が画面の再描画を行うのがピンポイントで制御できるため処理速度向上の観点から優れているといわれています
分からない人は後でまた説明します
lib/main.dart
import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; void main() { runApp( MultiProvider( providers: [ ChangeNotifierProvider(create: (_) => UserProvider()), ], child: const MyApp(), ), ); } class UserProvider with ChangeNotifier { String _nickname = ''; String get nickname => _nickname; notifyListeners(); void recommend() { _nickname = 'あなたの最高のお相手Aさんです'; notifyListeners(); } } class MyApp extends StatelessWidget { const MyApp({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return MaterialApp( home: Menu(), ); } } class Menu extends StatelessWidget { const Menu({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('メニューページ'), ), body: Center( child: Consumer<UserProvider>( builder: (context, provider, _) { return Column( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: <Widget>[ Text('さあ最高のマッチングアプリを始めましょう!'), Text(provider.nickname), ElevatedButton(onPressed: () => provider.recommend(), child: Text('あなたにふさわしいお相手を表示します')) ], ); }, ), ), ); } }
この実装で動的処理(状態管理)を実装することができました
ボタンを押すと新たな文字が表示されます
いい感じですね
今はボタンを押してもプログラミング内部で定義したデータを表示しているだけですが、次はバックエンドからとってきたデータを表示させていってみましょう
この処理が何を行っているかについて
さてバックエンドからデータをとってくるその前に、main.dartの中身、特にProviderの説明を軽くしておきます
まずそもそもアプリが起動するということが何なのかということを考えたいのですが、Flutterで定義されたアプリはFlutter runとかでアプリが実行されると一番最初にmain()が実行されます
↓こいつですね
void main() { }
このmain()の中でrunApp()が呼び出され、最初にMyAppが呼ばれているという構図です
MyApp はMenuを呼んでおり、メニューページが表示されます
そのため基本的には画面を見ている限りはclass Menu extends StatelessWidget {}の中身を眺めておけばよいことになります
さて、アップバーなどのウィジェットは解説しなくてもなんとなくわかると思います
難しいのはConsumerやprovider.nicknameやElevatedButton(onPressed: () => provider.recommend(), child: Text('あなたにふさわしいお相手を表示します'))の部分だと思います
これらは何をしているかというと、(何となくわかると思いますが)ElevatedButtonを押すとprovider.nicknameの値を画面に表示しています
最初は空文字で定義されてボタンを押すと定義した文字列が表示されるコードはこちらに定義されています
class UserProvider with ChangeNotifier { String _nickname = ''; // 初期値 String get nickname => _nickname; notifyListeners(); void recommend() { // ボタンが押されたら値がセットされる _nickname = 'あなたの最高のお相手Aさんです'; notifyListeners(); // 値の変更を画面に伝えるメソッドで、プロバイダーにはこれが必要。わからなければおまじない } }
青色のボタンを押して→recommend() が実行され、→nicknameの値が変更されて、変更された値が画面に反映されている流れです
画面に反映する役割は、notifyListeners()とConsumerが担っています
Consumerの基本形
Consumerの基本形はこちらです
Consumer<UserProvider>( // 定義したProviderのクラス名 builder: (context, provider, _) { // 第二引数のproviderに実際のインスタンスが入る return Widget( // WidgetにはColumnなどのお好きなWidgetが利用できる // providerを利用する処理がかける ); }, ),
アロー関数などが思いっきり使われているので大変理解しがたい形でしょうが、まあ慣れましょう
これでデータの状態管理ができるようになりました
では次にまずはログイン機能を実装してみましょう
ログイン機能の実装
ログインをするためには正しいemailとpasswordを渡しつつログイン認証用APIエンドポイントを呼び出して認証情報を受け取る必要があるので、HttpクライアントとしてはDioを利用して、認証情報はCookieに格納する方針で実装することとします
その他の情報はデータの状態管理モジュールのProviderを利用して実装していきます
まずはmain.dartなどをProviderを利用できる形に書き換えていきます
必要なパッケージインストール
Httpクライアントとして使用するDioとCookieを管理するためのパッケージとそのほか必要なパッケージをインストールします
pubspec.yaml
dependencies: http: [最新版] dio: [最新版] cookie_jar: [最新版] dio_cookie_manager: [最新版] path_provider: [最新版]
main.dartのProviderに対応するための書き換え実装
main.dart
import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; void main() { runApp( MultiProvider( providers: [ ChangeNotifierProvider(create: (_) => LoginProvider()), ChangeNotifierProvider(create: (_) => ProfileProvider()), ], child: const MyApp(), ), ); } class MyApp extends StatelessWidget { const MyApp({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return MaterialApp( home: MenuScreen(), routes: <String, WidgetBuilder>{ '/menu': (BuildContext context) => MenuScreen(), '/login': (BuildContext context) => LoginScreen(), '/signup': (BuildContext context) => SignupScreen(), '/my-profile': (BuildContext context) => MyProfileScreen(), '/looking': (BuildContext context) => ProfilesScreen(), }, ); } }
メニュー画面の実装
ところでアプリのタイトルを決めていませんでしたが、ここでは俺の嫁探しにしています(ギャルゲみたいとか言わない)
screens/menu.dart
import 'package:flutter/material.dart'; class MenuScreen extends StatelessWidget { const MenuScreen({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('俺の嫁探し'), ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: <Widget>[ const Text('さあ最高のマッチングアプリを始めましょう!',), const SizedBox(height: 16,), TextButton( child: const Text('ログイン'), onPressed: () { Navigator.pushNamed(context, '/login'); }, ), TextButton( child: const Text('新規会員登録'), onPressed: () { Navigator.pushNamed(context, '/signup'); }, ), ], ) ), ); } }
ログイン画面の実装
まずは画面のレイアウトを整えます
Consumerを利用してProviderを利用できるようにしています
またemailにはバリデータをつけています
email_validator | Dart Package
screens/login.dart
import 'package:email_validator/email_validator.dart'; import 'package:flutter/material.dart'; class LoginScreen extends StatelessWidget { const LoginScreen({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('ログインページ'), ), body: Consumer2<LoginProvider, ProfileProvider>( builder: (context, loginProvider, profileProvider, _) { return Center( child: Padding( padding: const EdgeInsets.all(32.0), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ TextFormField( onChanged: (value) => loginProvider.email = value, decoration: const InputDecoration( labelText: 'email', ), maxLength: 50, ), Text(loginProvider.message, style: const TextStyle(color: Colors.red),), TextFormField( onChanged: (value) => loginProvider.password = value, decoration: InputDecoration( labelText: 'password', suffixIcon: IconButton( onPressed: () => loginProvider.togglePasswordVisible(), icon: const Icon(Icons.remove_red_eye) ), ), obscureText: loginProvider.hidePassword, maxLength: 50, ), Padding( padding: const EdgeInsets.only(top: 16.0), child: ElevatedButton( child: const Text('ログイン'), style: ElevatedButton.styleFrom( fixedSize: Size(MediaQuery.of(context).size.width * 0.95, 32), ), onPressed: () { loginProvider.setMessage(''); if( !EmailValidator.validate(loginProvider.email) ) { loginProvider.setMessage('Email形式で入力してください'); return ; } loginProvider.auth() .then( (isSuccess) { if (isSuccess) { profileProvider.fetchMyProfile(loginProvider.getUserId()) .then((isSuccess) { if (isSuccess) { print('プロフィール作成済みユーザーです'); Navigator.pushAndRemoveUntil( context, MaterialPageRoute(builder: (context) => const MyProfileScreen()), (route) => false, ); } else { print('プロフィール未作成ユーザーです'); Navigator.pushAndRemoveUntil( context, MaterialPageRoute(builder: (context) => const MyProfileScreen()), (route) => false, ); } }); } }) .catchError((error) => print(error)); }, ), ) ], ), ), ); } ), ); } }
新規ユーザー作成画面の実装
ログインとほぼ同じです
ボタンを呼び出す際に呼び出しているProviderのメソッドが違います
screens/signup.dart
class SignupScreen extends StatelessWidget { const SignupScreen({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('サインアップページ'), ), body: Consumer<LoginProvider>( builder: (context, loginProvider, _) { return Center( child: Padding( padding: const EdgeInsets.all(32.0), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ TextFormField( onChanged: (value) => loginProvider.email = value, decoration: const InputDecoration( labelText: 'email', ), maxLength: 50, ), Text(loginProvider.message, style: const TextStyle(color: Colors.red),), TextFormField( onChanged: (value) => loginProvider.password = value, decoration: InputDecoration( labelText: 'password', suffixIcon: IconButton( onPressed: () => loginProvider.togglePasswordVisible(), icon: const Icon(Icons.remove_red_eye) ), ), obscureText: loginProvider.hidePassword, maxLength: 50, ), Padding( padding: const EdgeInsets.only(top: 16.0), child: ElevatedButton( child: const Text('新規ユーザー作成'), style: ElevatedButton.styleFrom( fixedSize: Size(MediaQuery.of(context).size.width * 0.95, 32), ), onPressed: () { loginProvider.setMessage(''); if( !EmailValidator.validate(loginProvider.email) ) { loginProvider.setMessage('Email形式で入力してください'); return ; } loginProvider.signup() .then( (isSuccess) { if (isSuccess) { Navigator.pushAndRemoveUntil( context, MaterialPageRoute(builder: (context) => const LoginScreen()), (route) => false, ); } }) .catchError((error) => print(error)); }, ), ) ], ), ), ); } ), ); } }
LoginProviderの実装
ロジックの部分を実装します
プロバイダーはemailやパスワード情報を保持したり認証処理を行ったりする役割を担っています
togglePasswordVisibleのメソッドは例えば画面上でパスワードの表示非表示を切り替えるためのメソッドとなります
またauthのメソッドはいわゆるログインのメソッドとなっております
ログインの処理の流れとしてはまずはDioを呼び出してDioを使用する準備を整え、ログイン後に取得できるtokenをCookieに格納するための下準備をCookieJarなどを使って準備を行っています
またスマートフォンはストレージのアクセス権などが厳しく明示的にCookieの情報をストレージに書き込めることをpath_provider.dartのパッケージで明示しています(明示しなければストレージは読み取り専用)
そしてsaveFromResponseでクッキーを保存しています
これで認証情報を取得する処理は出来ました
認証情報を利用してAPIを呼び出す場合は直後の処理のようにヘッダーに 'Authorization': を付与します
正しい認証情報を持っていればアクセスができて、そうでなければエラーが返ってきます
また余談ですが、これはFlutter特有なのかわかりませんが、Flutterはエラーハンドリングはあまり細かく分けたりせずに大雑把にエラーをキャッチして一緒くたに処理するみたいですね
ネットに転がっている例とか見ていても細かくエラーハンドリングをしているケースをあまり見かけません
なのでこのアプリでもその慣習を踏襲して、エラーが起きたとしてもユーザーはエラーが起きたくらいのメッセージしか得られません
ユーザーフレンドリーじゃない気がしますが、Flutterの設計思想としてもしかしたらそもそも設計思想としてエラーを起こすやつは何やってもダメとかあるのかもしれないです
(Flutter開発歴が浅いので間違っていたらすみません)
providers/login_pro.dart
import 'dart:io'; import 'package:cookie_jar/cookie_jar.dart'; import 'package:dio/dio.dart'; import 'package:dio_cookie_manager/dio_cookie_manager.dart'; import 'package:flutter/material.dart'; import 'package:path_provider/path_provider.dart'; class LoginProvider with ChangeNotifier { bool _isSuccess = false; String message = ''; String email = ''; String password = ''; bool hidePassword = true; final UserModel _userModel = UserModel(); final Uri _uriHost = Uri.parse('http://10.0.2.2:8000'); // Mobile(エミュレータ)ではこちらのホストを利用する // final Uri _uriHost = Uri.parse('http://127.0.0.1:8000'); // Webではこちらのホストを利用する String getUserId() { return _userModel.id; } void setMessage(String msg) { message = msg; notifyListeners(); } void togglePasswordVisible() { hidePassword = !hidePassword; notifyListeners(); } Future<bool> auth() async { _isSuccess = false; message = ''; try { Dio dio = Dio(); dio.options.baseUrl = _uriHost.toString(); dio.options.connectTimeout = 5000; dio.options.receiveTimeout = 3000; dio.options.contentType = 'application/json'; List<Cookie> cookieList = []; Directory appDocDir = await getApplicationDocumentsDirectory(); String appDocPath = appDocDir.path; PersistCookieJar cookieJar = PersistCookieJar(storage: FileStorage(appDocPath+"/.cookies/")); dio.interceptors.add(CookieManager(cookieJar)); final responseJwt = await dio.post( '/authen/jwt/create', data: { 'email': email, 'password': password, } ); cookieList = [ ...cookieList, Cookie('access_token', responseJwt.data['access']) ]; cookieJar.saveFromResponse(_uriHost, cookieList); final responseUser = await dio.get( '/authen/users/me', options: Options( headers: { 'Authorization': 'JWT ${cookieList.first.value}', }, ), ); _userModel.id = responseUser.data['id']; _userModel.email = responseUser.data['email']; _isSuccess = true; } catch(error) { message = '正しいEメールとパスワードを入力してください'; print(error); _isSuccess = false; } notifyListeners(); return _isSuccess; } Future<void> logout() async { } Future<bool> signup() async { _isSuccess = false; message = ''; try { Dio dio = Dio(); dio.options.baseUrl = _uriHost.toString(); dio.options.contentType = 'application/json'; final response = await dio.post( '/api/users/create/', data: { 'email': email, 'password': password, 'username': '', } ); _userModel.id = response.data['id']; message = '新規ユーザーの仮登録が成功しました。本登録にはユーザーのアクティベーションを行って下さい'; _isSuccess = true; } catch(error) { message = '新規ユーザー登録処理が失敗しました。同じEmailは使用できません'; print(error); _isSuccess = false; } notifyListeners(); return _isSuccess; } }
http://10.0.2.2:8000 は http://127.0.0.1:8000/ のエイリアス となっており今回FlutterエミュレータとDjangoのバックエンド双方が127.0.0.1:8000のホストを占有しておりバッティングしているため、10.0.2.2:8000を指定することでエミュレータ側からバックエンドにアクセスすることができるようになります
後で環境変数化の解説があるので、その時にこちらのコードも環境変数化してください
CookieJar の PersistCookieJar を使用することでクッキーの認証情報をストレージに格納して永続的に利用できるようになります
これが実装できるとこんな画面でログインが可能となるはずです
MyProfileを実装してない場合は当該部分を適宜コメントアウトなどして動作確認してくださいな
※パスワードはログイン後に空文字を入れるなどして初期化したほうがいいですね
サインアップの動作確認
サインアップも同様に利用できることを確認しておきましょう
サインアップはバックエンド開発の時に行ったようにユーザーを新規作成するとEmailかバックエンド側コンソールにアクティベーション用のURLが表示されるので、スマートフォンエミュレータのブラウザを開いてそのURLにアクセスしましょう
今回は開発環境のためURLのホストは12.7.0.0.1の部分を10.0.2.2に書き換えてアクセスします
Stripe画面が呼び出されてクレジット決済が完了したらユーザーがアクティベーションされることが確認出来たらOKです
今回はStripeのリダイレクトをDjango Rest Frameworkで作成したViewで返されたデータをフロントエンドで適切に受け取って画面を表示させていないので、Djangoの開発者用の画面が表示されてしまっています
これはDjangoテンプレートを使って画面を作成する方がいいなとフロント実装しながら思いました
今の実装だとアカウント作成の導線がぐちゃっているので、アプリを本気で運用する場合はこの辺は修正したほうがよさそうですね
自己プロフィール
さて、ログインが完了すると最初は自己プロフィール画面に遷移させるようにしたので、自己プロフィール画面を実装していきます
自己プロフィール画面の実装
レイアウトはこちらになります
全体像
まずは全体コードを記載します
とてもコードが長いので細かい単位でスナックバーなどの共通部品は個別にウィジェットに分けています
screens/my_profile_screen.dart
import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:matchingappweb/pickers/graduation_picker_widget.dart'; import 'package:matchingappweb/pickers/passion_picker_widget.dart'; import 'package:matchingappweb/providers/login_provider.dart'; import 'package:matchingappweb/providers/profile_provider.dart'; import 'package:provider/provider.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import '../pickers/location_picker_widget.dart'; import '../pickers/sex_picker.dart'; import '../utils/show_snack_bar.dart'; import '../widgets/bottom_nav_bar_widget.dart'; import '../widgets/drawer_widget.dart'; class MyProfileScreen extends StatelessWidget { const MyProfileScreen({Key? key}) : super(key: key); @override Widget build(BuildContext context) { final Uri _uriHost = Uri.parse(dotenv.get('BACKEND_URL_HOST')); return Scaffold( appBar: AppBar( title: const Text('マイプロフィール'), ), body: Consumer2<ProfileProvider, LoginProvider>( builder: (context, profileProvider, loginProvider, _) { return Padding( padding: const EdgeInsets.all(32.0), child: SingleChildScrollView( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Stack( children: <Widget>[ profileProvider.uploadTopImage != null ? Image.file(profileProvider.uploadTopImage!) : profileProvider.myProfile.topImage != null ? Image.network('${profileProvider.myProfile.topImage?.replaceFirst(dotenv.get('STORAGE_URL_HOST'), _uriHost.toString())}', width: 100, fit: BoxFit.fill, errorBuilder: (context, error, stackTrace) { return Image.asset('images/nophotos.png',width: 100, fit: BoxFit.fill,); }, ) : Image.asset('images/nophotos.png', width: 100, fit: BoxFit.fill,), profileProvider.myProfile.isKyc ? const Icon(Icons.check_circle, color: Colors.greenAccent, size: 16,) : const SizedBox(), ], ), TextButton( child: const Text('画像変更'), onPressed: () => profileProvider.pickTopImage(), ), TextFormField( onChanged: (value) => profileProvider.myProfile.nickname = value, decoration: const InputDecoration(labelText: 'ニックネーム', hintText: 'このフィールドは必須です'), maxLength: 50, initialValue: profileProvider.myProfile.nickname, ), TextFormField( keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], onChanged: (value) => profileProvider.myProfile.age = int.parse(value), decoration: const InputDecoration(labelText: '年齢', hintText: '18歳未満は登録出来ません',), enabled: profileProvider.myProfile.user == null, maxLength: 2, initialValue: profileProvider.myProfile.age.toString(), ), for (String key in sexPicker.keys) ... { RadioListTile( value: key, groupValue: profileProvider.myProfile.sex, title: Text(sexPicker[key] ?? '性別不詳'), selected: profileProvider.myProfile.sex == key, onChanged: (value) { if (profileProvider.myProfile.user == null) { profileProvider.myProfile.sex = value.toString(); profileProvider.notifyListeners(); } else { null; } }, ) }, TextFormField( keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], onChanged: (value) => profileProvider.myProfile.height = int.parse(value), decoration: const InputDecoration(labelText: '身長cm', hintText: '140cm以上200cm未満で入力してください'), maxLength: 3, initialValue: profileProvider.myProfile.height != null ? profileProvider.myProfile.height.toString() : '', ), const LocationPickerWidget(), TextFormField( onChanged: (value) => profileProvider.myProfile.work = value, decoration: const InputDecoration(labelText: '仕事',), maxLength: 20, initialValue: profileProvider.myProfile.work, ), TextFormField( keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], onChanged: (value) => profileProvider.myProfile.revenue = int.parse(value), decoration: const InputDecoration(labelText: '収入(万円)',), maxLength: 4, initialValue: profileProvider.myProfile.revenue.toString(), ), const GraduationPickerWidget(), TextFormField( onChanged: (value) => profileProvider.myProfile.hobby = value, decoration: const InputDecoration(labelText: '趣味',), maxLength: 20, initialValue: profileProvider.myProfile.hobby, ), const PassionPickerWidget(), TextFormField( onChanged: (value) => profileProvider.myProfile.tweet = value, decoration: const InputDecoration(labelText: 'つぶやき',), maxLength: 10, initialValue: profileProvider.myProfile.tweet, ), TextFormField( keyboardType: TextInputType.multiline, onChanged: (value) => profileProvider.myProfile.introduction = value, decoration: const InputDecoration(labelText: '自己紹介',), maxLength: 1000, maxLines: null, initialValue: profileProvider.myProfile.introduction, ), Padding( padding: const EdgeInsets.only(top: 16.0), child: ElevatedButton( child: profileProvider.myProfile.user == null ? const Text('プロフィールを作成する') : const Text('プロフィールを更新する'), style: ElevatedButton.styleFrom( fixedSize: Size(MediaQuery.of(context).size.width * 0.95, 32), ), onPressed: () { if (profileProvider.myProfile.user == null) { profileProvider.createMyProfile(loginProvider.getUserId()).then((isSuccess) { if (isSuccess) { Navigator.pushReplacementNamed(context, '/my-profile'); showSnackBar(context, 'プロフィールが新規作成されました'); } else { showSnackBar(context, 'エラーが発生しました'); } }); } else { profileProvider.updateMyProfile(loginProvider.getUserId()).then((isSuccess) { if (isSuccess) { Navigator.pushReplacementNamed(context, '/my-profile'); showSnackBar(context, 'プロフィール更新が完了しました'); } else { showSnackBar(context, 'エラーが発生しました'); } }); } }, ), ), ], ), ), ); }, ), drawer: const DrawerWidget(), bottomNavigationBar: const BottomNavBarWidget(), ); } }
共通部品のコード実装
スナックバー
ボタンを押すなどのユーザーアクション後にメッセージが表示されてくるアレです
トーストともいわれるUI部品です
utils/show_snack_bar.dart
import 'package:flutter/material.dart'; void showSnackBar(BuildContext context, String msg) { final snackBar = SnackBar( content: Text(msg), action: SnackBarAction(label: '閉じる', onPressed: () {}), ); ScaffoldMessenger.of(context).showSnackBar(snackBar); }
ボトムナブバー
ボトムナビゲーションバーとか言ったり同義語がたくさんありますがアプリの下部にあるナビゲーションバーです
今回はただの飾りつけとしての実装しかしていません
widgets/bottom.dart
class BottomNavBarWidget extends StatelessWidget { const BottomNavBarWidget({ Key? key, }) : super(key: key); @override Widget build(BuildContext context) { return BottomNavigationBar( items: [ BottomNavigationBarItem( label: '相手探し', icon: IconButton( icon: const Icon(Icons.wc), onPressed: () { // Navigator.pushNamed(context, '/looking'); }, ) ), BottomNavigationBarItem( label: 'いいねリスト', icon: IconButton( icon: const Icon(Icons.volunteer_activism), onPressed: () { // Navigator.pushNamed(context, '/chance'); }, ) ), BottomNavigationBarItem( label: 'メッセージ', icon: IconButton( icon: const Icon(Icons.message), onPressed: () { // Navigator.pushNamed(context, '/message'); }, ) ), ], ); } }
ドロワー
左右から出てくるアレですね
今回はこれで画面移動を行います
widgets/drawer.dart
class DrawerWidget extends StatelessWidget { const DrawerWidget({ Key? key, }) : super(key: key); @override Widget build(BuildContext context) { return Drawer( child: Consumer4<LoginProvider, ProfileProvider, MatchingProvider, MessageProvider>( builder: (context, loginProvider, profileProvider, matchingProvider, messageProvider, _) { return ListView( children: [ const DrawerHeader( child: Text('ようこそ俺の嫁探しへ', style: TextStyle(color: Colors.white),), decoration: BoxDecoration( color: Colors.blue, ), ), ListTile( title: const Text("相手を探す"), trailing: const Icon(Icons.arrow_forward), onTap: () async { bool _isSuccess = await profileProvider.fetchProfileList(); if (_isSuccess) Navigator.pushNamed(context, '/looking'); }, ), ListTile( title: const Text("いいねした人リスト"), trailing: const Icon(Icons.arrow_forward), onTap: () async { bool _isSuccess = await profileProvider.fetchProfileApproachingList(); if (_isSuccess) Navigator.pushNamed(context, '/favorite'); }, ), ListTile( title: const Text("いいねをもらった人リスト"), trailing: const Icon(Icons.arrow_forward), onTap: () async { bool _isSuccess = await profileProvider.fetchProfileApproachedList(); if (_isSuccess) Navigator.pushNamed(context, '/chance'); }, ), ListTile( title: const Text("マッチング成立リスト"), trailing: const Icon(Icons.arrow_forward), onTap: () async { bool _isSuccess = await profileProvider.fetchProfileMatchingList(); if (_isSuccess) Navigator.pushNamed(context, '/matching'); }, ), ListTile( title: const Text("メッセージ"), trailing: const Icon(Icons.arrow_forward), onTap: () {} // Navigator.pushNamed(context, '/message'), ), ListTile( title: const Text("自己プロフィール"), trailing: const Icon(Icons.arrow_forward), onTap: () { profileProvider.fetchMyProfile(loginProvider.getUserId()) .then( (_) { Navigator.pushNamed(context, '/my-profile'); }); } ), ], ); } ) ); } }
環境変数の導入
開発環境や本番環境の分離やシークレット情報の保持などのために使用する環境変数を設定します
以下の環境変数用のパッケージをインストールしてマニュアルに記載の設定を行っていきます
flutter_dotenv | Flutter Package
.env.devと.env.prodをプロジェクト直下に作成してassetsに記載します
pabspec.yaml
dependencies: flutter_dotenv: ^5.0.2 flutter: assets: - .env.dev - .env.prod
flutter pub get コマンドを叩いてインストールします
その後、.envファイルに環境変数を記載します
.env.dev
BACKEND_URL_HOST=http://10.0.2.2:8000 STORAGE_URL_HOST=http://127.0.0.1:8000/
import 'package:flutter_dotenv/flutter_dotenv.dart'; Future<void> main() async { await dotenv.load(fileName: ".env.dev"); runApp(); }
その後は読み込みたいファイルで環境変数を読み込みます
my_profile_screen.dart
import 'package:flutter_dotenv/flutter_dotenv.dart'; class MyProfileScreen extends StatelessWidget { Widget build(BuildContext context) { String _urlString = dotenv.get('BACKEND_URL_HOST'); final Uri _uriHost = Uri.parse(_urlString); ... } }
pickerの導入
詳しい手順はQiitaにまとめました
Provider利用時のFlutterのpicker(ドラムロール)の実装方法 - Qiita
例:graduation_picker.dart
final Map<String, String> graduationPicker = { '' : '', 'junior_high_school' : '中卒', 'high_school' : '高卒', 'trade_school' : '短大・専門学校卒', 'university' : '大卒', 'grad_school' : '大学院卒', };
例:graduation_picker_widget.dart
import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:matchingappweb/pickers/graduation_picker.dart'; import 'package:provider/provider.dart'; import '../providers/profile_provider.dart'; class GraduationPickerWidget extends StatelessWidget { const GraduationPickerWidget({ Key? key, }) : super(key: key); @override Widget build(BuildContext context) { return Consumer<ProfileProvider>( builder: (context, profileProvider, _) { return Row( children: [ const Text('最終学歴 ',), Text(graduationPicker[profileProvider.myProfile.graduation] ?? '', style: const TextStyle(fontWeight: FontWeight.bold)), TextButton( child: const Text('選択'), onPressed: () { showModalBottomSheet( context: context, builder: (BuildContext context) { return Container( height: MediaQuery.of(context).size.height / 2, child: Column( children: [ Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ TextButton( child: const Text('戻る'), onPressed: () => Navigator.pop(context), ), TextButton( child: const Text('決定'), onPressed: () { profileProvider.notifyListeners(); Navigator.pop(context); }, ), ], ), Container( height: MediaQuery.of(context).size.height / 3, child: CupertinoPicker( itemExtent: 40, children: [ for (String key in graduationPicker.keys) ... { Text(graduationPicker[key] ?? '最終学歴不詳') }, ], onSelectedItemChanged: (int index) => profileProvider.myProfile.graduation = graduationPicker.keys.elementAt(index), ), ) ], ), ); } ); }, ), ], ); } ); } }
ProfileProviderの実装
それでは自己プロフィールを扱うためのデータとロジックをProviderに実装していきましょう
以下が自己プロフィールに関連したコード全体です
profile_provider.dart
import 'dart:io'; import 'package:cookie_jar/cookie_jar.dart'; import 'package:dio/dio.dart'; import 'package:dio_cookie_manager/dio_cookie_manager.dart'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:image/image.dart'; import 'package:path_provider/path_provider.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import '../models/profile_model.dart'; class ProfileProvider with ChangeNotifier { final Uri _uriHost = Uri.parse(dotenv.get('BACKEND_URL_HOST')); bool _isSuccess = false; File? uploadTopImage; ProfileModel myProfile = ProfileModel( user: null, isSpecial: false, isKyc: false, nickname: '', topImage: null, createdAt: null, updatedAt: null, age: 0, sex: '', height: null, location: null, work: null, revenue: 0, graduation: null, hobby: null, passion: null, tweet: null, introduction: null, sendFavorite: null, receiveFavorite: null, stockFavorite: null, ); Future fetchMyProfile(String userId) async { _isSuccess = false; try { Dio dio = Dio(); List<Cookie> cookieList = await _prepareDio(dio); final Response<dynamic> profile = await dio.get( '/api/users/profile/$userId', options: Options( headers: { 'Authorization': 'JWT ${cookieList.first.value}', }, ), ); myProfile = _inputProfileModel(profile.data!); _isSuccess = true; } catch(error) { print(error); _isSuccess = false; } notifyListeners(); return _isSuccess; } Future pickTopImage() async { _isSuccess = false; final ImagePicker _picker = ImagePicker(); try { final XFile? image = await _picker.pickImage(source: ImageSource.gallery, imageQuality: 25); if (image != null) { final _imageDecode = decodeImage(File(image.path).readAsBytesSync()); if (_imageDecode != null) { var _imageResize; const int _imageLongSide = 720; if (_imageDecode.width > _imageDecode.height) { if (_imageDecode.width > _imageLongSide) { _imageResize = copyResize( _imageDecode, width: _imageLongSide, height: _imageLongSide * _imageDecode.height ~/ _imageDecode.width ); } } else { if (_imageDecode.height > _imageLongSide) { _imageResize = copyResize( _imageDecode, width: _imageLongSide * _imageDecode.width ~/ _imageDecode.height, height: _imageLongSide ); } } if (_imageResize != null) { File(image.path).writeAsBytesSync(encodePng(_imageResize)); } } uploadTopImage = File(image.path); } _isSuccess = true; } catch (error) { print("エラーが発生しました"); _isSuccess = false; } notifyListeners(); return _isSuccess; } Future createMyProfile(String userId) async { _isSuccess = false; try { Dio dio = Dio(); List<Cookie> cookieList = await _prepareDio(dio); FormData formData = FormData.fromMap({ "is_special": false, "is_kyc": false, "top_image": uploadTopImage != null ? await MultipartFile.fromFile( uploadTopImage!.path, filename: uploadTopImage!.path.split('/').last, ) : myProfile.topImage, "nickname": myProfile.nickname, "age": myProfile.age, "sex": myProfile.sex, "height": myProfile.height, "location": myProfile.location, "work": myProfile.work, "revenue": myProfile.revenue, "graduation": myProfile.graduation, "hobby": myProfile.hobby, "passion": myProfile.passion, "tweet": myProfile.tweet, "introduction": myProfile.introduction, "send_favorite": myProfile.sendFavorite, "receive_favorite": myProfile.receiveFavorite, "stock_favorite": myProfile.stockFavorite }); final Response profile = await dio.post( '/api/profiles/', options: Options( headers: { 'Authorization': 'JWT ${cookieList.first.value}', }, ), data: formData, ); myProfile = _inputProfileModel(profile.data!); _isSuccess = true; } catch(error) { print(error); _isSuccess = false; } notifyListeners(); return _isSuccess; } Future updateMyProfile(String userId) async { _isSuccess = false; try { Dio dio = Dio(); List<Cookie> cookieList = await _prepareDio(dio); FormData formData = FormData.fromMap({ "top_image": uploadTopImage != null ? await MultipartFile.fromFile( uploadTopImage!.path, filename: uploadTopImage!.path.split('/').last, ) : myProfile.topImage, "nickname": myProfile.nickname, "height": myProfile.height, "location": myProfile.location, "work": myProfile.work, "revenue": myProfile.revenue, "graduation": myProfile.graduation, "hobby": myProfile.hobby, "passion": myProfile.passion, "tweet": myProfile.tweet, "introduction": myProfile.introduction, }); final Response profile = await dio.patch( '/api/users/profile/$userId/', options: Options( headers: { 'Authorization': 'JWT ${cookieList.first.value}', }, ), data: formData, ); myProfile = _inputProfileModel(profile.data!); _isSuccess = true; } catch(error) { print(error); _isSuccess = false; } notifyListeners(); return _isSuccess; } ProfileModel _inputProfileModel(dynamic profile) { return ProfileModel( user: profile!['user'], isSpecial: profile!['is_special'], isKyc: profile!['is_kyc'], topImage: profile!['top_image'], nickname: profile!['nickname'], createdAt: profile!['created_at'], updatedAt: profile!['updated_at'], age: profile!['age'], sex: profile!['sex'], height: profile!['height'], location: profile!['location'], work: profile!['work'], revenue: profile!['revenue'], graduation: profile!['graduation'], hobby: profile!['hobby'], passion: profile!['passion'], tweet: profile!['tweet'], introduction: profile!['introduction'], sendFavorite: profile!['send_favorite'], receiveFavorite: profile!['receive_favorite'], stockFavorite: profile!['stock_favorite'], // profile!['fromLastLogin'], ); } Future<List<Cookie>> _prepareDio(Dio dio) async { dio.options.baseUrl = _uriHost.toString(); dio.options.connectTimeout = 5000; dio.options.receiveTimeout = 3000; // dio.options.contentType = 'application/json' or 'multipart/form-data'; Directory appDocDir = await getApplicationDocumentsDirectory(); String appDocPath = appDocDir.path; PersistCookieJar cookieJar = PersistCookieJar(storage: FileStorage(appDocPath+"/.cookies/")); dio.interceptors.add(CookieManager(cookieJar)); List<Cookie> cookieList = await cookieJar.loadForRequest(_uriHost); return cookieList; } }
CookieやDioの使い方はLoginの機能を作成したときと同じです
ここではmy_profileという変数を用意して、自己プロフィールを新規作成するメソッドと取得するメソッドと更新するメソッドを作成しました
Profileモデルが大きいのでコード量が大きく見えていますが、基本的にはバックエンドで作成したAPIのエンドポイントを呼んでいるだけです
ただ、HTTPクライアントで取得したデータは型がDynamicになってしまうため、少し面倒ですがProfile型に戻す処理をつける必要があり、その処理が_inputProfileModelです
また画像のアップロードも行えるロジックを実装しました
画像などのデータを含むフォームデータを作成する場合はFormData型でFormData.fromMapを使用するとよいところがポイントです
画像のアップロード
詳細はQiitaに記述しました
Flutter×Django(DRF)で画像をアップロードする方法 - Qiita
この実装ではpickTopImageのメソッドでproviderにセットし、プロフィール作成時もしくは更新時にアップロードするようになっています
現段階の実装ではほかの画面に遷移したときに画像はクリアされませんが、実運用では画像のアップロードを取りやめたい場合の画像クリア機能をつける必要があります
動作確認
新規アカウントを作成したり、既存のアカウントを使用して、ログインします
そしてプロフィール作成や更新を行って、作成や更新が出来たらスナップバーが出現し作成・更新されたデータができていることをDjangoの管理画面などでも確認してください
プロフィール一覧閲覧機能&いいね機能の実装
次に異性を閲覧できるプロフィール一覧機能を作っていきます
プロフィール全件取得コードの作成
まずはProfileProviderにProfileデータを取り扱うための処理を記載していきます
まずは画面共有するための変数を定義します
ProfileProvider.dart
/// プロフィール機能 List<ProfileModel> profileList = []; List<ProfileModel> profileApproachingList = []; List<ProfileModel> profileApproachedList = []; List<ProfileModel> profileMatchingList = []; ProfileModel? profileDetail;
そしてバックエンドから全件データを取得する処理をまずは記載します
Provider内のプライベートメソッドにします
バックエンドで実装したAPIをHTTPクライアントのDIOで呼び出しています
またmy_profileの時と同様に、HTTPクライアントで取得したデータは型がDynamicになってしまうためProfile型に戻す処理をつける必要があり、これらはこの後も他のメソッドで頻繁に共通利用されるため_inputMessageModel、_inputMessageModelListのメソッドとして処理を外だししています
ProfileProvider.dart
/// 【プライベート】プロフィール全件取得 Future _fetchProfileAllList() async { _isSuccess = false; profileList.clear(); try { Dio dio = Dio(); List<Cookie> cookieList = await _prepareDio(dio); final Response profiles = await dio.get( '/api/profiles', options: Options( headers: { 'Authorization': 'JWT ${cookieList.first.value}', }, ), ); profileList = _inputProfileModelList(profiles.data!); _isSuccess = true; } catch(error) { print(error); _isSuccess = false; } notifyListeners(); return _isSuccess; } /// 【プライベート】バックエンドから取得したメッセージデータのProvider化 MessageModel _inputMessageModel(dynamic message) { return MessageModel( id: message!['id'], sender: message!['sender'], receiver: message!['receiver'], message: message!['message'], createdAt: message!['created_at'], ); } /// 【プライベート】バックエンドから取得したメッセージデータ一覧のProvider化 List<MessageModel> _inputMessageModelList(dynamic messageList) { return messageList.map<MessageModel>( (message) => _inputMessageModel(message) ).toList(); }
マッチングデータモデルの作成
次にプロフィール一覧画面ではすでにいいねを送ったユーザーやいいねをもらったユーザーやマッチングしているユーザーは表示させない仕様とするため、いいね機能であるマッチングモデルの処理をここで追加していきます
_matchingList はマッチング中のMatchingModelデータがリストで格納され、_approachingList はいいねを送ったユーザー、_approachedList はいいねをくれたユーザーを格納します
またフィルタリングの処理の都合上、_matchingUserIdList のように相手ユーザーのIDだけをリスト化したものも作成しておきます
ProfileProvider.dart
/// マッチング機能 MatchingModel? matching; List<MatchingModel> _matchingList = []; List<String> _matchingUserIdList = []; List<MatchingModel> _approachingList = []; List<String> _approachingUserIdList = []; List<MatchingModel> _approachedList = []; List<String> _approachedUserIdList = [];
そして、バックエンドから取得したマッチングに関する全件データをローカルでそれぞれのデータに振り分けていく処理を実装して、上記で作成した変数に振り分けたデータをmapやwhereを使ってフィルタリングし、それぞれ格納していきます
ProfileProvider.dart
/// 【プライベート】いいねしているユーザー・いいねされているユーザー・マッチングしているユーザーのそれぞれのマッチングリストを取得する Future _fetchMatchingList() async { _isSuccess = false; try { Dio dio = Dio(); List<Cookie> cookieList = await _prepareDio(dio); final Response matchingList = await dio.get( '/api/favorite/', options: Options( headers: { 'Authorization': 'JWT ${cookieList.first.value}', }, ), ); _matchingList = _inputMatchingModelList(matchingList.data!); _approachingList = _matchingList.where((matching) => matching.approaching == myProfile.user).toList(); _approachingUserIdList = _approachingList.map((matching) => matching.approached).toList(); _approachedList = _matchingList.where((matching) => matching.approached == myProfile.user).toList(); _approachedUserIdList = _approachedList.map((matching) => matching.approaching!).toList(); _matchingList = _matchingList.where((matching) => matching.approved == true).toList(); _matchingUserIdList = _matchingList.map((matching) => matching.approaching!).toList(); _isSuccess = true; } catch(error) { print(error); _isSuccess = false; } notifyListeners(); return _isSuccess; }
フィルタリングの実装
プロフィール一覧画面に表示するユーザーのフィルタリング
上記で格納した _approachingList などの変数を使用していいねを送ったユーザーといいねをくれたユーザーを取り除く処理を記述します
removeWhereを使います
実装はこちらで以上となります
ちなみに後で気づきましたが、_isSuccessの部分の実装は正しくない実装です
余力のある日とはtry catchで囲んで実装の修正をしておきましょう
まあこのままでも動きますが、今のところ
ProfileProvider.dart
/// ユーザーのプロフィール一覧を取得する(いいね・マッチング状態のユーザーは除外) Future fetchProfileList() async { _isSuccess = false; _isSuccess = await _fetchProfileAllList(); _isSuccess = await _fetchMatchingList(); profileList.removeWhere((profile) => _approachingUserIdList.contains(profile.user)); profileList.removeWhere((profile) => _approachedUserIdList.contains(profile.user)); notifyListeners(); return _isSuccess; }
いいねを送ったユーザーのフィルタリング
上記で格納した _approachingList の変数を使用していいねを送ったユーザーをフィルタリングしていく処理を記述します
whereを使用しています
特段上の実装と変わったところはありません
ProfileProvider.dart
/// いいねしたユーザーのプロフィール一覧を取得する(マッチング状態のユーザーは除外) Future fetchProfileApproachingList() async { _isSuccess = false; _isSuccess = await _fetchProfileAllList(); _isSuccess = await _fetchMatchingList(); profileApproachingList = profileList.where((profile) => _approachingUserIdList.contains(profile.user)).toList(); profileApproachingList.removeWhere((profile) => _matchingUserIdList.contains(profile.user)); notifyListeners(); return _isSuccess; }
いいねをもらったユーザーのフィルタリング
同様に
ProfileProvider.dart
/// いいねされたユーザーのプロフィール一覧を取得する(マッチング状態のユーザーは除外) Future fetchProfileApproachedList() async { _isSuccess = false; _isSuccess = await _fetchProfileAllList(); _isSuccess = await _fetchMatchingList(); profileApproachedList = profileList.where((profile) => _approachedUserIdList.contains(profile.user)).toList(); profileApproachedList.removeWhere((profile) => _matchingUserIdList.contains(profile.user)); notifyListeners(); return _isSuccess; }
マッチングしたユーザーのフィルタリング
同様です
ProfileProvider.dart
/// マッチングしているユーザーのプロフィール一覧を取得する Future fetchProfileMatchingList() async { _isSuccess = false; _isSuccess = await _fetchProfileAllList(); _isSuccess = await _fetchMatchingList(); profileMatchingList = profileList.where((profile) => _matchingUserIdList.contains(profile.user)).toList(); notifyListeners(); return _isSuccess; }
ユーザー一覧画面の作成
それではユーザー一覧画面を実装していきます
一覧画面は4画面で共通利用するので共通部分を共通化しておきます
リストビューで実装して画像とニックネームと年齢とつぶやきが表示されるような実装にしました
共通利用画面のWidget化
widgets/ProfileListWidget .dart
class ProfileListWidget extends StatelessWidget { ProfileListWidget({ Key? key, required List<ProfileModel> profiles, String? nextUrl, Function? nextAction }) : _profiles = profiles, _nextUrl = nextUrl ?? '/profile-detail', super(key: key); final List<ProfileModel> _profiles; final String _nextUrl; // Flutter Web の場合 dotenv.get('BACKEND_URL_HOST_CASE_FLUTTER_WEB') final Uri _uriHost = Uri.parse(dotenv.get('BACKEND_URL_HOST')); @override Widget build(BuildContext context) { return Consumer<ProfileProvider>( builder: (context, profileProvider, _) { return ListView.builder( itemCount: _profiles.length, itemBuilder: (BuildContext context, int index) { return ListTile( leading: Stack( children: <Widget>[ _profiles[index].topImage != null ? Image.network('${_profiles[index].topImage?.replaceFirst( dotenv.get('STORAGE_URL_HOST'), _uriHost.toString())}', errorBuilder: (context, error, stackTrace) { return Image.asset('images/nophotos.png',); }, ) : Image.asset('images/nophotos.png',), _profiles[index].isKyc ? const Icon( Icons.check_circle, color: Colors.greenAccent, size: 16,) : const SizedBox(), ], ), title: Text('${_profiles[index].nickname} ${_profiles[index].age}歳'), subtitle: Text(_profiles[index].tweet ?? ''), trailing: Icon( profileProvider.checkSendFavorite(_profiles[index].user ?? '') ? Icons.favorite : Icons.favorite_border, color: Colors.pinkAccent, ), onTap: () async { profileProvider.setProfileDetail(_profiles[index]); if(_nextUrl == '/message') await profileProvider.getMessageList(); Navigator.pushNamed(context, _nextUrl); }, ); }, ); } ); } }
プロフィール一覧画面の実装
screens/ProfilesScreen.dart
class ProfilesScreen extends StatelessWidget { const ProfilesScreen({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('プロフィール一覧'), ), body: Consumer<ProfileProvider>( builder: (context, profileProvider, _) { return ProfileListWidget(profiles: profileProvider.profileList); }, ), drawer: DrawerWidget(), bottomNavigationBar: BottomNavBarWidget(), ); } }
いいねを送ったユーザーのプロフィール一覧画面
上記とほぼ同じ実装です
以下の部分が主に違います
screens/ApproachingScreen.dart
return ProfileListWidget(profiles: profileProvider.profileApproachingList);
いいねをもらったユーザーのプロフィール一覧画面
上記と同様です
引数にprofileProvider.profileApproachedListを与えます
マッチング中のユーザーのプロフィール一覧画面
上記と同様ですが、引数にprofiles: profileProvider.profileMatchingList と それだけでなく、一覧のユーザーをタップしたときに、マッチング画面ではメッセージ画面に飛ぶようにnextUrl: '/message',の引数を加えておきます
ユーザー情報詳細画面の実装
一覧画面のうち特定のユーザーをタップすると詳細情報を見られるように詳細画面を作成します
ユーザー詳細選択ロジック
タップしたときにどのユーザーをタップしたかわかるようなロジックをProviderに実装します
ProfileProvider.dart
ProfileModel? profileDetail; /// 選択したユーザーのプロフィールをセットする void setProfileDetail(ProfileModel profile) { profileDetail = profile; notifyListeners(); }
詳細画面実装
詳細画面では先ほど格納したprofileDetail の情報を元に詳細を表示する画面を作成します
レイアウトは適当ですので、適宜手を加えて改善してください
ProfileDetailScreen.dart
class ProfileDetailScreen extends StatelessWidget { const ProfileDetailScreen({Key? key,}) : super(key: key); @override Widget build(BuildContext context) { final Uri _uriHost = Uri.parse('http://10.0.2.2:8000'); return Scaffold( appBar: AppBar( title: const Text('プロフィール詳細'), ), body: Consumer<ProfileProvider>( builder: (context, profileProvider, _) { return Padding( padding: const EdgeInsets.all(32.0), child: SingleChildScrollView( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Padding( padding: const EdgeInsets.all(8.0), child: Text(profileProvider.profileDetail!.nickname, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),), ), Stack( children: <Widget>[ profileProvider.profileDetail!.topImage != null ? Image.network('${profileProvider.profileDetail!.topImage?.replaceFirst('http://127.0.0.1:8000/', _uriHost.toString())}', width: 100, fit: BoxFit.fill, errorBuilder: (context, error, stackTrace) { return Image.asset('images/nophotos.png',width: 100, fit: BoxFit.fill,); }, ) : Image.asset('images/nophotos.png', width: 100, fit: BoxFit.fill,), profileProvider.profileDetail!.isKyc ? const Icon(Icons.check_circle, color: Colors.greenAccent, size: 16,) : const SizedBox(), ], ), Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ Text('${profileProvider.profileDetail!.age.toString()}歳',), Text(sexPicker[profileProvider.profileDetail!.sex] ?? '',), Text(locationPicker[profileProvider.profileDetail!.location] ?? '',), ], ), Padding( padding: const EdgeInsets.all(8.0), child: Text('送ったいいね ${profileProvider.profileDetail!.sendFavorite ?? ''}',), ), Padding( padding: const EdgeInsets.all(8.0), child: Text('もらったいいね ${profileProvider.profileDetail!.receiveFavorite ?? ''}',), ), Padding( padding: const EdgeInsets.all(8.0), child: Text('所持しているいいね ${profileProvider.profileDetail!.stockFavorite ?? ''}',), ), const Padding( padding: EdgeInsets.all(8.0), child: Text('いいねする',), ), Padding( padding: const EdgeInsets.all(8.0), child: IconButton( icon: Icon( profileProvider.checkSendFavorite(profileProvider.profileDetail!.user ?? '') ? Icons.favorite : Icons.favorite_border, color: Colors.pinkAccent ), onPressed: () async { if (!profileProvider.checkSendFavorite(profileProvider.profileDetail!.user ?? '')) { profileProvider.sendFavorite().then((isSuccess) { if (isSuccess) { showSnackBar(context, 'いいねを送りました'); } else { showSnackBar(context, 'エラーが発生しました'); } }); } }, ), ), Padding( padding: const EdgeInsets.all(8.0), child: Text(profileProvider.profileDetail!.tweet ?? '', style: const TextStyle(decoration: TextDecoration.underline),), ), Padding( padding: const EdgeInsets.all(8.0), child: Text('${profileProvider.profileDetail!.height ?? ''}cm',), ), Padding( padding: const EdgeInsets.all(8.0), child: Text('お仕事 ${profileProvider.profileDetail!.work ?? ''}',), ), Padding( padding: const EdgeInsets.all(8.0), child: Text('${profileProvider.profileDetail!.revenue ?? ''}万円',), ), Padding( padding: const EdgeInsets.all(8.0), child: Text(graduationPicker[profileProvider.profileDetail!.graduation] ?? '',), ), Padding( padding: const EdgeInsets.all(8.0), child: Text('趣味 ${profileProvider.profileDetail!.hobby ?? ''}',), ), Padding( padding: const EdgeInsets.all(8.0), child: Text('結婚願望 ${passionPicker[profileProvider.profileDetail!.passion ?? '']}' ,), ), const Padding( padding: EdgeInsets.all(8.0), child: Text('自己紹介',), ), Container( padding: const EdgeInsets.all(8.0), decoration: BoxDecoration(border: Border.all()), child: Padding( padding: const EdgeInsets.all(8.0), child: Text(profileProvider.profileDetail!.introduction ?? '',), ), ), ], ), ), ); }, ), drawer: const DrawerWidget(), bottomNavigationBar: const BottomNavBarWidget(), ); } }
いいね送信機能の実装
さて詳細画面ができたので、詳細画面からいいねを押すことができる機能を作成していきましょう
全体としてのコードはこちらです
sendFavoriteがいいねを送るロジックを記載したコードであり、相手とのいいねの状態によって処理が異なります
check〇〇Faoriteはコメントアウト通りの処理になっており条件判定に使います
ProfileProvider.dart
/// いいねをしたユーザーかどうかをチェックする bool checkSendFavorite(String userId) { return _approachingUserIdList.contains(userId); } /// いいねをくれたユーザーかどうかをチェックする bool checkReceiveFavorite(String userId) { return _approachedUserIdList.contains(userId); } /// いいねを送る・承認する Future sendFavorite() async { _isSuccess = false; try { String approachUserId = profileDetail != null ? profileDetail!.user! : ''; // 既にこちらからいいねを送っている場合は処理を行わない if (checkSendFavorite(approachUserId)) { // 何も処理を行わない } // 既に相手からいいねが来ている場合は上記のリクエストデータのapprovedをTrueにしていいねを行い、相手のマッチングモデルデータのapprovedもTrueに更新する else if (checkReceiveFavorite(approachUserId)) { // いいね新規作成処理 await _createFavorite(approached: approachUserId, approved: true); // いいねをくれたユーザーのマッチングリストの中でapproachUserIdと一致するマッチングデータのIDを探索する Iterable<MatchingModel> approachMatching = _approachedList.where((matching) => matching.approaching == approachUserId); int approachMatchingId = approachMatching.first.id ?? 0; // 相手のいいねデータに対する承認処理 await _patchApproved(id: approachMatchingId); // マッチングデータ再取得 await fetchProfileMatchingList(); } // どちらもいいねを送っていない場合は上記のリクエストデータを用いてマッチングデータを作成する else { // いいね新規作成処理 await _createFavorite(approached: approachUserId, approved: false); // いいねしたユーザー一覧データ再取得 await fetchProfileApproachingList(); } _isSuccess = true; } catch(error) { print(error); _isSuccess = false; } notifyListeners(); return _isSuccess; } /// 【プライベート】マッチングデータ新規作成 Future _createFavorite({required String approached, required bool approved}) async { Dio dio = Dio(); List<Cookie> cookieList = await _prepareDio(dio); return await dio.post( '/api/favorite/', options: Options( headers: { 'Authorization': 'JWT ${cookieList.first.value}', }, ), data: { "approached": approached, "approved": approved, }, ); } /// 【プライベート】マッチングデータ承認フィールド更新 Future _patchApproved({required int id}) async { Dio dio = Dio(); List<Cookie> cookieList = await _prepareDio(dio); return await dio.patch( '/api/favorite/$id/', options: Options( headers: { 'Authorization': 'JWT ${cookieList.first.value}', }, ), data: { "approved": true }, ); }
既にもらっているいいねに対する承認ロジックについて
条件分岐ごとの処理はコメントアウトに記載されている通りですが、すでにいいねをもらっている人にいいねを送る処理というのは言い換えるといいねを承認していることと同じです
相手からすでにいいねが来ているということは、相互でいいねを送り合うので、マッチング成立ととらえます
こちらからは承認済のいいね(マッチングモデルデータ)を送信して、相手からすでにもらっているいいねデータのapprovedを承認済みに変更します
approvedはメッセージ送信を許可するかどうかを判定するためのフィールドであるため必ず両者のマッチングデータのapprovedをTrueにします
相手のデータを更新する必要があるため、_patchApprovedのメソッドを作成しています(※Djangoの記事でバックエンドのPatchを拒否する設定になってたかもしれないので、そうなっていた場合は当該コードをコメントアウトするなり修正してください)
これにて、いいね機能を実装することができました
メッセージ機能の実装
最後にメッセージ機能を作成します
ModelとProviderの実装
Modelの実装
MessageModel .dart
class MessageModel { late int? id = 0; late String sender = ''; late String receiver = ''; late String message = ''; late String? createdAt; MessageModel({ this.id, required this.sender, required this.receiver, required this.message, this.createdAt, }); }
全件メッセージを取得するロジック
別ファイルに分けたいのは山々ですが、Provider間でのデータの受け渡しに関しては、理解できておらず状態管理がうまくできるコードを作成できないので、ProfileProviderが長大になるとは思いつつもこちらに追記していきます
上記で実装してきた容量で状態管理したい変数とロジックを作成していきます
ProfileProvider.dart
/// メッセージ機能 List<MessageModel> messageList = []; List<MessageModel> _sendMessageList = []; List<MessageModel> _receiveMessageList = []; String newMessage = '';
バックエンドからデータを取得してデータを格納する処理
ProfileProvider.dart
/// 【プライベート】送ったメッセージの内容を全件取得する Future _fetchSendMessageList() async { Dio dio = Dio(); List<Cookie> cookieList = await _prepareDio(dio); final Response<dynamic> message = await dio.get( '/api/dm-message/', options: Options( headers: { 'Authorization': 'JWT ${cookieList.first.value}', }, ), ); _sendMessageList = _inputMessageModelList(message.data!); } /// 【プライベート】受け取ったメッセージの内容を全件取得する Future _fetchReceiveMessageList() async { Dio dio = Dio(); List<Cookie> cookieList = await _prepareDio(dio); final Response<dynamic> message = await dio.get( '/api/dm-inbox/', options: Options( headers: { 'Authorization': 'JWT ${cookieList.first.value}', }, ), ); _receiveMessageList = _inputMessageModelList(message.data!); } /// 【プライベート】バックエンドから取得したメッセージデータのProvider化 MessageModel _inputMessageModel(dynamic message) { return MessageModel( id: message!['id'], sender: message!['sender'], receiver: message!['receiver'], message: message!['message'], createdAt: message!['created_at'], ); } /// 【プライベート】バックエンドから取得したメッセージデータ一覧のProvider化 List<MessageModel> _inputMessageModelList(dynamic messageList) { return messageList.map<MessageModel>( (message) => _inputMessageModel(message) ).toList(); }
特定のユーザーとのメッセージだけにフィルタリングするロジック
マッチング成立画面からメッセージ画面に移るときに呼び出されるAPIを実装したものが以下です
ProfileProvider.dart
/// 指定したユーザーとのメッセージの内容を取得する Future getMessageList() async { _isSuccess = false; messageList.clear(); try { // バックエンドから自身に関連するメッセージ一覧を取得する await _fetchSendMessageList(); await _fetchReceiveMessageList(); // 指定したユーザーとのメッセージだけをフィルタリングして新規メッセージ順に messageList に格納する messageList.addAll(_sendMessageList.where((message) => message.receiver == profileDetail!.user)); messageList.addAll(_receiveMessageList.where((message) => message.sender == profileDetail!.user)); messageList.sort((alpha, beta) => alpha.createdAt!.compareTo(beta.createdAt!)); // 処理成功 _isSuccess = true; } catch(error) { print(error); _isSuccess = false; } notifyListeners(); return _isSuccess; }
新規メッセージ作成
新たなメッセージを送る処理です
ProfileProvider.dart
/// 指定したユーザーに新規メッセージを送信する Future createMessage() async { _isSuccess = false; try { Dio dio = Dio(); List<Cookie> cookieList = await _prepareDio(dio); await dio.post( '/api/dm-message/', options: Options( headers: { 'Authorization': 'JWT ${cookieList.first.value}', }, ), data: { "receiver": profileDetail!.user, "message": newMessage, }, ); newMessage = ''; await getMessageList(); _isSuccess = true; } catch(error) { print(error); _isSuccess = false; } notifyListeners(); return _isSuccess; }
メッセージ画面の実装
まずはメッセージ画面を作成します
コードが長くなるので、MessageListWidgetで一部ウィジェット化しています
MessageScreen.dart
class MessageScreen extends StatelessWidget { const MessageScreen({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return Consumer<ProfileProvider>( builder: (context, profileProvider, _) { return Scaffold( appBar: AppBar( title: Text('${profileProvider.profileDetail!.nickname}とのメッセージ'), ), body: Column( children: [ Expanded(child: MessageListWidget(messages: profileProvider.messageList,),), Padding( padding: const EdgeInsets.only(left: 8.0, right: 8.0, bottom: 4.0), child: TextFormField( onChanged: (value) => profileProvider.newMessage = value, decoration: InputDecoration( labelText: 'メッセージを送りましょう', suffixIcon: IconButton( onPressed: () { profileProvider.createMessage().then((isSuccess) { if (isSuccess) { showSnackBar(context, 'メッセージが送信されました'); } else { showSnackBar(context, 'エラーが発生しました'); } }); }, icon: const Icon(Icons.send) ), ), maxLength: 500, ), ), ], ), drawer: const DrawerWidget(), bottomNavigationBar: const BottomNavBarWidget(), ); }, ); } }
Widget化したメッセージ一覧画面コンポーネントの実装
こちらもレイアウトは適当です
LINEのような言わゆるよく見るチャットのレイアウトを作成する実力はなかったのでひとまずこちらの実装となっています
実運用を目指すのであればブラッシュアップが必要です
widgets/MessageListWidget.dart
class MessageListWidget extends StatelessWidget { MessageListWidget({ Key? key, required List<MessageModel> messages, }) : _messages = messages, super(key: key); final List<MessageModel> _messages; // Flutter Web の場合 dotenv.get('BACKEND_URL_HOST_CASE_FLUTTER_WEB') final Uri _uriHost = Uri.parse(dotenv.get('BACKEND_URL_HOST')); @override Widget build(BuildContext context) { return Consumer<ProfileProvider>( builder: (context, profileProvider, _) { return ListView.builder( itemCount: _messages.length, itemBuilder: (BuildContext context, int index) { String imageUrl = profileProvider.myProfile.topImage ?? ''; String sender = profileProvider.myProfile.nickname; String createdAt = _messages[index].createdAt ?? ''; if(_messages[index].sender == profileProvider.profileDetail!.user) { imageUrl = profileProvider.profileDetail!.topImage ?? ''; sender = profileProvider.profileDetail!.nickname; } if(imageUrl != '') imageUrl.replaceFirst(dotenv.get('STORAGE_URL_HOST'), _uriHost.toString()); return ListTile( leading: imageUrl != '' ? Image.network(imageUrl, errorBuilder: (context, error, stackTrace) { return Image.asset('images/nophotos.png',); }, ) : Image.asset('images/nophotos.png',), title: Text('$index ${_messages[index].message}'), subtitle: Text('$sender $createdAt'), ); }, ); } ); } }
これでマッチングアプリに必要な機能をすべて実装することができました
お疲れ様です
動作確認を行って想定どおりに動くか確認しておきましょう