Причесал код

This commit is contained in:
Вячеслав Иванов 2024-09-25 12:55:28 +04:00
parent 16f0f53745
commit decf3d296d
29 changed files with 178 additions and 151 deletions

View File

@ -2,8 +2,8 @@
"@@locale": "en", "@@locale": "en",
"search": "Search", "search": "Search",
"liked": "liked!", "liked": "liked",
"disliked": "disliked :(", "disliked": "disliked",
"arbEnding": "Чтобы не забыть про отсутствие запятой :)" "arbEnding": "Чтобы не забыть про отсутствие запятой :)"
} }

View File

@ -2,8 +2,8 @@
"@@locale": "ru", "@@locale": "ru",
"search": "Поиск", "search": "Поиск",
"liked": "понравился!", "liked": "поставлен лайк",
"disliked": "разонравился :(", "disliked": "убран лайк",
"arbEnding": "Чтобы не забыть про отсутствие запятой :)" "arbEnding": "Чтобы не забыть про отсутствие запятой :)"
} }

View File

@ -3,4 +3,4 @@ import '../locale/l10n/app_locale.dart';
extension LocalContextX on BuildContext { extension LocalContextX on BuildContext {
AppLocale get locale => AppLocale.of(this)!; AppLocale get locale => AppLocale.of(this)!;
} }

View File

@ -104,13 +104,13 @@ abstract class AppLocale {
/// No description provided for @liked. /// No description provided for @liked.
/// ///
/// In ru, this message translates to: /// In ru, this message translates to:
/// **'понравился!'** /// **'поставлен лайк'**
String get liked; String get liked;
/// No description provided for @disliked. /// No description provided for @disliked.
/// ///
/// In ru, this message translates to: /// In ru, this message translates to:
/// **'разонравился :('** /// **'убран лайк'**
String get disliked; String get disliked;
/// No description provided for @arbEnding. /// No description provided for @arbEnding.

View File

@ -10,10 +10,10 @@ class AppLocaleEn extends AppLocale {
String get search => 'Search'; String get search => 'Search';
@override @override
String get liked => 'liked!'; String get liked => 'liked';
@override @override
String get disliked => 'disliked :('; String get disliked => 'disliked';
@override @override
String get arbEnding => 'Чтобы не забыть про отсутствие запятой :)'; String get arbEnding => 'Чтобы не забыть про отсутствие запятой :)';

View File

@ -10,10 +10,10 @@ class AppLocaleRu extends AppLocale {
String get search => 'Поиск'; String get search => 'Поиск';
@override @override
String get liked => 'понравился!'; String get liked => 'поставлен лайк';
@override @override
String get disliked => 'разонравился :('; String get disliked => 'убран лайк';
@override @override
String get arbEnding => 'Чтобы не забыть про отсутствие запятой :)'; String get arbEnding => 'Чтобы не забыть про отсутствие запятой :)';

View File

@ -7,4 +7,4 @@ class R {
static const String ASSETS_SVG_RU_SVG = 'assets/svg/ru.svg'; static const String ASSETS_SVG_RU_SVG = 'assets/svg/ru.svg';
static const String ASSETS_SVG_UK_SVG = 'assets/svg/uk.svg'; static const String ASSETS_SVG_UK_SVG = 'assets/svg/uk.svg';
} }

View File

@ -11,10 +11,10 @@ class Debounce {
static Timer? _timer; static Timer? _timer;
static void run( static void run(
VoidCallback action, { VoidCallback action, {
Duration delay = const Duration(milliseconds: 500), Duration delay = const Duration(milliseconds: 500),
}) { }) {
_timer?.cancel(); _timer?.cancel();
_timer = Timer(delay, action); _timer = Timer(delay, action);
} }
} }

View File

@ -12,7 +12,8 @@ class CharactersDto {
this.meta, this.meta,
}); });
factory CharactersDto.fromJson(Map<String, dynamic> json) => _$CharactersDtoFromJson(json); factory CharactersDto.fromJson(Map<String, dynamic> json) =>
_$CharactersDtoFromJson(json);
} }
@JsonSerializable(createToJson: false) @JsonSerializable(createToJson: false)
@ -23,7 +24,8 @@ class CharacterDataDto {
const CharacterDataDto({this.id, this.type, this.attributes}); const CharacterDataDto({this.id, this.type, this.attributes});
factory CharacterDataDto.fromJson(Map<String, dynamic> json) => _$CharacterDataDtoFromJson(json); factory CharacterDataDto.fromJson(Map<String, dynamic> json) =>
_$CharacterDataDtoFromJson(json);
} }
@JsonSerializable(createToJson: false) @JsonSerializable(createToJson: false)
@ -33,7 +35,8 @@ class CharacterAttributesDataDto {
final String? species; final String? species;
final String? image; final String? image;
const CharacterAttributesDataDto({this.name, this.nationality, this.species, this.image}); const CharacterAttributesDataDto(
{this.name, this.nationality, this.species, this.image});
factory CharacterAttributesDataDto.fromJson(Map<String, dynamic> json) => factory CharacterAttributesDataDto.fromJson(Map<String, dynamic> json) =>
_$CharacterAttributesDataDtoFromJson(json); _$CharacterAttributesDataDtoFromJson(json);
@ -45,7 +48,8 @@ class MetaDto {
const MetaDto({this.pagination}); const MetaDto({this.pagination});
factory MetaDto.fromJson(Map<String, dynamic> json) => _$MetaDtoFromJson(json); factory MetaDto.fromJson(Map<String, dynamic> json) =>
_$MetaDtoFromJson(json);
} }
@JsonSerializable(createToJson: false) @JsonSerializable(createToJson: false)
@ -56,5 +60,6 @@ class PaginationDto {
const PaginationDto({this.current, this.next, this.last}); const PaginationDto({this.current, this.next, this.last});
factory PaginationDto.fromJson(Map<String, dynamic> json) => _$PaginationDtoFromJson(json); factory PaginationDto.fromJson(Map<String, dynamic> json) =>
} _$PaginationDtoFromJson(json);
}

