diff --git a/lib/components/utils/debounce.dart b/lib/components/utils/debounce.dart new file mode 100644 index 0000000..24e35af --- /dev/null +++ b/lib/components/utils/debounce.dart @@ -0,0 +1,21 @@ +import 'dart:async'; +import 'dart:ui'; + +class Debounce { + factory Debounce() => _instance; + + Debounce._(); + + static final Debounce _instance = Debounce._(); + + static Timer? _timer; + // если в течении 0.5 сек появится новый вызов (изменится текст в поле ввода) + // то текущий вызов заменяется новым + static void run ( + VoidCallback action, { + Duration delay = const Duration(milliseconds: 500), + }) { + _timer?.cancel(); + _timer = Timer(delay, action); + } +} \ No newline at end of file diff --git a/lib/data/dto/games_dto.dart b/lib/data/dto/games_dto.dart index ba013eb..aa2e4a1 100644 --- a/lib/data/dto/games_dto.dart +++ b/lib/data/dto/games_dto.dart @@ -8,14 +8,18 @@ class GamesDto { @JsonKey(name: 'results') final List? data; - const GamesDto({this.data}); + final int? current; + final int? next; + // final int? previous; + + const GamesDto({this.data, this.current, this.next}); factory GamesDto.fromJson(Map json) => _$GamesDtoFromJson(json); } @JsonSerializable(createToJson: false) class GameDto { - //для rawg, для получения описания игры + //для получения описания игры @JsonKey(name: 'id') final int? id; @@ -27,28 +31,10 @@ class GameDto { @JsonKey(name: 'background_image') final String? image; - // @JsonKey(name: 'original_release_date') @JsonKey(name: 'released') final String? date; const GameDto(this.id, this.name, this.description, this.image, this.date); factory GameDto.fromJson(Map json) => _$GameDtoFromJson(json); -} - -@JsonSerializable(createToJson: false) -class GameInfoDto extends GameDto{ - GameInfoDto(super.id, super.name, super.description, super.image, super.date); - -} - -// giant bomb -// @JsonSerializable(createToJson: false) -// class ImageDto { -// @JsonKey(name: 'original_url') -// final String? originalUrl; -// -// const ImageDto({this.originalUrl}); -// -// factory ImageDto.fromJson(Map json) => _$ImageDtoFromJson(json); -// } \ No newline at end of file +} \ No newline at end of file diff --git a/lib/data/dto/games_dto.g.dart b/lib/data/dto/games_dto.g.dart index 683e0f8..1d3efd3 100644 --- a/lib/data/dto/games_dto.g.dart +++ b/lib/data/dto/games_dto.g.dart @@ -7,10 +7,25 @@ part of 'games_dto.dart'; // ************************************************************************** GamesDto _$GamesDtoFromJson(Map json) => GamesDto( - data: (json['results'] as List?) - ?.map((e) => GameDto.fromJson(e as Map)) - .toList(), - ); + data: (json['results'] as List?) + ?.map((e) => GameDto.fromJson(e as Map)) + .toList(), + current: getNextPageNumber(json['next']) != null ? getNextPageNumber(json['next'])! - 1 : null, + next: getNextPageNumber(json['next']), +); + +int? getNextPageNumber(String url) { + RegExp regExp = RegExp(r'page=(\d+)'); + Match? match = regExp.firstMatch(url); + String? number; + if (match != null) { + return int.parse(match.group(1)!); + } + //по умолчанию след. = 2, т.е. тек. = 1 + else { + return null; + } +} GameDto _$GameDtoFromJson(Map json) => GameDto( (json['id'] as num?)?.toInt(), diff --git a/lib/data/mappers/games_mapper.dart b/lib/data/mappers/games_mapper.dart index 6b65adc..ea1292c 100644 --- a/lib/data/mappers/games_mapper.dart +++ b/lib/data/mappers/games_mapper.dart @@ -1,18 +1,27 @@ import 'package:mobiles_labs_5th_semester/data/dto/games_dto.dart'; import 'package:mobiles_labs_5th_semester/domain/models/game.dart'; -import 'package:html/parser.dart'; +import 'package:mobiles_labs_5th_semester/domain/models/page_of_games_home.dart'; + +extension GamesDtoToModel on GamesDto { + PageOfGames toDomain() => PageOfGames( + data: data?.map((e) => e.toDomain()).toList(), + nextPage: next); +} extension GameDtoToModel on GameDto { GameData toDomain() { - // удаление HTML-тегов и системных символов из описания + // удаление HTML-тегов из описания String cleanedDescription = description ?? ''; if (cleanedDescription.isNotEmpty) { - - cleanedDescription = cleanedDescription.replaceAll(RegExp(r'<[^>]*>'), ''); - //cleanedDescription = cleanedDescription.replaceAll(RegExp(r'\n|\r|\t'), ' '); + cleanedDescription = + cleanedDescription.replaceAll(RegExp(r'<[^>]*>'), ''); } // return GameData(name: name ?? 'Неизвестная игра', price: 100, image: image?.originalUrl, description: cleanedDescription); - return GameData(id:id, name: name ?? 'Неизвестная игра', date: DateTime.parse(date ?? '2000-01-01'), image: image, description: cleanedDescription); - + return GameData( + id: id, + name: name ?? 'Неизвестная игра', + date: DateTime.parse(date ?? '2000-01-01'), + image: image, + description: cleanedDescription); } -} \ No newline at end of file +} diff --git a/lib/data/repositories/api_interface.dart b/lib/data/repositories/api_interface.dart index c40045b..67303b5 100644 --- a/lib/data/repositories/api_interface.dart +++ b/lib/data/repositories/api_interface.dart @@ -1,5 +1,8 @@ import 'package:mobiles_labs_5th_semester/domain/models/game.dart'; +import 'package:mobiles_labs_5th_semester/domain/models/page_of_games_home.dart'; + +typedef OnErrorCallback = void Function(String? error); abstract class ApiInterface { - Future?> loadData(String? q); + Future loadData({OnErrorCallback? onError}); } \ No newline at end of file diff --git a/lib/data/repositories/games_repository.dart b/lib/data/repositories/games_repository.dart index 6d0c5b3..d8c7120 100644 --- a/lib/data/repositories/games_repository.dart +++ b/lib/data/repositories/games_repository.dart @@ -5,6 +5,8 @@ import 'package:dio/dio.dart'; import 'package:mobiles_labs_5th_semester/domain/models/game.dart'; import 'package:pretty_dio_logger/pretty_dio_logger.dart'; +import '../../domain/models/page_of_games_home.dart'; + class GamesRepository extends ApiInterface { // Для обращения к api, с удобным выводом в консоль @@ -14,28 +16,30 @@ class GamesRepository extends ApiInterface { requestBody: true, )); - // static const String _baseUrl = 'https://www.giantbomb.com'; static const String _baseUrl = 'https://api.rawg.io'; @override - Future?> loadData(String? q) async { + Future loadData({OnErrorCallback? onError, String? q, int page = 1, int pageSize = 10}) async { try { - // const String url = '$_baseUrl/api/games/?api_key=cfbca5bd0888309438b9cef29bbe13364bece292&format=json&limit=30&filter=date_added:2020-01-01|2099-12-31,original_release_date:2020-01-01|2099-12-31,platforms:94'; - String url = '$_baseUrl/api/games?key=793f3ef5bdb64d128bebbb6e68ab89bd&format=json&page_size=10&platforms=4'; + String url = '$_baseUrl/api/games?key=793f3ef5bdb64d128bebbb6e68ab89bd&format=json&platforms=4'; + url += '&page=$page'; + url += '&page_size=$pageSize'; + if (q != null) { url += '&search=$q'; } + final Response response = await _dio.get>(url); final GamesDto gamesDto = GamesDto.fromJson(response.data as Map); - final List? data = gamesDto.data?.map((e) => e.toDomain()).toList(); + final PageOfGames? data = gamesDto.toDomain(); return data; } on DioException catch (e) { - + onError?.call(e.error?.toString()); return null; } } @@ -52,7 +56,6 @@ class GamesRepository extends ApiInterface { return data; } on DioException catch (e) { - return null; } } diff --git a/lib/domain/models/page_of_games_home.dart b/lib/domain/models/page_of_games_home.dart new file mode 100644 index 0000000..adcdb9b --- /dev/null +++ b/lib/domain/models/page_of_games_home.dart @@ -0,0 +1,8 @@ +import 'package:mobiles_labs_5th_semester/domain/models/game.dart'; + +class PageOfGames { + final List? data; + final int? nextPage; + //final int? prevPage; + PageOfGames({this.data, this.nextPage}); +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 556a4f8..8a069e5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:mobiles_labs_5th_semester/data/repositories/games_repository.dart'; +import 'package:mobiles_labs_5th_semester/presentation/home_page/bloc/bloc.dart'; import 'presentation/home_page/home_page.dart'; @@ -14,7 +17,18 @@ class MyApp extends StatelessWidget { return MaterialApp( title: 'Flutter Demo', debugShowCheckedModeBanner: false, - home: const MyHomePage(title: 'Чернышев Георгий Янович'), + home: RepositoryProvider( + //репозиторий будет создан только тогда, когда будет запрошен, а не сразу при создании виджета + lazy: true, + create: (_) => GamesRepository(), + child: BlocProvider( + //bloc будет создан сразу при создании виджета + lazy: false, + //context нужен, чтобы суметь обратиться к провайдеру, кот. выше по дереву + create: (context) => HomeBloc(context.read()), + child: const HomePage(title: 'Чернышев Георгий Янович'), + ), + ) ); } } diff --git a/lib/presentation/home_page/bloc/bloc.dart b/lib/presentation/home_page/bloc/bloc.dart new file mode 100644 index 0000000..1c0acd5 --- /dev/null +++ b/lib/presentation/home_page/bloc/bloc.dart @@ -0,0 +1,40 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:mobiles_labs_5th_semester/data/repositories/games_repository.dart'; +import 'events.dart'; +import 'state.dart'; + +class HomeBloc extends Bloc { + final GamesRepository repo; + + //связывание метода с конкретным событием в конструкторе + HomeBloc(this.repo) : super (const HomeState()) { + on(_onLoadData); + } + + //Emitter - генератор событий + Future _onLoadData(HomeLoadDataEvent event, Emitter emit) async{ + if (event.nextPage == null) { + emit(state.copyWith(isLoading: true)); + } else { + emit(state.copyWith(isPaginationLoading: true)); + } + + String? error; + + final data = await repo.loadData( + q: event.search, + page: event.nextPage ?? 1, + onError: (e) => error = e + ); + if (event.nextPage != null) { + data?.data?.insertAll(0, state.data?.data ?? []); + } + + emit(state.copyWith( + data: data, + isLoading: false, + isPaginationLoading: false, + error: error + )); + } +} \ No newline at end of file diff --git a/lib/presentation/home_page/bloc/events.dart b/lib/presentation/home_page/bloc/events.dart new file mode 100644 index 0000000..cb3baee --- /dev/null +++ b/lib/presentation/home_page/bloc/events.dart @@ -0,0 +1,10 @@ +abstract class HomeEvent { + const HomeEvent(); +} + +class HomeLoadDataEvent extends HomeEvent { + final String? search; + final int? nextPage; + + const HomeLoadDataEvent({this. search, this.nextPage}); +} \ No newline at end of file diff --git a/lib/presentation/home_page/bloc/state.dart b/lib/presentation/home_page/bloc/state.dart new file mode 100644 index 0000000..c361cff --- /dev/null +++ b/lib/presentation/home_page/bloc/state.dart @@ -0,0 +1,24 @@ +import 'package:equatable/equatable.dart'; +import 'package:mobiles_labs_5th_semester/domain/models/game.dart'; +import 'package:mobiles_labs_5th_semester/domain/models/page_of_games_home.dart'; +import 'package:copy_with_extension/copy_with_extension.dart'; + +part 'state.g.dart'; + +@CopyWith() +class HomeState extends Equatable { + final PageOfGames? data; + final bool isLoading; + final bool isPaginationLoading; + final String? error; + + const HomeState( + {this.data, + this.isLoading = false, + this.isPaginationLoading = false, + this.error}); + + //какие поля будут сравниваться у состояний + @override + List get props => [data, isLoading, isPaginationLoading, error]; +} diff --git a/lib/presentation/home_page/bloc/state.g.dart b/lib/presentation/home_page/bloc/state.g.dart new file mode 100644 index 0000000..44e0a97 --- /dev/null +++ b/lib/presentation/home_page/bloc/state.g.dart @@ -0,0 +1,92 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'state.dart'; + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class _$HomeStateCWProxy { + HomeState data(PageOfGames? data); + + HomeState isLoading(bool isLoading); + + HomeState isPaginationLoading(bool isPaginationLoading); + + HomeState error(String? error); + + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `HomeState(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. + /// + /// Usage + /// ```dart + /// HomeState(...).copyWith(id: 12, name: "My name") + /// ```` + HomeState call({ + PageOfGames? data, + bool? isLoading, + bool? isPaginationLoading, + String? error, + }); +} + +/// Proxy class for `copyWith` functionality. This is a callable class and can be used as follows: `instanceOfHomeState.copyWith(...)`. Additionally contains functions for specific fields e.g. `instanceOfHomeState.copyWith.fieldName(...)` +class _$HomeStateCWProxyImpl implements _$HomeStateCWProxy { + const _$HomeStateCWProxyImpl(this._value); + + final HomeState _value; + + @override + HomeState data(PageOfGames? data) => this(data: data); + + @override + HomeState isLoading(bool isLoading) => this(isLoading: isLoading); + + @override + HomeState isPaginationLoading(bool isPaginationLoading) => + this(isPaginationLoading: isPaginationLoading); + + @override + HomeState error(String? error) => this(error: error); + + @override + + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `HomeState(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. + /// + /// Usage + /// ```dart + /// HomeState(...).copyWith(id: 12, name: "My name") + /// ```` + HomeState call({ + Object? data = const $CopyWithPlaceholder(), + Object? isLoading = const $CopyWithPlaceholder(), + Object? isPaginationLoading = const $CopyWithPlaceholder(), + Object? error = const $CopyWithPlaceholder(), + }) { + return HomeState( + data: data == const $CopyWithPlaceholder() + ? _value.data + // ignore: cast_nullable_to_non_nullable + : data as PageOfGames?, + isLoading: isLoading == const $CopyWithPlaceholder() || isLoading == null + ? _value.isLoading + // ignore: cast_nullable_to_non_nullable + : isLoading as bool, + isPaginationLoading: + isPaginationLoading == const $CopyWithPlaceholder() || + isPaginationLoading == null + ? _value.isPaginationLoading + // ignore: cast_nullable_to_non_nullable + : isPaginationLoading as bool, + error: error == const $CopyWithPlaceholder() + ? _value.error + // ignore: cast_nullable_to_non_nullable + : error as String?, + ); + } +} + +extension $HomeStateCopyWith on HomeState { + /// Returns a callable class that can be used as follows: `instanceOfHomeState.copyWith(...)` or like so:`instanceOfHomeState.copyWith.fieldName(...)`. + // ignore: library_private_types_in_public_api + _$HomeStateCWProxy get copyWith => _$HomeStateCWProxyImpl(this); +} diff --git a/lib/presentation/home_page/home_page.dart b/lib/presentation/home_page/home_page.dart index 1104ef3..a881494 100644 --- a/lib/presentation/home_page/home_page.dart +++ b/lib/presentation/home_page/home_page.dart @@ -1,23 +1,30 @@ import 'package:flutter/material.dart'; import 'package:flutter/cupertino.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:mobiles_labs_5th_semester/data/repositories/mock_repository.dart'; import 'package:mobiles_labs_5th_semester/domain/models/game.dart'; +import 'package:mobiles_labs_5th_semester/main.dart'; import 'package:mobiles_labs_5th_semester/presentation/details_page/details_page.dart'; +import 'package:mobiles_labs_5th_semester/presentation/home_page/bloc/bloc.dart'; +import 'package:mobiles_labs_5th_semester/presentation/home_page/bloc/events.dart'; +import 'package:mobiles_labs_5th_semester/presentation/home_page/bloc/state.dart'; +import '../../components/utils/debounce.dart'; import '../../data/repositories/games_repository.dart'; +import 'bloc/state.dart'; part 'gameCard.dart'; -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); +class HomePage extends StatefulWidget { + const HomePage({super.key, required this.title}); final String title; @override - State createState() => _MyHomePageState(); + State createState() => _HomePageState(); } -class _MyHomePageState extends State { +class _HomePageState extends State { @override void initState() { super.initState(); @@ -50,14 +57,39 @@ class Body extends StatefulWidget { class _BodyState extends State { late Future?> data; final searchController = TextEditingController(); - final repo = GamesRepository(); + final scrollController = ScrollController(); + + // final repo = GamesRepository(); @override void initState() { - data = repo.loadData(null); + //добавление кастомного события + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().add(const HomeLoadDataEvent()); + }); + + scrollController.addListener(_onNextPageListener); super.initState(); } + void _onNextPageListener() { + if (scrollController.offset >= scrollController.position.maxScrollExtent) { + final bloc = context.read(); + if (!bloc.state.isPaginationLoading) { + bloc.add(HomeLoadDataEvent( + search: searchController.text, + nextPage: bloc.state.data?.nextPage, + )); + } + } + } + + @override + void dispose() { + searchController.dispose(); + scrollController.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { @@ -71,37 +103,61 @@ class _BodyState extends State { // borderRadius: const BorderRadiusTween(2.0), controller: searchController, onChanged: (search) { - setState(() { - data = repo.loadData(search); - }); + Debounce.run(() => context + .read() + .add(HomeLoadDataEvent(search: search))); }, ), ), - Expanded( - child: Center( - child: FutureBuilder?>( - future: data, - builder: (context, snapshot) => SingleChildScrollView( - child: snapshot.hasData - ? Column( - children: snapshot.data?.map((data) { - return _GameCard.fromData( - data, - onLike: (String title, bool isLiked) => - _showSnackBar(context, title, isLiked), - onTap: () => _navToDetails(context, data), - ); - }).toList() ?? - []) - : const CircularProgressIndicator() - ), - ), - ), + BlocBuilder( + builder: (context, state) => state.error != null + ? Text( + state.error ?? '', + style: Theme.of(context) + .textTheme + .headlineSmall + ?.copyWith(color: Colors.red), + ) + : state.isLoading + ? const CircularProgressIndicator() + : Expanded( + child: RefreshIndicator( + onRefresh: _onRefresh, + child: ListView.builder( + controller: scrollController, + itemCount: state.data?.data?.length ?? 0, + itemBuilder: (context, index) { + final data = state.data?.data?[index]; + return data != null + ? _GameCard.fromData( + data, + onLike: (title, isLiked) => + _showSnackBar(context, title, isLiked), + onTap: () => _navToDetails(context, data), + ) + : const SizedBox.shrink(); + }, + ), + ), + ), ), + //значок загрузки при пагинации + BlocBuilder( + builder: (context, state) => state.isPaginationLoading + ? const CircularProgressIndicator() + : const SizedBox.shrink(), + ) ], ); } + Future _onRefresh() { + context + .read() + .add(HomeLoadDataEvent(search: searchController.text)); + return Future.value(null); + } + void _showSnackBar(BuildContext context, String title, bool isLiked) { WidgetsBinding.instance.addPostFrameCallback((_) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( diff --git a/pubspec.lock b/pubspec.lock index 02dd804..1f9ec2e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -38,6 +38,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.11.0" + bloc: + dependency: transitive + description: + name: bloc + sha256: "106842ad6569f0b60297619e9e0b1885c2fb9bf84812935490e6c5275777804e" + url: "https://pub.dev" + source: hosted + version: "8.1.4" boolean_selector: dependency: transitive description: @@ -158,6 +166,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + copy_with_extension: + dependency: transitive + description: + name: copy_with_extension + sha256: fbcf890b0c34aedf0894f91a11a579994b61b4e04080204656b582708b5b1125 + url: "https://pub.dev" + source: hosted + version: "5.0.4" + copy_with_extension_gen: + dependency: "direct main" + description: + name: copy_with_extension_gen + sha256: "51cd11094096d40824c8da629ca7f16f3b7cea5fc44132b679617483d43346b0" + url: "https://pub.dev" + source: hosted + version: "5.0.4" crypto: dependency: transitive description: @@ -206,6 +230,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + equatable: + dependency: "direct main" + description: + name: equatable + sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2 + url: "https://pub.dev" + source: hosted + version: "2.0.5" fake_async: dependency: transitive description: @@ -235,6 +267,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_bloc: + dependency: "direct main" + description: + name: flutter_bloc + sha256: b594505eac31a0518bdcb4b5b79573b8d9117b193cc80cc12e17d639b10aa27a + url: "https://pub.dev" + source: hosted + version: "8.1.6" flutter_lints: dependency: "direct dev" description: @@ -408,6 +448,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" package_config: dependency: transitive description: @@ -440,6 +488,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + provider: + dependency: transitive + description: + name: provider + sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c + url: "https://pub.dev" + source: hosted + version: "6.1.2" pub_semver: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 45a3b08..70e12d2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,6 +17,9 @@ dependencies: pretty_dio_logger: ^1.3.1 # чтобы получить нормальное описание без html тегов html: ^0.15.0 + flutter_bloc: ^8.1.6 + equatable: ^2.0.5 + copy_with_extension_gen: ^5.0.4 dev_dependencies: flutter_test: