lab6 done

This commit is contained in:
Алексей Тихоненков 2024-12-18 03:01:46 +04:00
parent 76dcbb954b
commit 1c46c1c0fb
13 changed files with 372 additions and 266 deletions

View File

@ -0,0 +1,20 @@
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: 1000),
}) {
_timer?.cancel();
_timer = Timer(delay, action);
}
}

View File

@ -1,5 +1,6 @@
import '../../domain/quote.dart';
import '/data/dtos/quotes_dto.dart'; import '/data/dtos/quotes_dto.dart';
import '/main.dart'; import '/presentation/home_page/home_page.dart';
const _imagePlaceholder = const _imagePlaceholder =
'https://cdn-icons-png.flaticon.com/128/17818/17818874.png'; 'https://cdn-icons-png.flaticon.com/128/17818/17818874.png';

View File

@ -1,4 +1,5 @@
import '/main.dart'; import '../../domain/quote.dart';
import '/presentation/home_page/home_page.dart';
typedef OnErrorCallback = void Function(String? error); typedef OnErrorCallback = void Function(String? error);

View File

@ -1,9 +1,10 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../../domain/quote.dart';
import '/data/dtos/quotes_dto.dart'; import '/data/dtos/quotes_dto.dart';
import '/data/mappers/quotes_mapper.dart'; import '/data/mappers/quotes_mapper.dart';
import '/data/repositories/api_interface.dart'; import '/data/repositories/api_interface.dart';
import '/main.dart'; import '/presentation/home_page/home_page.dart';
import '/data/presentation/dialogs/show_dialog.dart'; import '/presentation/dialogs/show_dialog.dart';
import 'package:pretty_dio_logger/pretty_dio_logger.dart'; import 'package:pretty_dio_logger/pretty_dio_logger.dart';
class QuotesRepository extends ApiInterface { class QuotesRepository extends ApiInterface {
@ -23,12 +24,12 @@ class QuotesRepository extends ApiInterface {
try { try {
final Map<String, dynamic> queryParams = {}; final Map<String, dynamic> queryParams = {};
// Добавляем фильтрацию по тексту цитаты
if (q != null && q.isNotEmpty) { if (q != null && q.isNotEmpty) {
queryParams['filter'] = q; // Фильтруем по слову queryParams['filter'] = q;
} }
// Добавляем фильтрацию по автору
if (author != null && author.isNotEmpty) { if (author != null && author.isNotEmpty) {
queryParams['type'] = 'author'; queryParams['type'] = 'author';
queryParams['filter'] = author; queryParams['filter'] = author;

12
lib/domain/quote.dart Normal file
View File

@ -0,0 +1,12 @@
class Quote {
final String text;
final String author;
final String imagePath;
bool isFavorite;
Quote(this.text, this.author, this.imagePath, [this.isFavorite = false]);
void toggleFavorite() {
isFavorite = !isFavorite;
}
}

View File

@ -1,5 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '/data/repositories/quotes_repository.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'data/repositories/quotes_repository.dart';
import '/presentation/home_page/bloc/bloc.dart';
import '/presentation/home_page/home_page.dart';
void main() { void main() {
runApp(const MyApp()); runApp(const MyApp());
@ -16,266 +19,15 @@ class MyApp extends StatelessWidget {
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true, useMaterial3: true,
), ),
home: const MyHomePage(title: 'Цитаты'), home: RepositoryProvider<QuotesRepository>(
); lazy: true,
} create: (_) => QuotesRepository(),
} child: BlocProvider<HomeBloc>(
lazy: false,
class Quote { create: (context) => HomeBloc(context.read<QuotesRepository>()),
final String text; child: const MyHomePage(title: "Цитаты",),
final String author;
final String imagePath;
bool isFavorite;
Quote(this.text, this.author, this.imagePath, [this.isFavorite = false]);
void toggleFavorite() {
isFavorite = !isFavorite;
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
final List<Quote> _quotes = [];
final List<Quote> _filteredQuotes = [];
final QuotesRepository _quotesRepository = QuotesRepository();
final TextEditingController searchController = TextEditingController();
final TextEditingController authorController = TextEditingController();
@override
void initState() {
super.initState();
_loadQuotes();
searchController.addListener(() {
_loadQuotes();
});
authorController.addListener(() {
_loadQuotes();
});
}
// Метод для загрузки цитат с учетом обоих фильтров
Future<void> _loadQuotes() async {
final query = searchController.text;
final author = authorController.text;
final quotes = await _quotesRepository.loadData(
q: query, // Поиск по цитате
author: author, // Поиск по автору
onError: (error) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ошибка загрузки: $error')),
);
},
);
if (quotes != null) {
setState(() {
_quotes.clear();
_quotes.addAll(quotes);
_filterQuotes();
});
}
print('Загружено цитат: ${_quotes.length}');
print('Отфильтрованные цитаты: ${_filteredQuotes.length}');
}
// Фильтрация цитат по введенным значениям
void _filterQuotes() {
setState(() {
_filteredQuotes.clear(); // Очистите список фильтрации
_filteredQuotes.addAll(_quotes); // Изначально показываем все цитаты
// Применяем фильтрацию по цитате
if (searchController.text.isNotEmpty) {
_filteredQuotes.retainWhere((quote) =>
quote.text.toLowerCase().contains(searchController.text.toLowerCase()));
}
// Применяем фильтрацию по автору
if (authorController.text.isNotEmpty) {
_filteredQuotes.retainWhere((quote) =>
quote.author.toLowerCase().contains(authorController.text.toLowerCase()));
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Цитаты'),
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: searchController,
decoration: const InputDecoration(
labelText: 'Поиск по цитате',
prefixIcon: Icon(Icons.search),
),
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: authorController,
decoration: const InputDecoration(
labelText: 'Поиск по автору',
prefixIcon: Icon(Icons.person),
),
),
),
Expanded(
child: _filteredQuotes.isEmpty
? const Center(
child: Text(
'Нет цитат для отображения.',
style: TextStyle(fontSize: 18, color: Colors.grey),
),
)
: ListView.builder(
itemCount: _filteredQuotes.length,
itemBuilder: (context, index) {
final quote = _filteredQuotes[index]; // Используем _filteredQuotes для отображения
return Card(
margin: const EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 10.0,
),
child: ListTile(
contentPadding: const EdgeInsets.all(8.0),
leading: SizedBox(
width: 50.0,
child: Image.network(
quote.imagePath,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
const Icon(Icons.error, color: Colors.red),
),
),
title: Text(
quote.text,
style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.w500),
),
subtitle: Text('- ${quote.author}'),
trailing: IconButton(
icon: Icon(
quote.isFavorite
? Icons.favorite
: Icons.favorite_border,
color: quote.isFavorite ? Colors.red : null,
),
onPressed: () {
setState(() {
quote.toggleFavorite();
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(quote.isFavorite
? 'Цитата добавлена в избранное'
: 'Цитата удалена из избранного'),
duration: const Duration(seconds: 2),
),
);
},
),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
QuoteDetailScreen(quote: quote),
),
);
},
),
);
},
),
),
],
),
);
}
}
class QuoteDetailScreen extends StatelessWidget {
final Quote quote;
const QuoteDetailScreen({super.key, required this.quote});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Детали цитаты'),
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.network(
quote.imagePath,
height: 150,
errorBuilder: (_, __, ___) =>
const Icon(Icons.error, color: Colors.red),
),
const SizedBox(height: 20),
Text(
quote.text,
style:
const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 10),
Text(
'- ${quote.author}',
style: const TextStyle(fontSize: 18, color: Colors.grey),
),
],
),
), ),
), ),
); );
} }
} }
extension StringExtension on String {
String capitalize() {
return split(' ').map((word) {
if (word.isNotEmpty) {
return '${word[0].toUpperCase()}${word.substring(1).toLowerCase()}';
}
return word;
}).join(' ');
}
String addQuotesIfMissing() {
if (startsWith('\"') && endsWith('\"')) return this;
if (startsWith('\"') && !endsWith('\"')) return '$this\"';
if (endsWith('\"') && !startsWith('\"')) return '\"$this';
return '\"$this\"';
}
}

