Flutter Riverpod+Fluter hooks+Statifier+freezed로 Qiita 앱을 만들었습니다.

69176 단어 Fluttertech

누구


평소 백그라운드를 담당하는 엔지니어가 플utter를 만지라고 해서 공부하는 김에 해봤어요.
처음에 어떻게 해야 좋을지 컨디션 관리야.
컨디션 관리 측면에서도 BloC, Provider 등 다양한 것이 있는데 요즘 유행인가요?흔히 볼 수 있는 구성
Riverpod+Fluter hooks+State Notifier+freezed를 사용하기로 결정했습니다.

만든 물건


QitaAPI 애플리케이션https://qiita.com/api/v2/docs을 사용합니다.
기사 일람·보도 상세(WebView)·검색 페이지가 있다.
또한 무한 스크롤과 아래로 업데이트를 실현했다.

기사 목록

찾기(본문 검색)
https://github.com/yumiba109/qiita_library
클론 후 루트 디렉토리에서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 (변하지 않음) 을 원합니다.
이렇게 하면 어떤 코드가 접근하든지 내용이 같다는 것을 보장할 수 있다.
https://pub.dev/packages/freezed
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.dart
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,
    @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를 사용했습니다.
https://pub.dev/packages/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를 사용하여 글의 상세한 정보를 표시합니다.
https://pub.dev/packages/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로 앱을 호출하는 것도 흥미롭다.
아직 초보자이기 때문에 잘못된 점이 있으면 지적해 주세요!

참고 자료


https://qiita.com/toda-axiaworks/items/fa2f77562bb2c0b7a158
https://zuma-lab.com/posts/flutter-todo-list-riverpod-use-provider-state-notifier-freezed
https://wasabeef.medium.com/flutter--mvm-로 구현 - 861c5dbcc565

GitHub


https://github.com/yumiba109/qiita_library
클론 후 루트 디렉토리에서flutter pub run build_runner build --delete-conflicting-outputs flutter run

좋은 웹페이지 즐겨찾기