This commit is contained in:
allllen4a 2024-11-14 16:44:44 +03:00
parent 5db40e6364
commit f420330f68
12 changed files with 278 additions and 178 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

View File

@ -31,21 +31,53 @@ class MoviePaginationDto {
@JsonSerializable(createToJson: false)
class MovieDataDto {
@JsonKey(name: 'id')
final int? id; // Это поле может использоваться для идентификации фильма
final String? name; // Название фильма
final String? description; // Описание фильма
final PosterDto? poster; // Постер фильма
final int? id;
final String? name;
final String? description;
final PosterDto? poster;
@JsonKey(name: 'year', defaultValue: 0)
final int year;
final List<GenreDto>? genres;
final List<CountryDto>? countries;
const MovieDataDto(this.name, this.description, this.poster, {this.id});
const MovieDataDto(
this.name,
this.description,
this.poster, {
this.id,
this.year = 0,
this.genres,
this.countries,
});
factory MovieDataDto.fromJson(Map<String, dynamic> json) =>
_$MovieDataDtoFromJson(json);
}
@JsonSerializable(createToJson: false)
class GenreDto {
final String? name;
const GenreDto({this.name});
factory GenreDto.fromJson(Map<String, dynamic> json) =>
_$GenreDtoFromJson(json);
}
@JsonSerializable(createToJson: false)
class CountryDto {
final String? name;
const CountryDto({this.name});
factory CountryDto.fromJson(Map<String, dynamic> json) =>
_$CountryDtoFromJson(json);
}
@JsonSerializable(createToJson: false)
class PosterDto {
final String? url; // URL постера
final String? previewUrl; // URL миниатюры постера
final String? url;
final String? previewUrl;
const PosterDto({this.url, this.previewUrl});

View File

@ -30,6 +30,21 @@ MovieDataDto _$MovieDataDtoFromJson(Map<String, dynamic> json) => MovieDataDto(
? null
: PosterDto.fromJson(json['poster'] as Map<String, dynamic>),
id: (json['id'] as num?)?.toInt(),
year: (json['year'] as num?)?.toInt() ?? 0,
genres: (json['genres'] as List<dynamic>?)
?.map((e) => GenreDto.fromJson(e as Map<String, dynamic>))
.toList(),
countries: (json['countries'] as List<dynamic>?)
?.map((e) => CountryDto.fromJson(e as Map<String, dynamic>))
.toList(),
);
GenreDto _$GenreDtoFromJson(Map<String, dynamic> json) => GenreDto(
name: json['name'] as String?,
);
CountryDto _$CountryDtoFromJson(Map<String, dynamic> json) => CountryDto(
name: json['name'] as String?,
);
PosterDto _$PosterDtoFromJson(Map<String, dynamic> json) => PosterDto(

View File

@ -2,18 +2,24 @@ import 'package:pmd_labs/data/dtos/movies_dto.dart';
import 'package:pmd_labs/domain/models/carddata.dart';
import 'package:pmd_labs/presentation/home_page/home_page.dart';
const _imagePlaceholder =
'https://upload.wikimedia.org/wikipedia/en/archive/b/b1/20210811082420%21Portrait_placeholder.png';
extension MovieDataDtoMapper on MovieDataDto {
CardData toDomain() => CardData(
name ?? 'UNKNOWN', // Исправлено с title на name
imageUrl: poster?.url, // Обратите внимание, что используем правильно поле
id: id?.toString() ?? '0', // Защита от null, если id нет
descriptionText: description ?? 'Нет описания', // Используем реальное описание
name ?? 'UNKNOWN',
imageUrl: poster?.url ?? _imagePlaceholder,
id: id?.toString() ?? '0',
descriptionText: description ?? 'Нет описания',
year: year,
genres: genres?.map((genre) => genre.name ?? 'UNKNOWN').toList() ?? [],
countries: countries?.map((country) => country.name ?? 'UNKNOWN').toList() ?? [],
);
}
extension MoviesDtoToModel on MoviesDto {
HomeData toDomain() => HomeData(
data: docs?.map((e) => e.toDomain()).toList(), // Изменено с data на docs
data: docs?.map((e) => e.toDomain()).toList(),
nextPage: (pagination?.hasNextPage ?? false)
? ((pagination?.currentPage ?? 0) + 1)
: null

View File

@ -1,18 +0,0 @@
import 'package:pmd_labs/components/utils/error_callback.dart';
import 'package:pmd_labs/data/repositories/api_interface.dart';
import 'package:pmd_labs/domain/models/carddata.dart';
import 'package:pmd_labs/presentation/home_page/home_page.dart';
class MockRepository extends ApiInterface {
@override
Future<HomeData?> loadData({OnErrorCallback? onError}) async {
return HomeData(
data: [
CardData('JoJos Bizarre Adventure', descriptionText: 'kono dio da', imageUrl: 'https://i1.sndcdn.com/avatars-253MmMf9QZzxVBJi-rvlyeg-t1080x1080.jpg'),
CardData('Example', descriptionText: 'what is this?', imageUrl: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQvaBQ6nAedlqvXsh-dLXZi2Gexy1RkDbTUKQ&s'),
CardData('Mock data', descriptionText: 'Mock data description', imageUrl: 'https://cdn-user30887.skyeng.ru/uploads/6692a339c6989979804399.png'),
],
);
}
}

View File

@ -14,14 +14,14 @@ class MovieRepository extends ApiInterface {
));
static const String _baseUrl = 'https://api.kinopoisk.dev';
static const String _apiKey = 'HQTFY5N-8D34FT0-HXQQQ1S-KPREHDX'; // Ваш API-ключ
static const String _apiKey = 'HQTFY5N-8D34FT0-HXQQQ1S-KPREHDX';
@override
Future<HomeData?> loadData({
OnErrorCallback? onError,
String? q,
int page = 1,
int pageSize = 15,
int pageSize = 10,
}) async {
try {
const String url = '$_baseUrl/v1.4/movie/search';
@ -30,7 +30,7 @@ class MovieRepository extends ApiInterface {
url,
queryParameters: {
'page': page,
'limit': pageSize,
'pageSize': pageSize,
'query': q,
},
options: Options(

View File

@ -1,12 +1,17 @@
class CardData {
final String text;
final String descriptionText;
final String? imageUrl;
final String? id;
CardData(this.text,
{required this.descriptionText,
this.imageUrl,
this.id});
final int? year;
final List<String>? genres;
final List<String>? countries;
CardData(this.text, {
required this.descriptionText,
this.imageUrl,
this.id,
this.year,
this.genres,
this.countries,
});
}

View File

@ -21,16 +21,16 @@ class MyApp extends StatelessWidget {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return BlocProvider<LocaleBloc>(
return BlocProvider<LocaleBloc>( //
lazy: false,
create: (context) => LocaleBloc(Locale(Platform.localeName)),
child: BlocBuilder<LocaleBloc, LocaleState>(
child: BlocBuilder<LocaleBloc, LocaleState>( //
builder: (context, state) {
return MaterialApp(
title: 'Flutter Demo',
locale: state.currentLocale,
localizationsDelegates: AppLocale.localizationsDelegates,
supportedLocales: AppLocale.supportedLocales,
locale: state.currentLocale, // передаем текущую локаль
localizationsDelegates: AppLocale.localizationsDelegates, // делегат (подключение локали)
supportedLocales: AppLocale.supportedLocales, // список доступных локалей (подключение локали)
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme:
@ -40,7 +40,7 @@ class MyApp extends StatelessWidget {
home: RepositoryProvider<MovieRepository>(
lazy: true,
create: (_) => MovieRepository(),
child: BlocProvider<LikeBloc>(
child: BlocProvider<LikeBloc>( // добавили BlocProvider
lazy: false,
create: (context) => LikeBloc(),
child: BlocProvider<HomeBloc>(

View File

@ -11,45 +11,86 @@ class DetailsPage extends StatelessWidget {
return Scaffold(
appBar: AppBar(
title: Text("Детали"),
backgroundColor: Colors.purpleAccent, // Фиолетовый цвет для AppBar
),
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Изображение слева
ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(20)),
child: Image.network(
data.imageUrl ?? '',
height: 600, // Задайте фиксированную высоту для изображения
width: 600, // Задайте фиксированную ширину для изображения
fit: BoxFit.cover, // Обеспечьте хороший аспект изображения
body: Container(
color: Colors.purple[100], // Светло-сиреневый фон для всей страницы
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Изображение слева
ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(20)),
child: Image.network(
data.imageUrl ?? '',
height: 600, // Задайте фиксированную высоту для изображения
width: 600, // Задайте фиксированную ширину для изображения
fit: BoxFit.cover, // Обеспечьте хороший аспект изображения
),
),
),
SizedBox(width: 16), // Промежуток между изображением и текстом
SizedBox(width: 16), // Промежуток между изображением и текстом
// Текст справа
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: Text(
data.text,
style: Theme.of(context).textTheme.headlineLarge,
// Текст справа
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: Text(
data.text,
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
color: Colors.purple, // Простой сиреневый цвет текста заголовка
fontWeight: FontWeight.bold, // Жирный шрифт
),
),
),
),
Text(
data.descriptionText,
style: Theme.of(context).textTheme.bodyLarge,
),
],
Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: Text(
'Год: ${data.year}', // Отображение года
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Colors.purple[700], // Темный сиреневый цвет текста года
fontStyle: FontStyle.italic, // Курсив для выделения года
),
),
),
Text(
data.descriptionText ?? '', // Обработаем случай, если описания нет
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Colors.purple[600], // Темный сиреневый цвет текста описания
),
),
// Отображение жанров
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
'Жанры: ${data.genres?.join(', ') ?? 'Нет жанров'}', // Проверка на null
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Colors.purple[600], // Темный сиреневый цвет текста жанров
fontStyle: FontStyle.italic, // Курсив для выделения жанров
),
),
),
// Отображение стран
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
'Страны: ${data.countries?.join(', ') ?? 'Нет стран'}', // Проверка на null
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Colors.purple[600], // Темный сиреневый цвет текста стран
fontStyle: FontStyle.italic, // Курсив для выделения стран
),
),
),
],
),
),
),
],
],
),
),
),
),