View File

@ -0,0 +1,51 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../data/repositories/quotes_repository.dart';
import '/presentation/home_page/bloc/events.dart';
import '/presentation/home_page/bloc/state.dart';
class HomeBloc extends Bloc<HomeEvent, HomeState> {
final QuotesRepository repo;
HomeBloc(this.repo) : super(const HomeState()) {
on<HomeLoadDataEvent>(_onLoadData);
on<HomeRefreshEvent>(_onRefreshData);
}
void _onLoadData(HomeLoadDataEvent event, Emitter<HomeState> emit) async {
final author = event.author?.trim() ?? '';
final search = event.search?.trim() ?? '';
// Если поле автора не заполнено, загружаем данные из репозитория
if (author.isEmpty) {
emit(state.copyWith(
data: repo.loadData(q: search).then((result) => result ?? []), // Обработка null
));
} else {
// Если автор указан, фильтруем данные по тексту цитаты
try {
final currentData = await repo.loadData(q:search, author: author,
);
final filteredData = currentData?.where((quote) {
final matchesAuthor = quote.author.toLowerCase().contains(author.toLowerCase());
final matchesSearch = search.isEmpty ||
quote.text.toLowerCase().contains(search.toLowerCase());
return matchesAuthor && matchesSearch;
}).toList();
emit(state.copyWith(data: Future.value(filteredData)));
} catch (error) {
emit(state.copyWith(data: Future.error(error)));
}
}
}
Future<void> _onRefreshData(HomeRefreshEvent event, Emitter<HomeState> emit) async {
add(HomeLoadDataEvent(search: event.search, author: event.author)); // Просто перезапускаем загрузку
}
}