View File

@ -4,27 +4,28 @@ import '../dtos/characters_dto.dart';
extension CharactersDtoToModel on CharactersDto { extension CharactersDtoToModel on CharactersDto {
HomeData toDomain() => HomeData( HomeData toDomain() => HomeData(
data: data?.map((e) => e.toDomain()).toList(), data: data?.map((e) => e.toDomain()).toList(),
nextPage: meta?.pagination?.next, nextPage: meta?.pagination?.next,
); );
} }
extension CharacterDataDtoToModel on CharacterDataDto { extension CharacterDataDtoToModel on CharacterDataDto {
CardData toDomain() => CardData( CardData toDomain() => CardData(
attributes?.name ?? 'UNKNOWN', attributes?.name ?? 'UNKNOWN',
image: attributes?.image ?? image: attributes?.image ??
'https://upload.wikimedia.org/wikipedia/en/archive/b/b1/20210811082420%21Portrait_placeholder.png', 'https://upload.wikimedia.org/wikipedia/en/archive/b/b1/20210811082420%21Portrait_placeholder.png',
descriptionText: _makeDescriptionText(attributes?.nationality, attributes?.species), descriptionText:
id: id, _makeDescriptionText(attributes?.nationality, attributes?.species),
); id: id,
);
String _makeDescriptionText(String? nationality, String? species) { String _makeDescriptionText(String? nationality, String? species) {
return nationality != null && species != null return nationality != null && species != null
? 'Nationality - $nationality \nSpecies - $species' ? 'Nationality - $nationality \nSpecies - $species'
: nationality != null : nationality != null
? 'Nationality - $nationality' ? 'Nationality - $nationality'
: species != null : species != null
? 'Species - $species' ? 'Species - $species'
: ''; : '';
} }
} }

View File