View File

@ -2,7 +2,7 @@ part of 'home_page.dart';
typedef OnLikeCallback = void Function(String? id, String title, bool isLiked)?;
class _Card extends StatelessWidget {
class _Card extends StatelessWidget { // состояние карточки регулируется извне; виджеты с точкой убираем
final String text;
final String descriptionText;
final String? imageUrl;
@ -11,22 +11,20 @@ class _Card extends StatelessWidget {
final String? id;
final bool isLiked;
const _Card(
this.text, {
required this.descriptionText,
this.imageUrl,
this.onLike,
this.onTap,
this.id,
this.isLiked = false,
});
const _Card(this.text, {
required this.descriptionText,
this.imageUrl,
this.onLike,
this.onTap,
this.id,
this.isLiked = false,
});
factory _Card.fromData(
CardData data, {
OnLikeCallback onLike,
VoidCallback? onTap,
bool isLiked = false,
}) =>
factory _Card.fromData(CardData data, {
OnLikeCallback onLike,
VoidCallback? onTap,
bool isLiked = false,
}) =>
_Card(
data.text,
descriptionText: data.descriptionText,
@ -43,13 +41,14 @@ class _Card extends StatelessWidget {
onTap: onTap,
child: Container(
margin: const EdgeInsets.all(16),
constraints: const BoxConstraints(minHeight: 160),
constraints: const BoxConstraints(minHeight: 130),
decoration: BoxDecoration(
color: Colors.white70,
color: Colors.white.withOpacity(0.9),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.purpleAccent, width: 2),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(.5),
color: Colors.grey.withOpacity(0.5),
spreadRadius: 4,
offset: const Offset(0, 5),
blurRadius: 8,
@ -68,33 +67,32 @@ class _Card extends StatelessWidget {
child: SizedBox(
height: double.infinity,
width: 120,
child: Stack(
children: [
Positioned.fill(
child: Image.network(
imageUrl ?? '',
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const Placeholder(),
),
),
],
child: Image.network(
imageUrl ?? '',
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const Placeholder(),
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 16.0),
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
text,
style: Theme.of(context).textTheme.headlineSmall,
Center(
child: Text(
text,
style: Theme
.of(context)
.textTheme
.headlineSmall
?.apply(color: Colors.purple),
textAlign: TextAlign.center,
),
),
Text(
descriptionText,
style: Theme.of(context).textTheme.bodyLarge,
)
SizedBox(height: 4),
],
),
),
@ -102,20 +100,35 @@ class _Card extends StatelessWidget {
Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.only(left: 8, right: 16, bottom: 16),
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),
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: isLiked ? Colors.transparent : Colors.purple,
width: 2,
),
borderRadius: BorderRadius.circular(20),
),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.all(8),
child: isLiked
? const Icon(
Icons.favorite,
color: Colors.purple,
key: ValueKey<int>(0),
)
: const Icon(
Icons.favorite_border,
color: Colors.purple,
key: ValueKey<int>(1),
),
),
),
),
),

