From 1c46c1c0fb8e2cd167976932d4e712ca8601206c Mon Sep 17 00:00:00 2001 From: Yourdax Date: Wed, 18 Dec 2024 03:01:46 +0400 Subject: [PATCH] lab6 done --- lib/components/utils/debounce.dart | 20 ++ lib/data/mappers/quotes_mapper.dart | 3 +- lib/data/repositories/api_interface.dart | 3 +- lib/data/repositories/quotes_repository.dart | 11 +- lib/domain/quote.dart | 12 + lib/main.dart | 270 +----------------- .../presentation/dialogs/error_dialog.dart | 0 .../presentation/dialogs/show_dialog.dart | 0 lib/presentation/home_page/bloc/bloc.dart | 51 ++++ lib/presentation/home_page/bloc/events.dart | 17 ++ lib/presentation/home_page/bloc/state.dart | 15 + lib/presentation/home_page/card.dart | 98 +++++++ lib/presentation/home_page/home_page.dart | 138 +++++++++ 13 files changed, 372 insertions(+), 266 deletions(-) create mode 100644 lib/components/utils/debounce.dart create mode 100644 lib/domain/quote.dart rename lib/{data => }/presentation/dialogs/error_dialog.dart (100%) rename lib/{data => }/presentation/dialogs/show_dialog.dart (100%) create mode 100644 lib/presentation/home_page/bloc/bloc.dart create mode 100644 lib/presentation/home_page/bloc/events.dart create mode 100644 lib/presentation/home_page/bloc/state.dart create mode 100644 lib/presentation/home_page/card.dart create mode 100644 lib/presentation/home_page/home_page.dart diff --git a/lib/components/utils/debounce.dart b/lib/components/utils/debounce.dart new file mode 100644 index 0000000..14e1d20 --- /dev/null +++ b/lib/components/utils/debounce.dart @@ -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); + } +} \ No newline at end of file diff --git a/lib/data/mappers/quotes_mapper.dart b/lib/data/mappers/quotes_mapper.dart index 95dec55..c4aa9e9 100644 --- a/lib/data/mappers/quotes_mapper.dart +++ b/lib/data/mappers/quotes_mapper.dart @@ -1,5 +1,6 @@ +import '../../domain/quote.dart'; import '/data/dtos/quotes_dto.dart'; -import '/main.dart'; +import '/presentation/home_page/home_page.dart'; const _imagePlaceholder = 'https://cdn-icons-png.flaticon.com/128/17818/17818874.png'; diff --git a/lib/data/repositories/api_interface.dart b/lib/data/repositories/api_interface.dart index 65bb47a..0c4185a 100644 --- a/lib/data/repositories/api_interface.dart +++ b/lib/data/repositories/api_interface.dart @@ -1,4 +1,5 @@ -import '/main.dart'; +import '../../domain/quote.dart'; +import '/presentation/home_page/home_page.dart'; typedef OnErrorCallback = void Function(String? error); diff --git a/lib/data/repositories/quotes_repository.dart b/lib/data/repositories/quotes_repository.dart index ef5ef7a..8a816e1 100644 --- a/lib/data/repositories/quotes_repository.dart +++ b/lib/data/repositories/quotes_repository.dart @@ -1,9 +1,10 @@ import 'package:dio/dio.dart'; +import '../../domain/quote.dart'; import '/data/dtos/quotes_dto.dart'; import '/data/mappers/quotes_mapper.dart'; import '/data/repositories/api_interface.dart'; -import '/main.dart'; -import '/data/presentation/dialogs/show_dialog.dart'; +import '/presentation/home_page/home_page.dart'; +import '/presentation/dialogs/show_dialog.dart'; import 'package:pretty_dio_logger/pretty_dio_logger.dart'; class QuotesRepository extends ApiInterface { @@ -23,12 +24,12 @@ class QuotesRepository extends ApiInterface { try { final Map queryParams = {}; - // Добавляем фильтрацию по тексту цитаты + if (q != null && q.isNotEmpty) { - queryParams['filter'] = q; // Фильтруем по слову + queryParams['filter'] = q; } - // Добавляем фильтрацию по автору + if (author != null && author.isNotEmpty) { queryParams['type'] = 'author'; queryParams['filter'] = author; diff --git a/lib/domain/quote.dart b/lib/domain/quote.dart new file mode 100644 index 0000000..a9448f2 --- /dev/null +++ b/lib/domain/quote.dart @@ -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; + } +} diff --git a/lib/main.dart b/lib/main.dart index b759f5c..8d27ff6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,8 @@ 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() { runApp(const MyApp()); @@ -16,266 +19,15 @@ class MyApp extends StatelessWidget { colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), useMaterial3: true, ), - home: const MyHomePage(title: 'Цитаты'), - ); - } -} - -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; - } -} - -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); - - final String title; - - @override - State createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - final List _quotes = []; - final List _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 _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), - ), - ], - ), + home: RepositoryProvider( + lazy: true, + create: (_) => QuotesRepository(), + child: BlocProvider( + lazy: false, + create: (context) => HomeBloc(context.read()), + child: const MyHomePage(title: "Цитаты",), ), ), ); } } - - - - - -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\"'; - } -} diff --git a/lib/data/presentation/dialogs/error_dialog.dart b/lib/presentation/dialogs/error_dialog.dart similarity index 100% rename from lib/data/presentation/dialogs/error_dialog.dart rename to lib/presentation/dialogs/error_dialog.dart diff --git a/lib/data/presentation/dialogs/show_dialog.dart b/lib/presentation/dialogs/show_dialog.dart similarity index 100% rename from lib/data/presentation/dialogs/show_dialog.dart rename to lib/presentation/dialogs/show_dialog.dart diff --git a/lib/presentation/home_page/bloc/bloc.dart b/lib/presentation/home_page/bloc/bloc.dart new file mode 100644 index 0000000..4ea1ef4 --- /dev/null +++ b/lib/presentation/home_page/bloc/bloc.dart @@ -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 { + final QuotesRepository repo; + + HomeBloc(this.repo) : super(const HomeState()) { + on(_onLoadData); + on(_onRefreshData); + } + + void _onLoadData(HomeLoadDataEvent event, Emitter 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 _onRefreshData(HomeRefreshEvent event, Emitter emit) async { + add(HomeLoadDataEvent(search: event.search, author: event.author)); // Просто перезапускаем загрузку + } + +} diff --git a/lib/presentation/home_page/bloc/events.dart b/lib/presentation/home_page/bloc/events.dart new file mode 100644 index 0000000..7fb43b4 --- /dev/null +++ b/lib/presentation/home_page/bloc/events.dart @@ -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}); +} diff --git a/lib/presentation/home_page/bloc/state.dart b/lib/presentation/home_page/bloc/state.dart new file mode 100644 index 0000000..23c6c11 --- /dev/null +++ b/lib/presentation/home_page/bloc/state.dart @@ -0,0 +1,15 @@ +import 'package:equatable/equatable.dart'; +import '../../../domain/quote.dart'; +import '../home_page.dart'; + + +class HomeState extends Equatable { + final Future>? data; // Изменено + + const HomeState({this.data}); + + HomeState copyWith({Future>? data}) => HomeState(data: data ?? this.data); + + @override + List get props => [data]; +} diff --git a/lib/presentation/home_page/card.dart b/lib/presentation/home_page/card.dart new file mode 100644 index 0000000..b5b24f6 --- /dev/null +++ b/lib/presentation/home_page/card.dart @@ -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), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/presentation/home_page/home_page.dart b/lib/presentation/home_page/home_page.dart new file mode 100644 index 0000000..140bab0 --- /dev/null +++ b/lib/presentation/home_page/home_page.dart @@ -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().add(HomeLoadDataEvent(search: searchController.text)); + }); + }); + + authorController.addListener(() { + Debounce.run(() { + context.read().add(HomeLoadDataEvent( + search: searchController.text, + author: authorController.text, + )); + }); + }); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + 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?>( + 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().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(); + }); + }, + ); + }, + ), + ); + }, + ), + ), + ], + ); + }, + ); + } +}