@ -4,4 +4,4 @@ typedef OnErrorCallback = void Function(String? error);
abstract class ApiInterface { abstract class ApiInterface {
Future<HomeData?> loadData({OnErrorCallback? onError}); Future<HomeData?> loadData({OnErrorCallback? onError});
} }

View File

@ -12,22 +12,23 @@ class MockRepository extends ApiInterface {
'Freeze', 'Freeze',
descriptionText: 'so cold..', descriptionText: 'so cold..',
image: image:
'https://www.skedaddlewildlife.com/wp-content/uploads/2018/09/depositphotos_22425309-stock-photo-a-lonely-raccoon-in-winter.jpg', 'https://www.skedaddlewildlife.com/wp-content/uploads/2018/09/depositphotos_22425309-stock-photo-a-lonely-raccoon-in-winter.jpg',
), ),
CardData( CardData(
'Hi', 'Hi',
descriptionText: 'pretty face', descriptionText: 'pretty face',
icon: Icons.hail, icon: Icons.hail,
image: image:
'https://www.thesprucepets.com/thmb/nKNaS4I586B_H7sEUw9QAXvWM_0=/2121x0/filters:no_upscale():strip_icc()/GettyImages-135630198-5ba7d225c9e77c0050cff91b.jpg', 'https://www.thesprucepets.com/thmb/nKNaS4I586B_H7sEUw9QAXvWM_0=/2121x0/filters:no_upscale():strip_icc()/GettyImages-135630198-5ba7d225c9e77c0050cff91b.jpg',
), ),
CardData( CardData(
'Orange', 'Orange',
descriptionText: 'I like autumn', descriptionText: 'I like autumn',
icon: Icons.warning_amber, icon: Icons.warning_amber,
image: 'https://furmanagers.com/wp-content/uploads/2019/11/dreamstime_l_22075357.jpg', image:
'https://furmanagers.com/wp-content/uploads/2019/11/dreamstime_l_22075357.jpg',
), ),
], ],
); );
} }
} }

View File

@ -33,7 +33,8 @@ class PotterRepository extends ApiInterface {
}, },
); );
final CharactersDto dto = CharactersDto.fromJson(response.data as Map<String, dynamic>); final CharactersDto dto =
CharactersDto.fromJson(response.data as Map<String, dynamic>);
final HomeData data = dto.toDomain(); final HomeData data = dto.toDomain();
return data; return data;
} on DioException catch (e) { } on DioException catch (e) {
@ -41,4 +42,4 @@ class PotterRepository extends ApiInterface {
return null; return null;
} }
} }
} }

View File

@ -8,10 +8,10 @@ class CardData {
final String? id; final String? id;
CardData( CardData(
this.text, { this.text, {
required this.descriptionText, required this.descriptionText,
this.icon = Icons.ac_unit_outlined, this.icon = Icons.ac_unit_outlined,
this.image, this.image,
this.id, this.id,
}); });
} }

View File

@ -5,4 +5,4 @@ class HomeData {
final int? nextPage; final int? nextPage;
HomeData({this.data, this.nextPage}); HomeData({this.data, this.nextPage});
} }

View File

@ -215,7 +215,8 @@ class MyApp extends StatelessWidget {
create: (context) => LikeBloc(), create: (context) => LikeBloc(),
child: BlocProvider<HomeBloc>( child: BlocProvider<HomeBloc>(
lazy: false, lazy: false,
create: (context) => HomeBloc(context.read<PotterRepository>()), create: (context) =>
HomeBloc(context.read<PotterRepository>()),
child: const HomePage(), child: const HomePage(),
), ),
), ),
@ -225,4 +226,4 @@ class MyApp extends StatelessWidget {
), ),
); );
} }
} }

View File

@ -10,7 +10,8 @@ abstract class SvgObjects {
]; ];
for (final String p in pics) { for (final String p in pics) {
final loader = SvgAssetLoader(p); final loader = SvgAssetLoader(p);
svg.cache.putIfAbsent(loader.cacheKey(null), () => loader.loadBytes(null)); svg.cache
.putIfAbsent(loader.cacheKey(null), () => loader.loadBytes(null));
} }
} }
} }
@ -31,4 +32,4 @@ class SvgUk extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SvgPicture.asset(R.ASSETS_SVG_UK_SVG); return SvgPicture.asset(R.ASSETS_SVG_UK_SVG);
} }
} }

