Flutter Riverpod+Fluter hooks+Statifier+freezed로 Qiita 앱을 만들었습니다.
누구
평소 백그라운드를 담당하는 엔지니어가 플utter를 만지라고 해서 공부하는 김에 해봤어요.
처음에 어떻게 해야 좋을지 컨디션 관리야.
컨디션 관리 측면에서도 BloC, Provider 등 다양한 것이 있는데 요즘 유행인가요?흔히 볼 수 있는 구성
Riverpod+Fluter hooks+State Notifier+freezed를 사용하기로 결정했습니다.
만든 물건
QitaAPI 애플리케이션https://qiita.com/api/v2/docs을 사용합니다.
기사 일람·보도 상세(WebView)·검색 페이지가 있다.
또한 무한 스크롤과 아래로 업데이트를 실현했다.
기사 목록
찾기(본문 검색)
클론 후 루트 디렉토리에서
flutter pub run build_runner build --delete-conflicting-outputs
flutter run
이렇게 하면 응용 프로그램이 시작된다.컨텐트
큰 가방
도입된 포장은 이런 느낌입니다.
pubspec.yaml
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
에 설치합니다.디렉토리 구조
디렉터리 구성과 파일 이름은 여기에 있습니다.
디렉토리 구조
MVM+Repository 모드로 구현됩니다.
구성도
ViewModel
MVM의 핵심 부분.기사 일람, 검색 키워드 등의 상태를 유지하여 View에 전달한다.
View
외관 부분.ViewModel에서 값을 받아 표시합니다.
Repository
ViewModel을 통해 데이터를 가져올 때 목적지가 서버인지 로컬인지(?)
모델 생성하기
글 모형(QitaArticle)과 글의 투고자(QitaUser) 모형을 만듭니다.
freezed를 사용하십시오. immutable (변하지 않음) 을 원합니다.
이렇게 하면 어떤 코드가 접근하든지 내용이 같다는 것을 보장할 수 있다.
models/qiita_article.dart
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,
@JsonKey(name: 'created_at') String? createdAt,
}) = _QiitaArticle;
factory QiitaArticle.fromJson(Map<String, dynamic> json) =>
_$QiitaArticleFromJson(json);
}
models/qiita_user.dartimport '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,
@JsonKey(name: 'profile_image_url') String? profileImageUrl,
}) = _QiitaUser;
factory QiitaUser.fromJson(Map<String, dynamic> json) =>
_$QiitaUserFromJson(json);
}
코드 생성
루트 디렉토리
flutter pub run build_runner build --delete-conflicting-outputs
그리고freezed 코드를 생성합니다.--delete-conflictiong-outputs
옵션은 생성된 코드를 삭제하고 다시 생성한 옵션으로 생성된freezed 코드와 충돌하지 않도록 합니다.QiitaAPI 호출
다음 코드를 사용하여 QitaAPI를 호출하여 글 목록을 가져옵니다.
호출할 때 dio를 사용했습니다.
apis/qiita_api_client.dart
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를 호출하는 절차입니다.
repositories/article_repository.dart
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
viewModels/article_view_model.dart
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를 호출하여 글을 가져오고 가져온 글을 new Articles에 삽입합니다.state.copyWith
업데이트 상태.상태 유지(State)
획득한 글 일람표, 무한 스크롤에 다음 글이 있는지 여부, 검색 키워드를 저장하는 State 클래스를 만듭니다.
여기도 freezed를 사용하여 immutable (변하지 않음) 을 만듭니다.
states/articles_state.dart
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({
@Default([]) dynamic articles,
@Default(true) bool hasNext,
@Default('') String keyword,
}) = _ArticlesState;
factory ArticlesState.fromJson(Map<String, dynamic> json) =>
_$ArticlesStateFromJson(json);
}
기사 목록 페이지
article_page.dart
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();
위에서 article ViewModel 방법을 호출할 수 있습니다.final state = useProvider(articleViewModel.state);
state.articles;
state.keyword;
상기 내용에서 문장 목록과 키워드를 얻을 수 있다.기사 상세 정보 페이지
webview_flutter를 사용하여 글의 상세한 정보를 표시합니다.
views/article_detail_page.dart
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,
),
),
],
),
);
}
}
검색 페이지
views/article_search_setting_page.dart
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+Fluter hooks+StateNotifier+freezed를 사용해 간단한 Qiita 앱을 만들었다.
API 호출을 통해 표시할 수 있기 때문에 QitapAPI뿐만 아니라 다른 API와 자체 제작 API로 앱을 호출하는 것도 흥미롭다.
아직 초보자이기 때문에 잘못된 점이 있으면 지적해 주세요!
참고 자료
GitHub
클론 후 루트 디렉토리에서
flutter pub run build_runner build --delete-conflicting-outputs
flutter run
Reference
이 문제에 관하여(Flutter Riverpod+Fluter hooks+Statifier+freezed로 Qiita 앱을 만들었습니다.), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다 https://zenn.dev/yumiba/articles/10db684c90069d텍스트를 자유롭게 공유하거나 복사할 수 있습니다.하지만 이 문서의 URL은 참조 URL로 남겨 두십시오.
우수한 개발자 콘텐츠 발견에 전념 (Collection and Share based on the CC Protocol.)