View File

@ -0,0 +1,17 @@
abstract class HomeEvent {
const HomeEvent();
}
class HomeLoadDataEvent extends HomeEvent {
final String? search;
final String? author;
const HomeLoadDataEvent({this.search, this.author});
}
class HomeRefreshEvent extends HomeEvent {
final String? search;
final String? author;
const HomeRefreshEvent({this.search, this.author});
}

View File

@ -0,0 +1,15 @@
import 'package:equatable/equatable.dart';
import '../../../domain/quote.dart';
import '../home_page.dart';
class HomeState extends Equatable {
final Future<List<Quote>>? data; // Изменено
const HomeState({this.data});
HomeState copyWith({Future<List<Quote>>? data}) => HomeState(data: data ?? this.data);
@override
List<Object?> get props => [data];
}

View File

@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import '../../domain/quote.dart';
import 'home_page.dart';
class QuoteCard extends StatelessWidget {
final Quote quote;
final VoidCallback onFavoriteToggle;
const QuoteCard({
super.key,
required this.quote,
required this.onFavoriteToggle,
});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 10.0,
),
child: ListTile(
contentPadding: const EdgeInsets.all(8.0),
leading: SizedBox(
width: 50.0,
child: Image.network(
quote.imagePath,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
const Icon(Icons.error, color: Colors.red),
),
),
title: Text(
quote.text,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w500),
),
subtitle: Text('- ${quote.author}'),
trailing: IconButton(
icon: Icon(
quote.isFavorite ? Icons.favorite : Icons.favorite_border,
color: quote.isFavorite ? Colors.red : null,
),
onPressed: onFavoriteToggle,
),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => QuoteDetailScreen(quote: quote),
),
);
},
),
);
}
}
class QuoteDetailScreen extends StatelessWidget {
final Quote quote;
const QuoteDetailScreen({super.key, required this.quote});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Детали цитаты'),
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.network(
quote.imagePath,
height: 150,
errorBuilder: (_, __, ___) =>
const Icon(Icons.error, color: Colors.red),
),
const SizedBox(height: 20),
Text(
quote.text,
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 10),
Text(
'- ${quote.author}',
style: const TextStyle(fontSize: 18, color: Colors.grey),
),
],
),
),
),
);
}
}

View File

@ -0,0 +1,138 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../components/utils/debounce.dart';
import '../../domain/quote.dart';
import '/data/repositories/quotes_repository.dart';
import '/presentation/home_page/bloc/bloc.dart';
import '/presentation/home_page/bloc/events.dart';
import '/presentation/home_page/bloc/state.dart';
import 'card.dart';
class MyHomePage extends StatelessWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => HomeBloc(QuotesRepository())..add(const HomeLoadDataEvent()),
child: Scaffold(
appBar: AppBar(
title: Text(title),
),
body: const _HomePageBody(),
),
);
}
}
class _HomePageBody extends StatefulWidget {
const _HomePageBody();
@override
State<_HomePageBody> createState() => _HomePageBodyState();
}
class _HomePageBodyState extends State<_HomePageBody> {
final TextEditingController searchController = TextEditingController();
final TextEditingController authorController = TextEditingController();
@override
void initState() {
super.initState();
searchController.addListener(() {
Debounce.run(() {
context.read<HomeBloc>().add(HomeLoadDataEvent(search: searchController.text));
});
});
authorController.addListener(() {
Debounce.run(() {
context.read<HomeBloc>().add(HomeLoadDataEvent(
search: searchController.text,
author: authorController.text,
));
});
});
}
@override
Widget build(BuildContext context) {
return BlocBuilder<HomeBloc, HomeState>(
builder: (context, state) {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: searchController,
decoration: const InputDecoration(
labelText: 'Поиск по цитате',
prefixIcon: Icon(Icons.search),
),
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: authorController,
decoration: const InputDecoration(
labelText: 'Поиск по автору',
prefixIcon: Icon(Icons.person),
),
),
),
Expanded(
child: state.data == null
? const Center(child: CircularProgressIndicator())
: FutureBuilder<List<Quote>?>(
future: state.data,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('Ошибка: ${snapshot.error}'));
}
if (snapshot.data == null || snapshot.data!.isEmpty) {
return const Center(
child: Text(
'Нет цитат для отображения.',
style: TextStyle(fontSize: 18, color: Colors.grey),
),
);
}
final quotes = snapshot.data!;
return RefreshIndicator(
onRefresh: () async {
context.read<HomeBloc>().add(HomeRefreshEvent(
search: searchController.text,
author: authorController.text,
));
},
child: ListView.builder(
itemCount: quotes.length,
itemBuilder: (context, index) {
final quote = quotes[index];
return QuoteCard(
quote: quote,
onFavoriteToggle: () {
setState(() {
quote.toggleFavorite();
});
},
);
},
),
);
},
),
),
],
);
},
);
}
}