View File

@ -23,14 +23,14 @@ class DetailsPage extends StatelessWidget {
const SizedBox(height: 20), const SizedBox(height: 20),
Text(data.text, Text(data.text,
style: style:
const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
const SizedBox(height: 10), const SizedBox(height: 10),
Text(data.descriptionText, Text(data.descriptionText,
style: const TextStyle(fontSize: 22, color: Colors.black87)), style: const TextStyle(fontSize: 22, color: Colors.black87)),
// Add more details here as needed // Add more details here as needed
], ],
), ),
), ),
); );
} }
} }

View File

@ -10,7 +10,8 @@ class HomeBloc extends Bloc<HomeEvent, HomeState> {
on<HomeLoadDataEvent>(_onLoadData); on<HomeLoadDataEvent>(_onLoadData);
} }
Future<void> _onLoadData(HomeLoadDataEvent event, Emitter<HomeState> emit) async { Future<void> _onLoadData(
HomeLoadDataEvent event, Emitter<HomeState> emit) async {
if (event.nextPage == null) { if (event.nextPage == null) {
emit(state.copyWith(isLoading: true)); emit(state.copyWith(isLoading: true));
} else { } else {
@ -36,4 +37,4 @@ class HomeBloc extends Bloc<HomeEvent, HomeState> {
error: error, error: error,
)); ));
} }
} }

View File

@ -7,4 +7,4 @@ class HomeLoadDataEvent extends HomeEvent {
final int? nextPage; final int? nextPage;
const HomeLoadDataEvent({this.search, this.nextPage}); const HomeLoadDataEvent({this.search, this.nextPage});
} }

View File

@ -20,9 +20,9 @@ class HomeState extends Equatable {
@override @override
List<Object?> get props => [ List<Object?> get props => [
data, data,
isLoading, isLoading,
isPaginationLoading, isPaginationLoading,
error, error,
]; ];
} }

View File

@ -13,22 +13,22 @@ class _Card extends StatelessWidget {
final bool isLiked; final bool isLiked;
const _Card( const _Card(
this.text, { this.text, {
this.icon = Icons.ac_unit_outlined, this.icon = Icons.ac_unit_outlined,
required this.descriptionText, required this.descriptionText,
this.image, this.image,
this.onLike, this.onLike,
this.onTap, this.onTap,
this.id, this.id,
this.isLiked = false, this.isLiked = false,
}); });
factory _Card.fromData( factory _Card.fromData(
CardData data, { CardData data, {
OnLikeCallback onLike, OnLikeCallback onLike,
VoidCallback? onTap, VoidCallback? onTap,
bool isLiked = false, bool isLiked = false,
}) => }) =>
_Card( _Card(
data.text, data.text,
descriptionText: data.descriptionText, descriptionText: data.descriptionText,
@ -48,7 +48,7 @@ class _Card extends StatelessWidget {
margin: const EdgeInsets.all(16), margin: const EdgeInsets.all(16),
constraints: const BoxConstraints(minHeight: 240), constraints: const BoxConstraints(minHeight: 240),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white30, color: Colors.grey,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
@ -61,6 +61,29 @@ class _Card extends StatelessWidget {
), ),
child: Column( child: Column(
children: [ children: [
Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.only(
left: 8, right: 16, bottom: 16, top: 10),
child: GestureDetector(
onTap: () => onLike?.call(id, text, isLiked),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: isLiked
? const Icon(
Icons.favorite,
color: Colors.redAccent,
key: ValueKey<int>(0),
)
: const Icon(
Icons.favorite_border,
key: ValueKey<int>(1),
),
),
),
),
),
ClipRRect( ClipRRect(
borderRadius: const BorderRadius.only( borderRadius: const BorderRadius.only(
topLeft: Radius.circular(20), topLeft: Radius.circular(20),
@ -83,7 +106,8 @@ class _Card extends StatelessWidget {
), ),
), ),
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), padding:
const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
@ -100,31 +124,9 @@ class _Card extends StatelessWidget {
], ],
), ),
), ),
Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.only(left: 8, right: 16, bottom: 16),
child: GestureDetector(
onTap: () => onLike?.call(id, text, isLiked),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: isLiked
? const Icon(
Icons.favorite,
color: Colors.redAccent,
key: ValueKey<int>(0),
)
: const Icon(
Icons.favorite_border,
key: ValueKey<int>(1),
),
),
),
),
),
], ],
), ),
), ),
); );
} }
} }

