diff --git a/lib/Components/utils/debounce.dart b/lib/Components/utils/debounce.dart new file mode 100644 index 0000000..76bf36b --- /dev/null +++ b/lib/Components/utils/debounce.dart @@ -0,0 +1,17 @@ +import 'dart:async'; +import 'dart:ui'; + +class Debouce { + factory Debouce() => _instance; + + Debouce._(); + + static final Debouce _instance = Debouce._(); + + static Timer? _timer; + + 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/dtos/anime_dto.dart b/lib/data/dtos/anime_dto.dart index e83fe7c..59789b0 100644 --- a/lib/data/dtos/anime_dto.dart +++ b/lib/data/dtos/anime_dto.dart @@ -5,8 +5,9 @@ part 'anime_dto.g.dart'; @JsonSerializable(createToJson: false) class AnimesDto { final List? data; + final PaginationDto? pagination; - const AnimesDto({this.data}); + const AnimesDto({this.data, this.pagination}); factory AnimesDto.fromJson(Map json) => _$AnimesDtoFromJson(json); } @@ -44,4 +45,18 @@ class AnimeImagesJpgDto { const AnimeImagesJpgDto(this.image, this.smallImage, this.largeImage); factory AnimeImagesJpgDto.fromJson(Map json) => _$AnimeImagesJpgDtoFromJson(json); +} + + +@JsonSerializable(createToJson: false) +class PaginationDto { + @JsonKey(name: "last_visible_page") + final int? last; + @JsonKey(name: "current_page") + final int? current; + @JsonKey(name: "has_next_page") + final bool? next; + + const PaginationDto({this.current, this.last, this.next}); + factory PaginationDto.fromJson(Map json) => _$PaginationDtoFromJson(json); } \ No newline at end of file diff --git a/lib/data/dtos/anime_dto.g.dart b/lib/data/dtos/anime_dto.g.dart index 2b58860..0ce59dd 100644 --- a/lib/data/dtos/anime_dto.g.dart +++ b/lib/data/dtos/anime_dto.g.dart @@ -10,6 +10,9 @@ AnimesDto _$AnimesDtoFromJson(Map json) => AnimesDto( data: (json['data'] as List?) ?.map((e) => AnimeDataDto.fromJson(e as Map)) .toList(), + pagination: json['pagination'] == null + ? null + : PaginationDto.fromJson(json['pagination'] as Map), ); AnimeDataDto _$AnimeDataDtoFromJson(Map json) => AnimeDataDto( @@ -35,3 +38,10 @@ AnimeImagesJpgDto _$AnimeImagesJpgDtoFromJson(Map json) => json['small_image_url'] as String?, json['large_image_url'] as String?, ); + +PaginationDto _$PaginationDtoFromJson(Map json) => + PaginationDto( + current: (json['current_page'] as num?)?.toInt(), + last: (json['last_visible_page'] as num?)?.toInt(), + next: json['has_next_page'] as bool?, + ); diff --git a/lib/data/mappers/anime_mapper.dart b/lib/data/mappers/anime_mapper.dart index e217d2d..8c3be35 100644 --- a/lib/data/mappers/anime_mapper.dart +++ b/lib/data/mappers/anime_mapper.dart @@ -1,4 +1,5 @@ import 'package:first_project/data/dtos/anime_dto.dart'; +import 'package:first_project/domain/models/home.dart'; import 'package:first_project/presentation/home_page/home_page.dart'; extension AnimeDataDtoToModel on AnimeDataDto { @@ -8,4 +9,11 @@ extension AnimeDataDtoToModel on AnimeDataDto { score: score ?? 0, description: synopsis == null ? "NONE" : synopsis!.split('\n').sublist(0, synopsis!.split('\n').length - 1).join('\n'), ); +} + +extension AnimesDataDtoToModel on AnimesDto { + HomeData toDomain() => HomeData( + data: data?.map((e) => e.toDomain()).toList(), + nextPage: pagination!.next! ? pagination!.current! + 1 : 0, + ); } \ No newline at end of file diff --git a/lib/data/repositories/anime_repository.dart b/lib/data/repositories/anime_repository.dart index 1ddf840..9c49fb8 100644 --- a/lib/data/repositories/anime_repository.dart +++ b/lib/data/repositories/anime_repository.dart @@ -1,8 +1,8 @@ - import 'package:dio/dio.dart'; import 'package:first_project/data/dtos/anime_dto.dart'; import 'package:first_project/data/mappers/anime_mapper.dart'; import 'package:first_project/data/repositories/api_interface.dart'; +import 'package:first_project/domain/models/home.dart'; import 'package:first_project/presentation/home_page/home_page.dart'; import 'package:pretty_dio_logger/pretty_dio_logger.dart'; @@ -16,21 +16,22 @@ class AnimeRepository extends ApiInterface { static const String _baseUrl = 'https://api.jikan.moe'; @override - Future?> loadData({String? q}) async{ + Future loadData({OnErrorCallback? onError,String? q, int page = 1, int pageSize = 25}) async{ try { const String url = '$_baseUrl/v4/anime'; - Map query = {'limit' : 5, 'q' : q}; + Map query = {'q' : q, 'page' : page, 'limit' : pageSize}; final Response response = await _dio.get>( url, queryParameters: query, ); final AnimesDto dto = AnimesDto.fromJson(response.data as Map); - final List? data = dto.data?.map((e) => e.toDomain()).toList(); + final HomeData data = dto.toDomain(); return data; } on DioException catch (e) { + onError?.call(e.error?.toString()); return null; } } diff --git a/lib/data/repositories/api_interface.dart b/lib/data/repositories/api_interface.dart index af66c8b..1e52124 100644 --- a/lib/data/repositories/api_interface.dart +++ b/lib/data/repositories/api_interface.dart @@ -1,6 +1,9 @@ +import 'package:first_project/domain/models/home.dart'; import 'package:first_project/presentation/home_page/home_page.dart'; +typedef OnErrorCallback = void Function(String? error); + abstract class ApiInterface { - Future?> loadData(); + Future loadData({OnErrorCallback? onError}); } \ No newline at end of file diff --git a/lib/data/repositories/mock_repository.dart b/lib/data/repositories/mock_repository.dart index 03b9dce..8df15ad 100644 --- a/lib/data/repositories/mock_repository.dart +++ b/lib/data/repositories/mock_repository.dart @@ -1,11 +1,12 @@ import 'package:first_project/data/repositories/api_interface.dart'; +import 'package:first_project/domain/models/home.dart'; import 'package:first_project/presentation/home_page/home_page.dart'; import 'package:flutter/material.dart'; class MockRepository extends ApiInterface { @override - Future?> loadData() async { - return [ + Future loadData({OnErrorCallback? onError}) async { + return HomeData(data:[ const CardData( "First", score: 5, @@ -25,6 +26,6 @@ class MockRepository extends ApiInterface { icon: Icons.offline_bolt_outlined, description: "Wow >_<", ), - ]; + ]); } } \ No newline at end of file diff --git a/lib/domain/models/home.dart b/lib/domain/models/home.dart new file mode 100644 index 0000000..520097d --- /dev/null +++ b/lib/domain/models/home.dart @@ -0,0 +1,8 @@ +import 'package:first_project/presentation/home_page/home_page.dart'; + +class HomeData { + final List? data; + final int? nextPage; + + HomeData({this.data, this.nextPage}); +} diff --git a/lib/presentation/home_page/bloc/bloc.dart b/lib/presentation/home_page/bloc/bloc.dart index 3d7583b..8d7767c 100644 --- a/lib/presentation/home_page/bloc/bloc.dart +++ b/lib/presentation/home_page/bloc/bloc.dart @@ -1,5 +1,3 @@ - - import 'package:first_project/data/repositories/anime_repository.dart'; import 'package:first_project/presentation/home_page/bloc/events.dart'; import 'package:first_project/presentation/home_page/bloc/state.dart'; @@ -12,7 +10,30 @@ class HomeBloc extends Bloc { 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, + ); + + 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 index 08edf95..99c63fc 100644 --- a/lib/presentation/home_page/bloc/events.dart +++ b/lib/presentation/home_page/bloc/events.dart @@ -4,5 +4,8 @@ abstract class HomeEvent { } class HomeLoadDataEvent extends HomeEvent { - const HomeLoadDataEvent(); + 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 index d97baf3..0e89776 100644 --- a/lib/presentation/home_page/bloc/state.dart +++ b/lib/presentation/home_page/bloc/state.dart @@ -1,15 +1,42 @@ import 'package:equatable/equatable.dart'; import 'package:first_project/domain/models/card.dart'; +import 'package:first_project/domain/models/home.dart'; import 'package:first_project/presentation/home_page/home_page.dart'; +import 'package:copy_with_extension/copy_with_extension.dart'; +part 'state.g.dart'; + +@CopyWith() class HomeState extends Equatable { - final Future?>? data; + final HomeData? data; + final bool isLoading; + final bool isPaginationLoading; + final String? error; - const HomeState({this.data}); + const HomeState({ + this.data, + this.isLoading = false, + this.isPaginationLoading = false, + this.error, + }); - HomeState copyWith({Future?>? data}) => - HomeState(data: data ?? this.data); + HomeState copyWith( + {HomeData? data, + bool? isLoading, + bool? isPaginationLoading, + String? error}) => + HomeState( + data: data ?? this.data, + isLoading: isLoading ?? this.isLoading, + isPaginationLoading: isPaginationLoading ?? this.isPaginationLoading, + error: error ?? 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 fb6abee..2c8b411 100644 --- a/lib/presentation/home_page/home_page.dart +++ b/lib/presentation/home_page/home_page.dart @@ -1,8 +1,14 @@ import 'package:first_project/data/repositories/anime_repository.dart'; import 'package:first_project/data/repositories/mock_repository.dart'; import 'package:first_project/domain/models/card.dart'; +import 'package:first_project/presentation/home_page/bloc/bloc.dart'; +import 'package:first_project/presentation/home_page/bloc/events.dart'; +import 'package:first_project/presentation/home_page/bloc/state.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:first_project/Components/utils/debounce.dart'; part 'card.dart'; @@ -38,64 +44,103 @@ class _MyHomePageState extends State { backgroundColor: Colors.purple, title: Text(widget.title), ), - body: const Body(), + body: const _Body(), ); } } -class Body extends StatefulWidget { - const Body({super.key}); + +class _Body extends StatefulWidget { + const _Body({super.key}); @override - State createState() => _BodyState(); + State<_Body> createState() => _BodyState(); } -class _BodyState extends State { - final AnimeRepository repos = AnimeRepository(); +class _BodyState extends State<_Body> { final searchController = TextEditingController(); - late Future?> data; + final scrollController = ScrollController(); @override void initState() { - data = repos.loadData(); + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().add(const HomeLoadDataEvent()); + }); + + scrollController.addListener(_onNextPageListener); + super.initState(); } + void _onNextPageListener() { + if (scrollController.offset > scrollController.position.maxScrollExtent - 50) { + 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: FutureBuilder?>( - future: data, - builder: (context, snapshot) => SingleChildScrollView( - child: snapshot.hasData - ? Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Padding( - padding: const EdgeInsets.all(8), - child: CupertinoSearchTextField( - controller: searchController, - onChanged: (search) { - setState(() { - data = repos.loadData(q : search); - }); - }, - ), - ), - ...snapshot.data!.map( - (e) { - return _Card.fromData( - e, - onLike: (String title, bool isLiked) => - _showLiked(context, title, isLiked), - onTap: () => _navToDetails(context, e), - ); - }, - ).toList() - ], - ) - : const CircularProgressIndicator(), - ), - )); + return BlocBuilder( + builder: (context, state) => state.error != null + ? Text( + state.error ?? '', + style: Theme.of(context) + .textTheme + .headlineSmall + ?.copyWith(color: Colors.red), + ) + : state.isLoading + ? const CircularProgressIndicator() + : Column(children: [ + Padding( + padding: const EdgeInsets.all(8), + child: CupertinoSearchTextField( + controller: searchController, + onChanged: (search) { + Debouce.run(() => context + .read() + .add(HomeLoadDataEvent(search: search))); + }, + ), + ), + Expanded( + child: RefreshIndicator( + onRefresh: _onRefresh, + child: ListView.builder( + controller: scrollController, + padding: EdgeInsets.zero, + itemCount: state.data?.data?.length ?? 0, + itemBuilder: (context, index) { + final data = state.data?.data?[index]; + return data != null + ? _Card.fromData( + data, + onLike: (title, isLiked) => + _showLiked(context, title, isLiked), + onTap: () => _navToDetails(context, data), + ) + : const SizedBox.shrink(); + }), + ), + ), + BlocBuilder( + builder: (context, state) => state.isPaginationLoading + ? const CircularProgressIndicator() + : const SizedBox.shrink(), + ) + ])); } void _navToDetails(BuildContext context, CardData data) { @@ -117,4 +162,11 @@ class _BodyState extends State { )); }); } + + Future _onRefresh() { + context + .read() + .add(HomeLoadDataEvent(search: searchController.text)); + return Future.value(null); + } } diff --git a/pubspec.lock b/pubspec.lock index 1f60748..c3eed5e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -166,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: diff --git a/pubspec.yaml b/pubspec.yaml index db1c6b0..07ed1d3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,6 +42,7 @@ dependencies: #BLoC equatable: ^2.0.5 flutter_bloc: ^8.1.5 + copy_with_extension_gen: ^5.0.4 dev_dependencies: flutter_test: sdk: flutter