View File

@ -32,7 +32,7 @@ class _HomePageState extends State<HomePage> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
backgroundColor: Colors.purpleAccent,
),
body: const Body(),
);
@ -56,7 +56,7 @@ class _BodyState extends State<Body> {
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<HomeBloc>().add(const HomeLoadDataEvent());
context.read<LikeBloc>().add(const LoadLikesEvent());
context.read<LikeBloc>().add(const LoadLikesEvent()); // событие на изменение лайка
});
scrollController.addListener(_onNextPageListener);
@ -84,7 +84,7 @@ class _BodyState extends State<Body> {
final MovieRepository repo = MovieRepository();
var data = MovieRepository().loadData();
void _onLike(String? id, String title, bool isLiked) {
void _onLike(String? id, String title, bool isLiked) { // обработчик лайков
print("$id $title, $isLiked");
if (id != null) {
context.read<LikeBloc>().add(ChangeLikeEvent(id));
@ -96,7 +96,7 @@ class _BodyState extends State<Body> {
WidgetsBinding.instance.addPostFrameCallback((_) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(
' ${isLiked ? context.locale.liked : context.locale.disliked} $title',
' ${isLiked ? context.locale.liked : context.locale.disliked} $title', //переписали константные строки под локаль
style: Theme.of(context).textTheme.bodyLarge,
),
backgroundColor: Colors.orangeAccent,
@ -124,58 +124,64 @@ class _BodyState extends State<Body> {
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(12),
child: CupertinoSearchTextField(
controller: searchController,
onChanged: (search) {
Debounce.run(() => context
.read<HomeBloc>()
.add(HomeLoadDataEvent(search: search)));
})),
GestureDetector(
onTap: () =>
context.read<LocaleBloc>().add(const ChangeLocaleEvent()),
child: SizedBox.square(
dimension: 50,
child: Padding(
padding: const EdgeInsets.only(right: 12),
child: BlocBuilder<LocaleBloc, LocaleState>(
builder: (context, state) {
return state.currentLocale.languageCode == 'ru'
? const SvgRu()
: const SvgUk();
},
padding: const EdgeInsets.all(12),
child: Row(
children: [
Expanded(
child: CupertinoSearchTextField(
controller: searchController,
onChanged: (search) {
Debounce.run(() => context
.read<HomeBloc>()
.add(HomeLoadDataEvent(search: search)));
},
),
),
),
const SizedBox(width: 12), // Отступ между полем поиска и иконкой
GestureDetector(
onTap: () =>
context.read<LocaleBloc>().add(const ChangeLocaleEvent()), // смена иконки локализации
child: SizedBox.square(
dimension: 50,
child: BlocBuilder<LocaleBloc, LocaleState>(
builder: (context, state) {
return state.currentLocale.languageCode == 'ru'
? const SvgRu()
: const SvgUk();
},
),
),
),
],
),
),
BlocBuilder<HomeBloc, HomeState>(
BlocBuilder<HomeBloc, HomeState>( // обертка списка с карточками
builder: (context, state) => state.isLoading
? CircularProgressIndicator()
: BlocBuilder<LikeBloc, LikeState>(
builder: (context, likeState) => 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,
isLiked: likeState.likedIds
?.contains(data.id) ==
true,
onLike: _onLike,
onTap: () =>
_navToDetails(context, data),
)
: const SizedBox.shrink();
}),
),
))),
builder: (context, likeState) => 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,
isLiked: likeState.likedIds
?.contains(data.id) ==
true,
onLike: _onLike,
onTap: () =>
_navToDetails(context, data),
)
: const SizedBox.shrink();
}),
),
))),
BlocBuilder<HomeBloc, HomeState>(
builder: (context, state) => state.isPaginationLoading
? const CircularProgressIndicator()

View File

@ -43,7 +43,7 @@ dev_dependencies:
flutter_icons:
android: "ic_launcher"
ios: true
image_path: "assets/launcher.jpg"
image_path: "assets/icon1.jpg"
min_sdk_android: 21
flutter: