Flutter Riverpod+Flutter hooks+StataNotifier+freezedを使ってQiitaアプリを作ってみた
なんぞや
普段バックエンドを担当しているエンジニアが、Flutterを触らせてもらうことになったので、勉強がてら作成してみました。
最初にどうしようかなとなったのが状態管理。
状態管理にも、BLoCやらProviderやら色々あるっぽいですが、最近流行り?よく見かける構成の
Riverpod + Flutter hooks+ StateNotifier + freezedを使うことにしました。
作ったもの
QiitaAPIを使ったアプリ(https://qiita.com/api/v2/docs)。
記事一覧・記事詳細(WebView)・検索ページがあります。
あとは無限スクロールと下に引っ張って更新も実装しています。
記事一覧
検索(本文検索)
クローン後、rootディレクトリで
flutter pub run build_runner build --delete-conflicting-outputs
flutter run
これでアプリが起動します。
内容
パッケージ
導入したパッケージはこんな感じ。
dependencies:
flutter:
sdk: flutter
flutter_hooks: ^0.16.0
hooks_riverpod: ^0.13.0
state_notifier: ^0.7.0
dio: ^4.0.0-beta4
json_serializable: ^4.0.2
freezed_annotation: ^0.14.2
shared_preferences: ^2.0.3
intl: ^0.17.0
webview_flutter: ^2.0.8
cupertino_icons: ^1.0.2
dev_dependencies:
flutter_test:
sdk: flutter
build_runner: ^1.11.1
freezed: ^0.14.0
flutter pub get
でインストールします。
ディレクトリ構成
ディレクトリ構成とファイル名はこちら。
ディレクトリ構成
MVVM + Repositoryパターンで実装しています。
アーキテクチャ図
ViewModel
MVVMの中核となる部分。記事一覧・検索キーワードなどの状態を保持して、Viewに渡す役割を担います。
View
見た目の部分。ViewModelから値を受け取って表示します。
Repository
ViewModelでデータを取得するとき、その取得先がサーバーなのかローカルなのかを意識させないためのもの(?)
モデルの作成
記事モデル(QiitaArticle)と記事の投稿者(QiitaUser)モデルを作成します。
freezedを使って、immutable(不変)にしています。
こうすることで、どのコードがアクセスしても同じ内容であることを保証することができます。
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:qiita_library/models/qiita_user.dart';
part 'qiita_article.freezed.dart';
part 'qiita_article.g.dart';
abstract class QiitaArticle with _$QiitaArticle {
factory QiitaArticle({
String? title,
String? url,
QiitaUser? user,
List? tags,
(name: 'created_at') String? createdAt,
}) = _QiitaArticle;
factory QiitaArticle.fromJson(Map<String, dynamic> json) =>
_$QiitaArticleFromJson(json);
}
import 'package:freezed_annotation/freezed_annotation.dart';
part 'qiita_user.freezed.dart';
part 'qiita_user.g.dart';
abstract class QiitaUser with _$QiitaUser {
factory QiitaUser({
String? id,
(name: 'profile_image_url') String? profileImageUrl,
}) = _QiitaUser;
factory QiitaUser.fromJson(Map<String, dynamic> json) =>
_$QiitaUserFromJson(json);
}
コード生成
rootディレクトリで、
flutter pub run build_runner build --delete-conflicting-outputs
を実行し、freezedのコードを生成します。
--delete-conflictiong-outputs
オプションは、既に生成されたfreezedのコードとコンフリクトを起こさないために、既に生成されたコードを一度削除して生成し直すためのオプションです。
QiitaAPIの呼び出し
以下のコードでQiitaAPIを呼び出し、記事一覧を取得します。
呼び出しにはdioを使用しています。
import 'package:dio/dio.dart';
import 'package:qiita_library/models/qiita_article.dart';
class QiitaApiClient {
dynamic fetchArticles(int page, String keyword) async {
final response = await Dio().get(
'https://qiita.com/api/v2/items?per_page=20',
queryParameters: {
'page': page,
'per_page': 20,
if (keyword != '') 'query': 'body:$keyword or tag:$keyword',
},
options: Options(
headers: {
"Content-Type": "application/json",
"Authorization": " Bearer 9b71d2f82f8fa8577cdb22c6f2d556b0e590168b",
},
),
);
var articles = response.data
.map((dynamic i) => QiitaArticle.fromJson(i as Map<String, dynamic>))
.toList();
return articles;
}
}
そして、上記のメソッドをrepositoryから呼び出します。
後述するViewModelからrepositoryを呼び出す→APIを呼び出す、こういった流れになります。
import 'package:qiita_library/apis/qiita_api_client.dart';
class ArticleRepository {
final _api = QiitaApiClient();
dynamic fetchArticles(int page, String keyword) async {
return await _api.fetchArticles(page, keyword);
}
}
ViewModel
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:qiita_library/repositories/article_repository.dart';
import 'package:qiita_library/states/articles_state.dart';
final articleViewModel = StateNotifierProvider(
(_) => ArticleViewModel(
ArticleRepository(),
),
);
class ArticleViewModel extends StateNotifier<ArticlesState> {
ArticleViewModel(this.repository) : super(ArticlesState()) {
getArticles();
}
final ArticleRepository repository;
int _page = 1;
bool _isLoading = false;
Future<void> getArticles() async {
if (_isLoading || !state.hasNext) {
return;
}
_isLoading = true;
final articles = await repository.fetchArticles(_page, state.keyword);
final newArticles = [...state.articles, ...articles];
if (articles.length % 20 != 0 || articles.length == 0) {
state = state.copyWith(
hasNext: false,
);
}
state = state.copyWith(
articles: newArticles,
);
_page++;
_isLoading = false;
}
Future<void> setQuery(String keyword) async {
state = state.copyWith(
articles: [],
keyword: keyword,
hasNext: true,
);
_page = 1;
}
Future<void> refreshArticles() async {
state = state.copyWith(
articles: [],
hasNext: true,
);
_page = 1;
this.getArticles();
}
}
final articles = await repository.fetchArticles(_page, state.keyword);
final newArticles = [...state.articles, ...articles];
state = state.copyWith(
articles: newArticles,
);
repositoryを呼び出して記事を取得し、newArticlesに取得した記事を挿入。
state.copyWith
で状態を更新します。
状態の保持(State)
取得した記事一覧・無限スクロールで次の記事があるか・検索キーワードを保持しておくためのStateクラスを作成します。
こちらもfreezedを使用してimmutable(不変)にしています。
import 'package:freezed_annotation/freezed_annotation.dart';
part 'articles_state.freezed.dart';
part 'articles_state.g.dart';
abstract class ArticlesState with _$ArticlesState {
const factory ArticlesState({
([]) dynamic articles,
(true) bool hasNext,
('') String keyword,
}) = _ArticlesState;
factory ArticlesState.fromJson(Map<String, dynamic> json) =>
_$ArticlesStateFromJson(json);
}
記事一覧ページ
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:qiita_library/viewModels/article_view_model.dart';
import 'package:intl/intl.dart';
import 'package:qiita_library/views/article_datail_page.dart';
import 'package:qiita_library/views/article_search_setting_page.dart';
class ArticlesPage extends HookWidget {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
actions: <Widget>[
IconButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => ArticleSearchSettingPage(),
fullscreenDialog: true,
),
);
},
icon: Icon(Icons.search),
),
],
),
body: _Articles(),
);
}
}
class _Articles extends HookWidget {
Widget build(BuildContext context) {
final viewModel = useProvider(articleViewModel);
final state = useProvider(articleViewModel.state);
if (state.articles.length == 0) {
if (!state.hasNext) return Text('検索結果なし');
return const LinearProgressIndicator();
}
return RefreshIndicator(
child: ListView.builder(
itemCount: state.articles.length,
itemBuilder: (context, int index) {
if (index == (state.articles.length - 1) && state.hasNext) {
viewModel.getArticles();
return const LinearProgressIndicator();
}
return _articleItem(context, state.articles[index]);
},
),
onRefresh: () async {
viewModel.refreshArticles();
},
);
}
Widget _articleItem(context, article) {
return GestureDetector(
child: Container(
padding: EdgeInsets.all(15.0),
decoration: BoxDecoration(
border: const Border(
bottom: const BorderSide(
color: const Color(0x1e333333),
width: 1,
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_articleUser(article.user),
SizedBox(
height: 10.0,
),
Text(article.title),
SizedBox(
height: 10.0,
),
Wrap(
spacing: 7.5,
children: <Widget>[
for (int i = 0; i < article.tags.length; i++)
_articleTag(article.tags[i])
],
),
SizedBox(
height: 5.0,
),
_articleCreatedAt(article.createdAt),
],
),
),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => ArticleDetailPage(
article: article,
),
),
);
},
);
}
Widget _articleUser(user) {
final userId = user.id;
return Row(
children: [
CircleAvatar(
backgroundImage: NetworkImage(user.profileImageUrl),
radius: 12.0,
child: Text(''),
),
SizedBox(width: 8.0),
Text('@$userId'),
],
);
}
Widget _articleTag(tag) {
return GestureDetector(
child: Container(
child: Text(
tag['name'],
style: TextStyle(
decoration: TextDecoration.underline,
),
),
),
onTap: () {
print(tag['name']);
},
);
}
Widget _articleCreatedAt(createdAt) {
DateFormat format = DateFormat('yyyy-MM-dd');
String date = format.format(DateTime.parse(createdAt).toLocal());
return Container(
width: double.infinity,
child: Text(
'$dateに投稿',
textAlign: TextAlign.right,
),
);
}
}
final viewModel = useProvider(articleViewModel);
viewModel.getArticles();
上記で、articleViewModelのメソッドを呼び出すことができます。
final state = useProvider(articleViewModel.state);
state.articles;
state.keyword;
上記で、記事一覧やキーワードを取得することができます。
記事詳細ページ
webview_flutterを使って記事詳細を表示。
import 'package:flutter/material.dart';
import 'package:qiita_library/models/qiita_article.dart';
import 'package:webview_flutter/webview_flutter.dart';
class ArticleDetailPage extends StatelessWidget {
ArticleDetailPage({ this.article});
final QiitaArticle? article;
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
article!.title ?? '',
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
),
),
),
body: Column(
children: [
Expanded(
child: WebView(
initialUrl: article?.url,
javascriptMode: JavascriptMode.unrestricted,
),
),
],
),
);
}
}
検索ページ
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:qiita_library/viewModels/article_view_model.dart';
class ArticleSearchSettingPage extends HookWidget {
Widget build(BuildContext context) {
final viewModel = useProvider(articleViewModel);
final state = useProvider(articleViewModel.state);
return Scaffold(
appBar: AppBar(
title: Text('検索'),
),
body: Container(
padding: EdgeInsets.all(15.0),
child: Column(
children: <Widget>[
TextFormField(
controller: TextEditingController(text: state.keyword),
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: 'キーワード',
),
onFieldSubmitted: (value) async {
await viewModel.setQuery(value);
viewModel.getArticles();
Navigator.of(context).pop();
},
),
],
),
),
);
}
}
終わりに
今回は、Riverpod + Flutter hooks + StateNotifier + freezedを使って、簡単なQiitaアプリを作成しました。
API呼び出して表示、っていうことができるようになったので、QiitaAPIに限らず、他のAPIや自分でAPIを作って呼び出してアプリ作るのも面白そう。
まだまだ初心者なので、おいお前間違ってんぞそれ、っていう部分があったらご指摘ください!
参考にさせていただいた記事
GitHub
クローン後、rootディレクトリで
flutter pub run build_runner build --delete-conflicting-outputs
flutter run
Discussion