diff --git a/lib/components/utils/debounce.dart b/lib/components/utils/debounce.dart new file mode 100644 index 0000000..bb5320c --- /dev/null +++ b/lib/components/utils/debounce.dart @@ -0,0 +1,19 @@ +import 'dart:async'; +import 'dart:ui'; + +class Debounce { + factory Debounce() => _instance; + + Debounce._(); + + static final Debounce _instance = Debounce._(); + static Timer? _timer; + + static void run( + VoidCallback action, { + Duration delay = const Duration(milliseconds: 700), + }) { + _timer?.cancel(); + _timer = Timer(delay, action); + } +} diff --git a/lib/data/dtos/spells_dto.dart b/lib/data/dtos/spells_dto.dart index 13ff1ff..225a2f3 100644 --- a/lib/data/dtos/spells_dto.dart +++ b/lib/data/dtos/spells_dto.dart @@ -5,23 +5,48 @@ part 'spells_dto.g.dart'; @JsonSerializable(createToJson: false) class SpellsDto { final List? data; + final MetaDto? meta; const SpellsDto({ this.data, + this.meta, }); factory SpellsDto.fromJson(Map json) => - _$SpellDtoFromJson(json); + _$SpellsDtoFromJson(json); +} + +@JsonSerializable(createToJson: false) +class MetaDto { + final PaginationDto? pagination; + + const MetaDto({this.pagination}); + + factory MetaDto.fromJson(Map json) => + _$MetaDtoFromJson(json); +} + +@JsonSerializable(createToJson: false) +class PaginationDto { + final int? current; + final int? next; + final int? last; + final int? records; + + const PaginationDto({this.current, this.next, this.last, this.records}); + + factory PaginationDto.fromJson(Map json) => + _$PaginationDtoFromJson(json); } @JsonSerializable(createToJson: false) class SpellDataDto { - final String? id; + final String id; final String? type; final SpellAttributesDataDto? attributes; - const SpellDataDto({ - this.id, + const SpellDataDto( + this.id, { this.type, this.attributes, }); diff --git a/lib/data/dtos/spells_dto.g.dart b/lib/data/dtos/spells_dto.g.dart index b29aaae..bd62ec5 100644 --- a/lib/data/dtos/spells_dto.g.dart +++ b/lib/data/dtos/spells_dto.g.dart @@ -6,14 +6,31 @@ part of 'spells_dto.dart'; // JsonSerializableGenerator // ************************************************************************** -SpellsDto _$SpellDtoFromJson(Map json) => SpellsDto( +SpellsDto _$SpellsDtoFromJson(Map json) => SpellsDto( data: (json['data'] as List?) ?.map((e) => SpellDataDto.fromJson(e as Map)) .toList(), + meta: json['meta'] == null + ? null + : MetaDto.fromJson(json['meta'] as Map), + ); + +MetaDto _$MetaDtoFromJson(Map json) => MetaDto( + pagination: json['pagination'] == null + ? null + : PaginationDto.fromJson(json['pagination'] as Map), + ); + +PaginationDto _$PaginationDtoFromJson(Map json) => + PaginationDto( + current: (json['current'] as num?)?.toInt(), + next: (json['next'] as num?)?.toInt(), + last: (json['last'] as num?)?.toInt(), + records: (json['records'] as num?)?.toInt(), ); SpellDataDto _$SpellDataDtoFromJson(Map json) => SpellDataDto( - id: json['id'] as String?, + json['id'] as String, type: json['type'] as String?, attributes: json['attributes'] == null ? null diff --git a/lib/data/mappers/spells_mapper.dart b/lib/data/mappers/spells_mapper.dart index 0945704..6aa8ade 100644 --- a/lib/data/mappers/spells_mapper.dart +++ b/lib/data/mappers/spells_mapper.dart @@ -1,11 +1,20 @@ import 'package:laba1/domain/models/card.dart'; +import '../../domain/models/home.dart'; import '../dtos/spells_dto.dart'; const _imagePlaceHolder ='https://cdn-icons-png.flaticon.com/512/1277/1277244.png'; +extension SpellsDtoToModel on SpellsDto { + HomeData toDomain() => HomeData( + data: data?.map((e) => e.toDomain()).toList(), + nextPage: meta?.pagination?.next, + ); +} + extension SpellsDataDtoToModel on SpellDataDto { CardData toDomain() => CardData( + id, attributes?.name ?? 'UNKNOWN', imageUrl: attributes?.image ?? _imagePlaceHolder, description: _makeDescription(attributes), diff --git a/lib/data/repositories/api_interface.dart b/lib/data/repositories/api_interface.dart index 92eb3a8..0db24a1 100644 --- a/lib/data/repositories/api_interface.dart +++ b/lib/data/repositories/api_interface.dart @@ -1,7 +1,8 @@ import '../../domain/models/card.dart'; +import '../../domain/models/home.dart'; typedef OnErrorCallback = void Function(String? error); abstract class ApiInterface { - Future?> loadData({OnErrorCallback? onError}); + Future loadData({OnErrorCallback? onError}); } diff --git a/lib/data/repositories/mock_repository.dart b/lib/data/repositories/mock_repository.dart index 8607703..d20a562 100644 --- a/lib/data/repositories/mock_repository.dart +++ b/lib/data/repositories/mock_repository.dart @@ -1,25 +1,28 @@ import '../../domain/models/card.dart'; +import '../../domain/models/home.dart'; import 'api_interface.dart'; class MockRepository extends ApiInterface { @override - Future?> loadData({OnErrorCallback? onError}) async { - return [ - CardData('orange', - imageUrl: - 'https://kuban24.tv/wp-content/uploads/2023/10/photo_2023-10-02_16-08-02.jpg'), - CardData("aboba", - imageUrl: - 'https://masterpiecer-images.s3.yandex.net/5fa453a2d4c51a7:upscaled'), - CardData("Hello world!!!", - imageUrl: - 'https://m.media-amazon.com/images/I/81YqUbAZ0GL._AC_UF1000,1000_QL80_.jpg'), - CardData('(=^・^=)', - imageUrl: - 'https://i.pinimg.com/236x/c8/cc/24/c8cc24bba37a25c009647b8875aae0e3.jpg'), - CardData('плохо быть старым ' + 'трезвым и больным, ' * 5, - imageUrl: - 'https://images.genius.com/c754c6f1755acee741881d55985a6c34.865x865x1.jpg'), - ]; + Future loadData({OnErrorCallback? onError}) async { + return HomeData( + data: [ + CardData('a', 'orange', + imageUrl: + 'https://kuban24.tv/wp-content/uploads/2023/10/photo_2023-10-02_16-08-02.jpg'), + CardData('b', "aboba", + imageUrl: + 'https://masterpiecer-images.s3.yandex.net/5fa453a2d4c51a7:upscaled'), + CardData('c', "Hello world!!!", + imageUrl: + 'https://m.media-amazon.com/images/I/81YqUbAZ0GL._AC_UF1000,1000_QL80_.jpg'), + CardData('d', '(=^・^=)', + imageUrl: + 'https://i.pinimg.com/236x/c8/cc/24/c8cc24bba37a25c009647b8875aae0e3.jpg'), + CardData('e', 'плохо быть старым ' + 'трезвым и больным, ' * 5, + imageUrl: + 'https://images.genius.com/c754c6f1755acee741881d55985a6c34.865x865x1.jpg'), + ], + ); } } diff --git a/lib/data/repositories/potter_repository.dart b/lib/data/repositories/potter_repository.dart index 37749ad..341cda0 100644 --- a/lib/data/repositories/potter_repository.dart +++ b/lib/data/repositories/potter_repository.dart @@ -3,6 +3,7 @@ import 'package:laba1/data/mappers/spells_mapper.dart'; import 'package:pretty_dio_logger/pretty_dio_logger.dart'; import '../../domain/models/card.dart'; +import '../../domain/models/home.dart'; import '../dtos/spells_dto.dart'; import 'api_interface.dart'; @@ -16,49 +17,55 @@ class PotterRepository extends ApiInterface { static const String _baseUrl = 'https://api.potterdb.com'; @override - Future?> loadData( - {String? q, OnErrorCallback? onError}) async { + Future loadData({ + OnErrorCallback? onError, + String? q, + int page = 1, + int pageSize = 25, + }) async { try { const String url = '$_baseUrl/v1/spells'; SpellsDto dto; if (q == null) { final Response response = - await _dio.get>(url); + await _dio.get>(url, queryParameters: { + 'filter[name_cont]': q, + 'page[number]': page, + 'page[size]': pageSize, + }); dto = SpellsDto.fromJson(response.data as Map); - } else { - final Response response1 = - await _dio.get>( - url, - queryParameters: q != null ? {'filter[name_cont]': q} : null, - ); - final Response response2 = - await _dio.get>( - url, - queryParameters: q != null ? {'filter[incantation_cont]': q} : null, - ); - SpellsDto dto1 = - SpellsDto.fromJson(response1.data as Map); - SpellsDto dto2 = - SpellsDto.fromJson(response2.data as Map); - - List combinedData = [ - ...?dto1.data, // добавляем данные из dto1 - ...?dto2.data // добавляем данные из dto2 - ]; -// Удаляем дубликаты по полю id - Map uniqueSpellsMap = { - for (var spell in combinedData) spell.id: spell - }; -// Преобразуем обратно в список - List uniqueSpells = uniqueSpellsMap.values.toList(); -// Создаем новый SpellsDto с уникальными данными - dto = SpellsDto(data: uniqueSpells); + return dto.toDomain(); } - final List? data = dto.data?.map((e) => e.toDomain()).toList(); - return data; + + final Response response1 = + await _dio.get>(url, queryParameters: { + 'filter[name_cont]': q, + 'page[number]': page, + 'page[size]': pageSize, + }); + SpellsDto dto1 = + SpellsDto.fromJson(response1.data as Map); + + final HomeData homeData1 = dto1.toDomain(); + if (homeData1.data != null && homeData1.data!.isNotEmpty) + return HomeData(data: homeData1.data, nextPage: page+1); + + page -= ((dto1.meta?.pagination?.records ?? 0) / pageSize).ceil(); + + final Response response2 = + await _dio.get>(url, queryParameters: { + 'filter[incantation_cont]': q, + 'page[number]': page, + 'page[size]': pageSize, + }); + SpellsDto dto2 = + SpellsDto.fromJson(response2.data as Map); + + return dto2.toDomain(); + } on DioException catch (e) { - onError?.call(e.response?.statusMessage); + onError?.call(e.error?.toString()); return null; } } diff --git a/lib/domain/models/card.dart b/lib/domain/models/card.dart index 8c61a87..567dad7 100644 --- a/lib/domain/models/card.dart +++ b/lib/domain/models/card.dart @@ -1,9 +1,11 @@ class CardData { + final String id; final String text; final String? description; final String? imageUrl; CardData( + this.id, this.text, { this.description, this.imageUrl, diff --git a/lib/domain/models/home.dart b/lib/domain/models/home.dart new file mode 100644 index 0000000..489eca5 --- /dev/null +++ b/lib/domain/models/home.dart @@ -0,0 +1,13 @@ +import 'card.dart'; + +class HomeData { + final List? data; + final Set existingIds = {}; + int? nextPage; + + HomeData({this.data, this.nextPage}) { + for (var entity in data ?? []) { + existingIds.add(entity.id); + } + } +} diff --git a/lib/presentation/home_page/bloc/bloc.dart b/lib/presentation/home_page/bloc/bloc.dart index 6e6960a..0360341 100644 --- a/lib/presentation/home_page/bloc/bloc.dart +++ b/lib/presentation/home_page/bloc/bloc.dart @@ -1,5 +1,4 @@ import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:laba1/data/repositories/mock_repository.dart'; import 'package:laba1/presentation/home_page/bloc/state.dart'; import '../../../data/repositories/potter_repository.dart'; @@ -7,10 +6,51 @@ import 'events.dart'; class HomeBloc extends Bloc { final PotterRepository repo; + HomeBloc(this.repo) : super(const HomeState()) { on(_onLoadData); } - void _onLoadData(HomeLoadDataEvent event, Emitter emit) { - emit(state.copyWith(data: repo.loadData())); + + 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, + ); + data?.nextPage = data?.nextPage ?? (event.nextPage ?? 1); + if (event.nextPage != null) { + // Добавляем новые данные, пропуская дубликаты + if (state.data != null) { + for (var entity in data?.data ?? []) { + if (!(state.data!.existingIds.contains(entity.id))) { + state.data?.data?.add(entity); + state.data?.existingIds.add(entity.id); + } + } + } + state.data?.nextPage = data?.nextPage; + emit(state.copyWith( + isLoading: false, + isPaginationLoading: false, + data: state.data, + error: error, + )); + } else { + emit(state.copyWith( + isLoading: false, + isPaginationLoading: false, + data: data, + 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 index c565592..cefba70 100644 --- a/lib/presentation/home_page/bloc/events.dart +++ b/lib/presentation/home_page/bloc/events.dart @@ -1,6 +1,10 @@ abstract class HomeEvent { const HomeEvent(); } + class HomeLoadDataEvent extends HomeEvent { - const HomeLoadDataEvent(); -} \ No newline at end of file + final String? search; + final int? nextPage; + + const HomeLoadDataEvent({this.search, this.nextPage}); +} diff --git a/lib/presentation/home_page/bloc/state.dart b/lib/presentation/home_page/bloc/state.dart index ff3a706..efb3133 100644 --- a/lib/presentation/home_page/bloc/state.dart +++ b/lib/presentation/home_page/bloc/state.dart @@ -1,11 +1,30 @@ +import 'package:copy_with_extension/copy_with_extension.dart'; import 'package:equatable/equatable.dart'; import '../../../domain/models/card.dart'; +import '../../../domain/models/home.dart'; +part 'state.g.dart'; + +@CopyWith() class HomeState extends Equatable { - final Future?>? data; - const HomeState({this.data}); - HomeState copyWith({Future?>? data}) => HomeState(data: data ?? this.data); + final HomeData? 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]; -} \ No newline at end of file + 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..114ac25 --- /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(HomeData? 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({ + HomeData? 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(HomeData? 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 HomeData?, + 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 e6dca77..4d7b097 100644 --- a/lib/presentation/home_page/home_page.dart +++ b/lib/presentation/home_page/home_page.dart @@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:laba1/data/repositories/potter_repository.dart'; import 'package:laba1/presentation/details_page/details_page.dart'; +import '../../components/utils/debounce.dart'; import '../../data/repositories/mock_repository.dart'; import '../../domain/models/card.dart'; import '../dialogs/show_dialog.dart'; @@ -44,88 +45,110 @@ class _Body extends StatefulWidget { class _BodyState extends State<_Body> { final searchController = TextEditingController(); + final scrollController = ScrollController(); @override void initState() { WidgetsBinding.instance.addPostFrameCallback((_) { context.read().add(const HomeLoadDataEvent()); }); + scrollController.addListener(_onNextPageListener); super.initState(); } + void _onNextPageListener() { + if (scrollController.offset >= scrollController.position.maxScrollExtent) { + // preventing multiple pagination request on multiple swipes + 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) { - return Center( - child: Stack( + return Padding( + padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top), + child: Column( children: [ - Positioned.fill( - child: BlocBuilder ( - builder: (context, state) => - FutureBuilder?>( - future: state.data, - builder: (context, snapshot) { - var cards = Column( - children: [], - ); - cards.children.add(Padding( - padding: const EdgeInsets.only(left: 8.0, right: 8), - child: CupertinoSearchTextField( - borderRadius: BorderRadius.only( - bottomLeft: Radius.circular(20), - bottomRight: Radius.circular(20), - ), - backgroundColor: Colors.amberAccent, - ), - )); - cards.children.addAll( - snapshot.data - ?.map((e) => - _Card.fromData( - e, - onLike: (String title, bool isLiked) { - _showSnackBar(context, title, isLiked); - }, - onTap: () => _navToDetails(context, e), - )) - .toList() ?? - [], - ); - return snapshot.hasData - ? SingleChildScrollView( - child: cards, - ) - : Center(child: CircularProgressIndicator()); - }, - ), - ),), - Align( - alignment: Alignment.topCenter, - child: Padding( - padding: const EdgeInsets.only(left: 8.0, right: 8), + Padding( + padding: const EdgeInsets.all(12), child: CupertinoSearchTextField( - borderRadius: BorderRadius.only( - bottomLeft: Radius.circular(20), - bottomRight: Radius.circular(20), - ), - backgroundColor: Colors.amberAccent, controller: searchController, onChanged: (search) { - //TODO + Debounce.run(() => context + .read() + .add(HomeLoadDataEvent(search: search))); }, ), ), - ), + 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, + padding: EdgeInsets.zero, + itemCount: (state.data?.data?.length ?? 0) + 1, + itemBuilder: (context, index) { + if (index == 0) { + return Padding( + padding: const EdgeInsets.all(12), + child: CupertinoSearchTextField(), + ); + } + final data = state.data?.data?[index-1]; + return data != null + ? _Card.fromData( + data, + onLike: (title, isLiked) => _showSnackBar( + context, title, isLiked), + onTap: () => _navToDetails(context, data), + ) + : const SizedBox.expand(); + }, + ), + ), + ), + ), + 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 _navToDetails(BuildContext context, CardData data) { Navigator.push( context, @@ -138,10 +161,7 @@ class _BodyState extends State<_Body> { ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text( '$title ${isLiked ? 'liked' : 'unliked'}', - style: Theme - .of(context) - .textTheme - .bodyLarge, + style: Theme.of(context).textTheme.bodyLarge, ), backgroundColor: Colors.orangeAccent, duration: const Duration(seconds: 1),