View File

@ -59,7 +59,7 @@ class _BodyState extends State<_Body> {
void _onNextPageListener() { void _onNextPageListener() {
if (scrollController.offset > scrollController.position.maxScrollExtent) { if (scrollController.offset > scrollController.position.maxScrollExtent) {
// preventing multiple pagination request on multiple swipes // preventing multiple pagination request on multiple swipes
final bloc = context.read<HomeBloc>(); final bloc = context.read<HomeBloc>();
if (!bloc.state.isPaginationLoading) { if (!bloc.state.isPaginationLoading) {
bloc.add(HomeLoadDataEvent( bloc.add(HomeLoadDataEvent(
@ -93,14 +93,16 @@ class _BodyState extends State<_Body> {
controller: searchController, controller: searchController,
placeholder: context.locale.search, placeholder: context.locale.search,
onChanged: (search) { onChanged: (search) {
Debounce.run( Debounce.run(() => context
() => context.read<HomeBloc>().add(HomeLoadDataEvent(search: search))); .read<HomeBloc>()
.add(HomeLoadDataEvent(search: search)));
}, },
), ),
), ),
), ),
GestureDetector( GestureDetector(
onTap: () => context.read<LocaleBloc>().add(const ChangeLocaleEvent()), onTap: () =>
context.read<LocaleBloc>().add(const ChangeLocaleEvent()),
child: SizedBox.square( child: SizedBox.square(
dimension: 50, dimension: 50,
child: Padding( child: Padding(
@ -120,36 +122,42 @@ class _BodyState extends State<_Body> {
BlocBuilder<HomeBloc, HomeState>( BlocBuilder<HomeBloc, HomeState>(
builder: (context, state) => state.error != null builder: (context, state) => state.error != null
? Text( ? Text(
state.error ?? '', state.error ?? '',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(color: Colors.red), style: Theme.of(context)
) .textTheme
.headlineSmall
?.copyWith(color: Colors.red),
)
: state.isLoading : state.isLoading
? const CircularProgressIndicator() ? const CircularProgressIndicator()
: BlocBuilder<LikeBloc, LikeState>( : BlocBuilder<LikeBloc, LikeState>(
builder: (context, likeState) { builder: (context, likeState) {
return Expanded( return Expanded(
child: RefreshIndicator( child: RefreshIndicator(
onRefresh: _onRefresh, onRefresh: _onRefresh,
child: ListView.builder( child: ListView.builder(
controller: scrollController, controller: scrollController,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
itemCount: state.data?.data?.length ?? 0, itemCount: state.data?.data?.length ?? 0,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final data = state.data?.data?[index]; final data = state.data?.data?[index];
return data != null return data != null
? _Card.fromData( ? _Card.fromData(
data, data,
onLike: _onLike, onLike: _onLike,
isLiked: likeState.likedIds?.contains(data.id) == true, isLiked: likeState.likedIds
onTap: () => _navToDetails(context, data), ?.contains(data.id) ==
) true,
: const SizedBox.shrink(); onTap: () =>
}, _navToDetails(context, data),
), )
), : const SizedBox.shrink();
); },
}, ),
), ),
);
},
),
), ),
BlocBuilder<HomeBloc, HomeState>( BlocBuilder<HomeBloc, HomeState>(
builder: (context, state) => state.isPaginationLoading builder: (context, state) => state.isPaginationLoading
@ -162,7 +170,9 @@ class _BodyState extends State<_Body> {
} }
Future<void> _onRefresh() { Future<void> _onRefresh() {
context.read<HomeBloc>().add(HomeLoadDataEvent(search: searchController.text)); context
.read<HomeBloc>()
.add(HomeLoadDataEvent(search: searchController.text));
return Future.value(null); return Future.value(null);
} }
@ -192,4 +202,4 @@ class _BodyState extends State<_Body> {
)); ));
}); });
} }
} }

View File

@ -11,14 +11,16 @@ class LikeBloc extends Bloc<LikeEvent, LikeState> {
on<LoadLikesEvent>(_onLoadLikes); on<LoadLikesEvent>(_onLoadLikes);
} }
Future<void> _onLoadLikes(LoadLikesEvent event, Emitter<LikeState> emit) async { Future<void> _onLoadLikes(
LoadLikesEvent event, Emitter<LikeState> emit) async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final data = prefs.getStringList(_likedPrefsKey); final data = prefs.getStringList(_likedPrefsKey);
emit(state.copyWith(likedIds: data)); emit(state.copyWith(likedIds: data));
} }
Future<void> _onChangeLike(ChangeLikeEvent event, Emitter<LikeState> emit) async { Future<void> _onChangeLike(
ChangeLikeEvent event, Emitter<LikeState> emit) async {
final updatedList = List<String>.from(state.likedIds ?? []); final updatedList = List<String>.from(state.likedIds ?? []);
if (updatedList.contains(event.id)) { if (updatedList.contains(event.id)) {
@ -32,4 +34,4 @@ class LikeBloc extends Bloc<LikeEvent, LikeState> {
emit(state.copyWith(likedIds: updatedList)); emit(state.copyWith(likedIds: updatedList));
} }
} }

View File

@ -10,4 +10,4 @@ class ChangeLikeEvent extends LikeEvent {
final String id; final String id;
const ChangeLikeEvent(this.id); const ChangeLikeEvent(this.id);
} }

View File

@ -11,4 +11,4 @@ class LikeState extends Equatable {
@override @override
List<Object?> get props => [likedIds]; List<Object?> get props => [likedIds];
} }

View File

@ -5,13 +5,15 @@ import 'package:flutter_app/presentation/locale_bloc/locale_state.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
class LocaleBloc extends Bloc<LocaleEvent, LocaleState> { class LocaleBloc extends Bloc<LocaleEvent, LocaleState> {
LocaleBloc(Locale defaultLocale) : super(LocaleState(currentLocale: defaultLocale)) { LocaleBloc(Locale defaultLocale)
: super(LocaleState(currentLocale: defaultLocale)) {
on<ChangeLocaleEvent>(_onChangeLocale); on<ChangeLocaleEvent>(_onChangeLocale);
} }
Future<void> _onChangeLocale(ChangeLocaleEvent event, Emitter<LocaleState> emit) async { Future<void> _onChangeLocale(
ChangeLocaleEvent event, Emitter<LocaleState> emit) async {
final toChange = AppLocale.supportedLocales final toChange = AppLocale.supportedLocales
.firstWhere((e) => e.languageCode != state.currentLocale.languageCode); .firstWhere((e) => e.languageCode != state.currentLocale.languageCode);
emit(state.copyWith(currentLocale: toChange)); emit(state.copyWith(currentLocale: toChange));
} }
} }

View File

@ -4,4 +4,4 @@ abstract class LocaleEvent {
class ChangeLocaleEvent extends LocaleEvent { class ChangeLocaleEvent extends LocaleEvent {
const ChangeLocaleEvent(); const ChangeLocaleEvent();
} }

View File

@ -12,4 +12,4 @@ class LocaleState extends Equatable {
@override @override
List<Object?> get props => [currentLocale]; List<Object?> get props => [currentLocale];
} }