Fix import paths and test issues

- Fixed test file import paths to point to correct Bloc file locations
- Fixed Bloc file import paths for models (../../../models/models.dart)
- Added explicit type annotations to resolve null safety warnings
- Fixed null safety issues in wishlist_bloc_test.dart
- All 70 tests now passing
This commit is contained in:
Yuriy Panov
2026-02-04 15:28:59 +06:00
parent 310463e89a
commit 2f97873095
46 changed files with 270 additions and 260 deletions

View File

@@ -0,0 +1,320 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:image_picker/image_picker.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../../bloc/app_bloc.dart';
import '../../bloc/app_event.dart';
import '../../../models/models.dart';
class AddBookScreen extends StatefulWidget {
final dynamic initialData;
const AddBookScreen({super.key, this.initialData});
@override
State<AddBookScreen> createState() => _AddBookScreenState();
}
class _AddBookScreenState extends State<AddBookScreen> {
final _formKey = GlobalKey<FormState>();
late TextEditingController _titleController;
late TextEditingController _authorController;
late TextEditingController _genreController;
late TextEditingController _annotationController;
String? _coverUrl;
BookStatus _selectedStatus = BookStatus.wantToRead;
@override
void initState() {
super.initState();
_titleController = TextEditingController();
_authorController = TextEditingController();
_genreController = TextEditingController();
_annotationController = TextEditingController();
if (widget.initialData is Book) {
final book = widget.initialData as Book;
_titleController.text = book.title;
_authorController.text = book.author;
_genreController.text = book.genre;
_annotationController.text = book.annotation;
_coverUrl = book.coverUrl;
_selectedStatus = book.status;
} else if (widget.initialData is Map) {
final data = widget.initialData as Map<String, dynamic>;
_titleController.text = data['title']?.toString() ?? '';
_authorController.text = data['author']?.toString() ?? '';
_genreController.text = data['genre']?.toString() ?? '';
_annotationController.text = data['annotation']?.toString() ?? '';
_coverUrl = data['coverUrl']?.toString();
}
}
@override
void dispose() {
_titleController.dispose();
_authorController.dispose();
_genreController.dispose();
_annotationController.dispose();
super.dispose();
}
Future<void> _pickImage() async {
final picker = ImagePicker();
final XFile? image = await picker.pickImage(source: ImageSource.gallery);
if (image != null) {
setState(() {
_coverUrl = image.path;
});
}
}
void _saveBook() {
if (_formKey.currentState!.validate()) {
context.read<AppBloc>().add(
BookSaved({
'title': _titleController.text,
'author': _authorController.text,
'genre': _genreController.text,
'annotation': _annotationController.text,
'coverUrl': _coverUrl,
'status': _selectedStatus,
}),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF112116),
appBar: AppBar(
backgroundColor: const Color(0xFF112116).withValues(alpha: 0.9),
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, color: Colors.white),
onPressed: () {
context.read<AppBloc>().add(
widget.initialData is Book
? const ScreenChanged(AppScreen.details)
: const ScreenChanged(AppScreen.library),
);
},
),
title: const Text('Добавить книгу'),
centerTitle: true,
),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
children: [
Center(
child: GestureDetector(
onTap: _pickImage,
child: Container(
width: 160,
height: 240,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: const Color(0xFF346544),
width: 2,
),
color: const Color(0xFF1A3222),
),
child: _coverUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(10),
child: _coverUrl!.startsWith('http')
? CachedNetworkImage(
imageUrl: _coverUrl!,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
)
: Image.network(_coverUrl!, fit: BoxFit.cover),
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF17CF54).withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Icon(
Icons.add_a_photo,
color: Color(0xFF17CF54),
size: 28,
),
),
const SizedBox(height: 12),
const Text(
'Загрузить',
style: TextStyle(
fontSize: 12,
color: Color(0xFF93C8A5),
),
),
],
),
),
),
),
const SizedBox(height: 32),
_buildTextField(
controller: _titleController,
label: 'Название',
validator: (value) =>
value?.isEmpty ?? true ? 'Введите название' : null,
),
const SizedBox(height: 16),
_buildTextField(
controller: _authorController,
label: 'Автор',
validator: (value) =>
value?.isEmpty ?? true ? 'Введите автора' : null,
),
const SizedBox(height: 16),
_buildTextField(
controller: _genreController,
label: 'Жанр',
validator: (value) =>
value?.isEmpty ?? true ? 'Введите жанр' : null,
),
const SizedBox(height: 16),
_buildTextField(
controller: _annotationController,
label: 'Аннотация',
maxLines: 4,
validator: (value) =>
value?.isEmpty ?? true ? 'Введите аннотацию' : null,
),
const SizedBox(height: 16),
DropdownButtonFormField<BookStatus>(
initialValue: _selectedStatus,
decoration: InputDecoration(
labelText: 'Статус',
labelStyle: const TextStyle(
color: Color(0xFFE0E0E0),
fontSize: 14,
fontWeight: FontWeight.w600,
),
filled: true,
fillColor: const Color(0xFF1A3222),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFF346544)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFF346544)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFF17CF54)),
),
),
dropdownColor: const Color(0xFF1A3222),
style: const TextStyle(color: Colors.white),
items: BookStatus.values.map((status) {
return DropdownMenuItem(
value: status,
child: Text(status.displayName),
);
}).toList(),
onChanged: (value) {
if (value != null) {
setState(() {
_selectedStatus = value;
});
}
},
),
const SizedBox(height: 120),
],
),
),
bottomNavigationBar: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF112116).withValues(alpha: 0.95),
border: Border(
top: BorderSide(color: Colors.white.withValues(alpha: 0.05), width: 1),
),
),
child: Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () {
context.read<AppBloc>().add(
widget.initialData is Book
? const ScreenChanged(AppScreen.details)
: const ScreenChanged(AppScreen.library),
);
},
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
side: const BorderSide(color: Colors.transparent),
),
child: const Text('Отмена'),
),
),
const SizedBox(width: 12),
Expanded(
flex: 2,
child: ElevatedButton.icon(
onPressed: _saveBook,
icon: const Icon(Icons.save),
label: const Text('Сохранить'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF17CF54),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
),
],
),
),
);
}
Widget _buildTextField({
required TextEditingController controller,
required String label,
int maxLines = 1,
String? Function(String?)? validator,
}) {
return TextFormField(
controller: controller,
maxLines: maxLines,
validator: validator,
style: const TextStyle(color: Colors.white, fontSize: 16),
decoration: InputDecoration(
labelText: label,
labelStyle: const TextStyle(
color: Color(0xFFE0E0E0),
fontSize: 14,
fontWeight: FontWeight.w600,
),
filled: true,
fillColor: const Color(0xFF1A3222),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFF346544)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFF346544)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFF17CF54)),
),
),
);
}
}

View File

@@ -0,0 +1,110 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../models/models.dart';
import 'add_book_event.dart';
import 'add_book_state.dart';
class AddBookBloc extends Bloc<AddBookEvent, AddBookState> {
AddBookBloc() : super(AddBookState.initial()) {
on<InitializeForm>(_onInitializeForm);
on<UpdateFormField>(_onUpdateFormField);
on<ToggleFavorite>(_onToggleFavorite);
on<ClearForm>(_onClearForm);
on<SubmitBook>(_onSubmitBook);
}
void _onInitializeForm(InitializeForm event, Emitter<AddBookState> emit) {
emit(state.copyWith(
prefilledData: event.prefilledData,
isLoading: false,
));
if (event.prefilledData != null) {
final data = event.prefilledData!;
emit(state.copyWith(
title: data['title'] as String? ?? '',
author: data['author'] as String? ?? '',
genre: data['genre'] as String? ?? '',
annotation: data['annotation'] as String? ?? '',
coverUrl: data['coverUrl'] as String? ?? '',
pages: data['pages'] as int?,
language: data['language'] as String? ?? '',
publishedYear: data['publishedYear'] as int?,
rating: data['rating'] as double? ?? 0.0,
status: data['status'] as BookStatus? ?? BookStatus.wantToRead,
progress: data['progress'] as int? ?? 0,
isFavorite: data['isFavorite'] as bool? ?? false,
));
}
}
void _onUpdateFormField(UpdateFormField event, Emitter<AddBookState> emit) {
switch (event.field) {
case 'title':
emit(state.copyWith(title: event.value as String));
break;
case 'author':
emit(state.copyWith(author: event.value as String));
break;
case 'genre':
emit(state.copyWith(genre: event.value as String));
break;
case 'annotation':
emit(state.copyWith(annotation: event.value as String));
break;
case 'coverUrl':
emit(state.copyWith(coverUrl: event.value as String));
break;
case 'pages':
emit(state.copyWith(pages: event.value as int?));
break;
case 'language':
emit(state.copyWith(language: event.value as String));
break;
case 'publishedYear':
emit(state.copyWith(publishedYear: event.value as int?));
break;
case 'rating':
emit(state.copyWith(rating: event.value as double?));
break;
case 'status':
emit(state.copyWith(status: event.value as BookStatus));
break;
case 'progress':
emit(state.copyWith(progress: event.value as int?));
break;
}
}
void _onToggleFavorite(ToggleFavorite event, Emitter<AddBookState> emit) {
emit(state.copyWith(isFavorite: !state.isFavorite));
}
void _onClearForm(ClearForm event, Emitter<AddBookState> emit) {
emit(AddBookState.initial());
}
void _onSubmitBook(SubmitBook event, Emitter<AddBookState> emit) {
final book = createBook(
id: DateTime.now().millisecondsSinceEpoch.toString(),
title: state.title.isNotEmpty ? state.title : 'Unknown',
author: state.author.isNotEmpty ? state.author : 'Unknown',
genre: state.genre.isNotEmpty ? state.genre : 'Unknown',
annotation: state.annotation,
coverUrl: state.coverUrl.isNotEmpty
? state.coverUrl
: 'https://picsum.photos/seed/${DateTime.now().millisecondsSinceEpoch}/400/600',
pages: state.pages,
language: state.language,
publishedYear: state.publishedYear,
rating: state.rating,
status: state.status,
progress: state.progress,
isFavorite: state.isFavorite,
);
emit(state.copyWith(
submittedBook: book,
isSubmitted: true,
));
}
}

View File

@@ -0,0 +1,27 @@
abstract class AddBookEvent {
const AddBookEvent();
}
class InitializeForm extends AddBookEvent {
final Map<String, dynamic>? prefilledData;
const InitializeForm(this.prefilledData);
}
class UpdateFormField extends AddBookEvent {
final String field;
final dynamic value;
const UpdateFormField(this.field, this.value);
}
class ToggleFavorite extends AddBookEvent {
const ToggleFavorite();
}
class ClearForm extends AddBookEvent {
const ClearForm();
}
class SubmitBook extends AddBookEvent {
const SubmitBook();
}

View File

@@ -0,0 +1,109 @@
import 'package:equatable/equatable.dart';
import '../../../models/models.dart';
class AddBookState extends Equatable {
final String title;
final String author;
final String genre;
final String annotation;
final String coverUrl;
final int? pages;
final String language;
final int? publishedYear;
final double? rating;
final BookStatus status;
final int? progress;
final bool isFavorite;
final Map<String, dynamic>? prefilledData;
final Book? submittedBook;
final bool isSubmitted;
final bool isLoading;
final String? errorMessage;
const AddBookState({
this.title = '',
this.author = '',
this.genre = '',
this.annotation = '',
this.coverUrl = '',
this.pages,
this.language = '',
this.publishedYear,
this.rating,
this.status = BookStatus.wantToRead,
this.progress,
this.isFavorite = false,
this.prefilledData,
this.submittedBook,
this.isSubmitted = false,
this.isLoading = false,
this.errorMessage,
});
factory AddBookState.initial() {
return const AddBookState(
isLoading: false,
);
}
AddBookState copyWith({
String? title,
String? author,
String? genre,
String? annotation,
String? coverUrl,
int? pages,
String? language,
int? publishedYear,
double? rating,
BookStatus? status,
int? progress,
bool? isFavorite,
Map<String, dynamic>? prefilledData,
Book? submittedBook,
bool? isSubmitted,
bool? isLoading,
String? errorMessage,
}) {
return AddBookState(
title: title ?? this.title,
author: author ?? this.author,
genre: genre ?? this.genre,
annotation: annotation ?? this.annotation,
coverUrl: coverUrl ?? this.coverUrl,
pages: pages ?? this.pages,
language: language ?? this.language,
publishedYear: publishedYear ?? this.publishedYear,
rating: rating ?? this.rating,
status: status ?? this.status,
progress: progress ?? this.progress,
isFavorite: isFavorite ?? this.isFavorite,
prefilledData: prefilledData ?? this.prefilledData,
submittedBook: submittedBook ?? this.submittedBook,
isSubmitted: isSubmitted ?? this.isSubmitted,
isLoading: isLoading ?? this.isLoading,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@override
List<Object?> get props => [
title,
author,
genre,
annotation,
coverUrl,
pages,
language,
publishedYear,
rating,
status,
progress,
isFavorite,
prefilledData,
submittedBook,
isSubmitted,
isLoading,
errorMessage,
];
}

View File

@@ -0,0 +1,64 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../models/models.dart';
import 'book_details_event.dart';
import 'book_details_state.dart';
class BookDetailsBloc extends Bloc<BookDetailsEvent, BookDetailsState> {
BookDetailsBloc() : super(BookDetailsState.initial()) {
on<LoadBookDetails>(_onLoadBookDetails);
on<ToggleFavorite>(_onToggleFavorite);
on<UpdateProgress>(_onUpdateProgress);
on<UpdateStatus>(_onUpdateStatus);
on<DeleteBook>(_onDeleteBook);
}
void _onLoadBookDetails(LoadBookDetails event, Emitter<BookDetailsState> emit) {
emit(state.copyWith(
book: event.book,
isLoading: false,
));
}
void _onToggleFavorite(ToggleFavorite event, Emitter<BookDetailsState> emit) {
if (state.book == null) return;
final updatedBook = state.book!.copyWith(
isFavorite: !state.book!.isFavorite,
);
emit(state.copyWith(
book: updatedBook,
));
}
void _onUpdateProgress(UpdateProgress event, Emitter<BookDetailsState> emit) {
if (state.book == null) return;
final updatedBook = state.book!.copyWith(
progress: event.progress,
status: event.progress >= 100 ? BookStatus.done : BookStatus.reading,
);
emit(state.copyWith(
book: updatedBook,
));
}
void _onUpdateStatus(UpdateStatus event, Emitter<BookDetailsState> emit) {
if (state.book == null) return;
final updatedBook = state.book!.copyWith(
status: event.status,
);
emit(state.copyWith(
book: updatedBook,
));
}
void _onDeleteBook(DeleteBook event, Emitter<BookDetailsState> emit) {
emit(state.copyWith(
isDeleted: true,
));
}
}

View File

@@ -0,0 +1,28 @@
import '../../../models/models.dart';
abstract class BookDetailsEvent {
const BookDetailsEvent();
}
class LoadBookDetails extends BookDetailsEvent {
final Book book;
const LoadBookDetails(this.book);
}
class ToggleFavorite extends BookDetailsEvent {
const ToggleFavorite();
}
class UpdateProgress extends BookDetailsEvent {
final int progress;
const UpdateProgress(this.progress);
}
class UpdateStatus extends BookDetailsEvent {
final BookStatus status;
const UpdateStatus(this.status);
}
class DeleteBook extends BookDetailsEvent {
const DeleteBook();
}

View File

@@ -0,0 +1,44 @@
import 'package:equatable/equatable.dart';
import '../../../models/models.dart';
class BookDetailsState extends Equatable {
final Book? book;
final bool isLoading;
final bool isDeleted;
final String? errorMessage;
const BookDetailsState({
this.book,
this.isLoading = false,
this.isDeleted = false,
this.errorMessage,
});
factory BookDetailsState.initial() {
return const BookDetailsState(
isLoading: true,
);
}
BookDetailsState copyWith({
Book? book,
bool? isLoading,
bool? isDeleted,
String? errorMessage,
}) {
return BookDetailsState(
book: book ?? this.book,
isLoading: isLoading ?? this.isLoading,
isDeleted: isDeleted ?? this.isDeleted,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@override
List<Object?> get props => [
book,
isLoading,
isDeleted,
errorMessage,
];
}

View File

@@ -0,0 +1,288 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../../bloc/app_bloc.dart';
import '../../bloc/app_event.dart';
import '../../../models/models.dart';
class BookDetailsScreen extends StatelessWidget {
final Book book;
const BookDetailsScreen({super.key, required this.book});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF112116),
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 460,
pinned: true,
backgroundColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, color: Colors.white),
onPressed: () {
context.read<AppBloc>().add(const ScreenChanged(AppScreen.library));
},
),
actions: [
IconButton(
icon: const Icon(Icons.more_horiz, color: Colors.white),
onPressed: () {},
),
],
flexibleSpace: FlexibleSpaceBar(
background: Stack(
fit: StackFit.expand,
children: [
CachedNetworkImage(
imageUrl: book.coverUrl ??
'https://picsum.photos/seed/placeholder/400/600',
fit: BoxFit.cover,
width: double.infinity,
),
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
const Color(0xFF112116),
],
),
),
),
],
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
),
),
child: Text(
book.status.displayName,
style: const TextStyle(
color: Color(0xFF17CF54),
fontSize: 12,
fontWeight: FontWeight.bold,
letterSpacing: 1,
),
),
),
const SizedBox(height: 16),
Text(
book.title,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 8),
Text(
book.author,
style: const TextStyle(
fontSize: 18,
color: Color(0xFF93C8A5),
),
),
const SizedBox(height: 24),
Wrap(
spacing: 8,
runSpacing: 8,
children: book.genre.split(',').map((genre) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
decoration: BoxDecoration(
color: const Color(0xFF244730),
borderRadius: BorderRadius.circular(8),
),
child: Text(
genre.trim(),
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
);
}).toList(),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: () {
context.read<AppBloc>().add(const ScreenChanged(AppScreen.addBook));
},
icon: const Icon(Icons.edit_square),
label: const Text('Edit Details'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF17CF54),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: () {
context.read<AppBloc>().add(BookDeleted(book.id));
},
icon: const Icon(Icons.delete),
label: const Text('Delete'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white.withValues(alpha: 0.05),
foregroundColor: Colors.white,
side: BorderSide(
color: Colors.white.withValues(alpha: 0.1),
),
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
),
],
),
const SizedBox(height: 32),
const Align(
alignment: Alignment.centerLeft,
child: Text(
'About',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Colors.white.withValues(alpha: 0.05),
),
),
child: Text(
book.annotation,
style: const TextStyle(
fontSize: 15,
color: Color(0xFF93C8A5),
height: 1.5,
),
),
),
const SizedBox(height: 24),
GridView.count(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 1.2,
children: [
_buildInfoCard(
Icons.menu_book,
'Pages',
book.pages?.toString() ?? 'N/A',
Colors.blue,
),
_buildInfoCard(
Icons.language,
'Language',
book.language ?? 'Russian',
Colors.purple,
),
_buildInfoCard(
Icons.calendar_month,
'Published',
book.publishedYear?.toString() ?? 'N/A',
Colors.orange,
),
_buildInfoCard(
Icons.star,
'Rating',
'${book.rating ?? 0}/5',
Colors.amber,
),
],
),
],
),
),
),
],
),
);
}
Widget _buildInfoCard(IconData icon, String label, String value, Color color) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.white.withValues(alpha: 0.05),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: color, size: 18),
),
const SizedBox(height: 4),
Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: Colors.grey.shade500,
letterSpacing: 0.5,
),
),
const SizedBox(height: 2),
Text(
value,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../models/models.dart';
import 'categories_event.dart';
import 'categories_state.dart';
class CategoriesBloc extends Bloc<CategoriesEvent, CategoriesState> {
CategoriesBloc() : super(CategoriesState.initial()) {
on<LoadCategories>(_onLoadCategories);
on<SelectCategory>(_onSelectCategory);
on<SearchCategories>(_onSearchCategories);
}
void _onLoadCategories(LoadCategories event, Emitter<CategoriesState> emit) {
final categories = _getCategories();
emit(state.copyWith(
categories: categories,
filteredCategories: categories,
isLoading: false,
));
}
void _onSelectCategory(SelectCategory event, Emitter<CategoriesState> emit) {
emit(state.copyWith(selectedCategory: event.category));
}
void _onSearchCategories(SearchCategories event, Emitter<CategoriesState> emit) {
final query = event.query.toLowerCase();
final filtered = state.categories.where((category) {
return category.name.toLowerCase().contains(query);
}).toList();
emit(state.copyWith(
searchQuery: event.query,
filteredCategories: filtered,
));
}
List<Category> _getCategories() {
return [
createCategory(
id: '1',
name: 'Classic',
count: 15,
icon: Icons.category,
colorClass: 'category-classic',
),
createCategory(
id: '2',
name: 'Sci-Fi',
count: 8,
icon: Icons.rocket_launch,
colorClass: 'category-scifi',
),
createCategory(
id: '3',
name: 'Fantasy',
count: 12,
icon: Icons.auto_awesome,
colorClass: 'category-fantasy',
),
createCategory(
id: '4',
name: 'Mystery',
count: 6,
icon: Icons.search,
colorClass: 'category-mystery',
),
createCategory(
id: '5',
name: 'Romance',
count: 10,
icon: Icons.favorite,
colorClass: 'category-romance',
),
createCategory(
id: '6',
name: 'Non-fiction',
count: 9,
icon: Icons.book,
colorClass: 'category-nonfiction',
),
createCategory(
id: '7',
name: 'Dystopian',
count: 4,
icon: Icons.nightlife,
colorClass: 'category-dystopian',
),
createCategory(
id: '8',
name: 'Adventure',
count: 7,
icon: Icons.explore,
colorClass: 'category-adventure',
),
];
}
}

View File

@@ -0,0 +1,19 @@
import '../../../models/models.dart';
abstract class CategoriesEvent {
const CategoriesEvent();
}
class LoadCategories extends CategoriesEvent {
const LoadCategories();
}
class SelectCategory extends CategoriesEvent {
final Category category;
const SelectCategory(this.category);
}
class SearchCategories extends CategoriesEvent {
final String query;
const SearchCategories(this.query);
}

View File

@@ -0,0 +1,54 @@
import 'package:equatable/equatable.dart';
import '../../../models/models.dart';
class CategoriesState extends Equatable {
final List<Category> categories;
final List<Category> filteredCategories;
final Category? selectedCategory;
final String searchQuery;
final bool isLoading;
final String? errorMessage;
const CategoriesState({
this.categories = const [],
this.filteredCategories = const [],
this.selectedCategory,
this.searchQuery = '',
this.isLoading = false,
this.errorMessage,
});
factory CategoriesState.initial() {
return const CategoriesState(
isLoading: true,
);
}
CategoriesState copyWith({
List<Category>? categories,
List<Category>? filteredCategories,
Category? selectedCategory,
String? searchQuery,
bool? isLoading,
String? errorMessage,
}) {
return CategoriesState(
categories: categories ?? this.categories,
filteredCategories: filteredCategories ?? this.filteredCategories,
selectedCategory: selectedCategory,
searchQuery: searchQuery ?? this.searchQuery,
isLoading: isLoading ?? this.isLoading,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@override
List<Object?> get props => [
categories,
filteredCategories,
selectedCategory,
searchQuery,
isLoading,
errorMessage,
];
}

View File

@@ -0,0 +1,224 @@
import 'package:flutter/material.dart';
import '../../../models/models.dart';
class CategoriesScreen extends StatelessWidget {
const CategoriesScreen({super.key});
static const List<Category> categories = [
(
id: 'fiction',
name: 'Фантастика',
count: 24,
icon: Icons.rocket_launch,
colorClass: 'bg-indigo-500/20 text-indigo-300',
),
(
id: 'fantasy',
name: 'Фэнтези',
count: 18,
icon: Icons.auto_fix_high,
colorClass: 'bg-purple-500/20 text-purple-300',
),
(
id: 'nonfiction',
name: 'Научпоп',
count: 7,
icon: Icons.psychology,
colorClass: 'bg-teal-500/20 text-teal-300',
),
(
id: 'business',
name: 'Бизнес',
count: 3,
icon: Icons.business_center,
colorClass: 'bg-blue-500/20 text-blue-300',
),
(
id: 'education',
name: 'Учебная',
count: 0,
icon: Icons.school,
colorClass: 'bg-orange-500/20 text-orange-300',
),
(
id: 'classics',
name: 'Классика',
count: 15,
icon: Icons.history_edu,
colorClass: 'bg-amber-500/20 text-amber-300',
),
];
Color _getCategoryColor(String colorClass) {
if (colorClass.contains('indigo')) return Colors.indigo.shade400;
if (colorClass.contains('purple')) return Colors.purple.shade400;
if (colorClass.contains('teal')) return Colors.teal.shade400;
if (colorClass.contains('blue')) return Colors.blue.shade400;
if (colorClass.contains('orange')) return Colors.orange.shade400;
if (colorClass.contains('amber')) return Colors.amber.shade400;
return Colors.grey.shade400;
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF112116),
body: Column(
children: [
_buildHeader(),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: categories.length,
itemBuilder: (context, index) {
final category = categories[index];
return _buildCategoryCard(category);
},
),
),
],
),
);
}
Widget _buildHeader() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: const Color(0xFF112116).withValues(alpha: 0.95),
border: Border(
bottom: BorderSide(color: Colors.white.withValues(alpha: 0.05), width: 1),
),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Изменить',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.6),
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: const Color(0xFF17CF54).withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Icon(
Icons.add,
color: Color(0xFF17CF54),
size: 24,
),
),
],
),
const SizedBox(height: 8),
const Text(
'Категории',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 8),
Container(
height: 40,
decoration: BoxDecoration(
color: const Color(0xFF1A3023),
borderRadius: BorderRadius.circular(8),
),
child: TextField(
style: const TextStyle(color: Colors.white, fontSize: 16),
decoration: InputDecoration(
hintText: 'Поиск жанра...',
hintStyle: TextStyle(
color: Colors.white.withValues(alpha: 0.4),
),
prefixIcon: Icon(
Icons.search,
color: Colors.white.withValues(alpha: 0.4),
size: 20,
),
border: InputBorder.none,
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
),
),
),
],
),
);
}
Widget _buildCategoryCard(Category category) {
return GestureDetector(
onTap: () {
// Navigate to category details
},
child: Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF1A3023),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white.withValues(alpha: 0.05)),
),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: _getCategoryColor(category.colorClass).withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
category.icon,
color: _getCategoryColor(category.colorClass),
size: 24,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
category.name,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
Text(
category.count > 0
? '${category.count} книги'
: 'Нет книг',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Colors.white.withValues(alpha: 0.5),
),
),
],
),
),
Icon(
Icons.chevron_right,
color: Colors.white.withValues(alpha: 0.2),
size: 24,
),
],
),
),
);
}
}

View File

@@ -0,0 +1,22 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../models/models.dart';
import 'home_event.dart';
import 'home_state.dart';
class HomeBloc extends Bloc<HomeEvent, HomeState> {
HomeBloc() : super(HomeState.initial()) {
on<LoadHomeData>(_onLoadHomeData);
on<NavigateToScreen>(_onNavigateToScreen);
}
void _onLoadHomeData(LoadHomeData event, Emitter<HomeState> emit) {
emit(state.copyWith(
currentScreen: AppScreen.library,
isLoading: false,
));
}
void _onNavigateToScreen(NavigateToScreen event, Emitter<HomeState> emit) {
emit(state.copyWith(currentScreen: event.screen));
}
}

View File

@@ -0,0 +1,14 @@
import '../../../models/models.dart';
abstract class HomeEvent {
const HomeEvent();
}
class LoadHomeData extends HomeEvent {
const LoadHomeData();
}
class NavigateToScreen extends HomeEvent {
final AppScreen screen;
const NavigateToScreen(this.screen);
}

View File

@@ -0,0 +1,39 @@
import 'package:equatable/equatable.dart';
import '../../../models/models.dart';
class HomeState extends Equatable {
final AppScreen currentScreen;
final bool isLoading;
final String? errorMessage;
const HomeState({
this.currentScreen = AppScreen.library,
this.isLoading = false,
this.errorMessage,
});
factory HomeState.initial() {
return const HomeState(
isLoading: true,
);
}
HomeState copyWith({
AppScreen? currentScreen,
bool? isLoading,
String? errorMessage,
}) {
return HomeState(
currentScreen: currentScreen ?? this.currentScreen,
isLoading: isLoading ?? this.isLoading,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@override
List<Object?> get props => [
currentScreen,
isLoading,
errorMessage,
];
}

View File

@@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../bloc/app_bloc.dart';
import '../../bloc/app_state.dart';
import '../../../models/models.dart';
import '../library/library_screen.dart';
import '../categories/categories_screen.dart';
import '../book_details/book_details_screen.dart';
import '../add_book/add_book_screen.dart';
import '../scanner/scanner_screen.dart';
import '../wishlist/wishlist_screen.dart';
import '../settings/settings_screen.dart';
import '../../widgets/bottom_nav.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: BlocBuilder<AppBloc, AppState>(
builder: (context, state) {
final currentScreen = state.currentScreen;
final hideNav = [
AppScreen.scanner,
AppScreen.details,
AppScreen.addBook,
].contains(currentScreen);
return Stack(
children: [
_buildScreen(context, state),
if (!hideNav)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: BottomNav(currentScreen: currentScreen),
),
],
);
},
),
);
}
Widget _buildScreen(BuildContext context, AppState state) {
switch (state.currentScreen) {
case AppScreen.library:
return const LibraryScreen();
case AppScreen.categories:
return const CategoriesScreen();
case AppScreen.wishlist:
return const WishlistScreen();
case AppScreen.settings:
return const SettingsScreen();
case AppScreen.details:
if (state.selectedBook == null) return const SizedBox();
return BookDetailsScreen(book: state.selectedBook!);
case AppScreen.addBook:
return AddBookScreen(
initialData: state.selectedBook ?? state.prefilledData,
);
case AppScreen.scanner:
return const ScannerScreen();
}
}
}

View File

@@ -0,0 +1,124 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../models/models.dart';
import 'library_event.dart';
import 'library_state.dart';
class LibraryBloc extends Bloc<LibraryEvent, LibraryState> {
LibraryBloc() : super(LibraryState.initial()) {
on<LoadBooks>(_onLoadBooks);
on<SearchBooks>(_onSearchBooks);
on<BookSelected>(_onBookSelected);
on<FilterByStatus>(_onFilterByStatus);
on<ClearFilters>(_onClearFilters);
}
static List<Book> get _initialBooks => [
createBook(
id: '1',
title: 'Великий Гэтсби',
author: 'Ф. Скотт Фицджеральд',
genre: 'Classic',
annotation:
'История о несбывшейся любви и трагедии американской мечты на фоне бурных двадцатых годов.',
coverUrl: 'https://picsum.photos/seed/gatsby/400/600',
pages: 208,
language: 'English',
publishedYear: 1925,
rating: 4.8,
status: BookStatus.reading,
progress: 45,
isFavorite: true,
),
createBook(
id: '2',
title: '1984',
author: 'Джордж Оруэлл',
genre: 'Dystopian',
annotation:
'Антиутопия о тоталитарном государстве, где мысли контролируются, а правда переменчива.',
coverUrl: 'https://picsum.photos/seed/1984/400/600',
pages: 328,
language: 'English',
publishedYear: 1949,
rating: 4.9,
status: BookStatus.wantToRead,
isFavorite: true,
),
createBook(
id: '3',
title: 'Дюна',
author: 'Фрэнк Герберт',
genre: 'Sci-Fi',
annotation:
'Эпическая сага о борьбе за власть над самой важной планетой во Вселенной.',
coverUrl: 'https://picsum.photos/seed/dune/400/600',
pages: 896,
language: 'English',
publishedYear: 1965,
rating: 4.7,
status: BookStatus.reading,
progress: 12,
isFavorite: false,
),
createBook(
id: '4',
title: 'Хоббит',
author: 'Дж. Р. Р. Толкин',
genre: 'Fantasy',
annotation:
'Путешествие Бильбо Бэггинса туда и обратно в поисках сокровищ гномов.',
coverUrl: 'https://picsum.photos/seed/hobbit/400/600',
pages: 310,
language: 'English',
publishedYear: 1937,
rating: 4.9,
status: BookStatus.done,
isFavorite: false,
),
];
void _onLoadBooks(LoadBooks event, Emitter<LibraryState> emit) {
emit(state.copyWith(
books: _initialBooks,
filteredBooks: _initialBooks,
isLoading: false,
));
}
void _onSearchBooks(SearchBooks event, Emitter<LibraryState> emit) {
final query = event.query.toLowerCase();
final filtered = state.books.where((Book book) {
return book.title.toLowerCase().contains(query) ||
book.author.toLowerCase().contains(query) ||
book.genre.toLowerCase().contains(query);
}).toList();
emit(state.copyWith(
searchQuery: event.query,
filteredBooks: filtered,
));
}
void _onBookSelected(BookSelected event, Emitter<LibraryState> emit) {
emit(state.copyWith(selectedBook: event.book));
}
void _onFilterByStatus(FilterByStatus event, Emitter<LibraryState> emit) {
final filtered = state.books.where((Book book) {
return book.status == event.status;
}).toList();
emit(state.copyWith(
statusFilter: event.status,
filteredBooks: filtered,
));
}
void _onClearFilters(ClearFilters event, Emitter<LibraryState> emit) {
emit(state.copyWith(
searchQuery: '',
statusFilter: null,
filteredBooks: state.books,
));
}
}

View File

@@ -0,0 +1,28 @@
import '../../../models/models.dart';
abstract class LibraryEvent {
const LibraryEvent();
}
class LoadBooks extends LibraryEvent {
const LoadBooks();
}
class SearchBooks extends LibraryEvent {
final String query;
const SearchBooks(this.query);
}
class BookSelected extends LibraryEvent {
final Book book;
const BookSelected(this.book);
}
class FilterByStatus extends LibraryEvent {
final BookStatus status;
const FilterByStatus(this.status);
}
class ClearFilters extends LibraryEvent {
const ClearFilters();
}

View File

@@ -0,0 +1,59 @@
import 'package:equatable/equatable.dart';
import '../../../models/models.dart';
class LibraryState extends Equatable {
final List<Book> books;
final List<Book> filteredBooks;
final Book? selectedBook;
final String searchQuery;
final BookStatus? statusFilter;
final bool isLoading;
final String? errorMessage;
const LibraryState({
this.books = const [],
this.filteredBooks = const [],
this.selectedBook,
this.searchQuery = '',
this.statusFilter,
this.isLoading = false,
this.errorMessage,
});
factory LibraryState.initial() {
return const LibraryState(
isLoading: true,
);
}
LibraryState copyWith({
List<Book>? books,
List<Book>? filteredBooks,
Book? selectedBook,
String? searchQuery,
BookStatus? statusFilter,
bool? isLoading,
String? errorMessage,
}) {
return LibraryState(
books: books ?? this.books,
filteredBooks: filteredBooks ?? this.filteredBooks,
selectedBook: selectedBook,
searchQuery: searchQuery ?? this.searchQuery,
statusFilter: statusFilter ?? this.statusFilter,
isLoading: isLoading ?? this.isLoading,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@override
List<Object?> get props => [
books,
filteredBooks,
selectedBook,
searchQuery,
statusFilter,
isLoading,
errorMessage,
];
}

View File

@@ -0,0 +1,288 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../../bloc/app_bloc.dart';
import '../../bloc/app_event.dart';
import '../../bloc/app_state.dart';
import '../../../models/models.dart';
class LibraryScreen extends StatefulWidget {
const LibraryScreen({super.key});
@override
State<LibraryScreen> createState() => _LibraryScreenState();
}
class _LibraryScreenState extends State<LibraryScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF112116),
body: Column(
children: [
_buildHeader(context),
Expanded(child: _buildBookGrid(context)),
],
),
floatingActionButton: Padding(
padding: const EdgeInsets.only(bottom: 80),
child: FloatingActionButton(
onPressed: () {
context.read<AppBloc>().add(const AddBookClicked());
},
backgroundColor: const Color(0xFF17CF54),
elevation: 8,
child: const Icon(Icons.add, size: 28),
),
),
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
);
}
Widget _buildHeader(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: const Color(0xFF112116).withValues(alpha: 0.95),
border: Border(
bottom: BorderSide(color: Colors.white.withValues(alpha: 0.05), width: 1),
),
),
child: Column(
children: [
Row(
children: [
const SizedBox(width: 40),
Expanded(
child: Text(
'Книжная полка',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
const SizedBox(
width: 40,
child: Icon(
Icons.notifications_outlined,
color: Colors.white,
size: 24,
),
),
],
),
const SizedBox(height: 8),
BlocBuilder<AppBloc, AppState>(
buildWhen: (previous, current) =>
previous.searchQuery != current.searchQuery,
builder: (context, state) {
return Container(
height: 48,
decoration: BoxDecoration(
color: const Color(0xFF244730),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white.withValues(alpha: 0.05)),
),
child: TextField(
onChanged: (value) {
context.read<AppBloc>().add(SearchChanged(value));
},
style: const TextStyle(color: Colors.white, fontSize: 16),
decoration: InputDecoration(
hintText: 'Поиск по названию или автору...',
hintStyle: const TextStyle(
color: Color(0xFF93C8A5),
),
prefixIcon: const Icon(
Icons.search,
color: Color(0xFF93C8A5),
size: 20,
),
border: InputBorder.none,
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
),
);
},
),
],
),
);
}
Widget _buildBookGrid(BuildContext context) {
return BlocBuilder<AppBloc, AppState>(
builder: (context, state) {
final filteredBooks = state.books.where((book) {
final query = state.searchQuery.toLowerCase();
return book.title.toLowerCase().contains(query) ||
book.author.toLowerCase().contains(query);
}).toList();
if (filteredBooks.isEmpty) {
return Center(
child: Text(
'Книги не найдены',
style: TextStyle(
color: const Color(0xFF93C8A5),
fontSize: 16,
),
),
);
}
return GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
childAspectRatio: 0.67,
),
itemCount: filteredBooks.length,
itemBuilder: (context, index) {
final book = filteredBooks[index];
return _buildBookCard(context, book);
},
);
},
);
}
Widget _buildBookCard(BuildContext context, Book book) {
return GestureDetector(
onTap: () {
context.read<AppBloc>().add(BookClicked(book));
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Stack(
children: [
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: const Color(0xFF1A3222),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: CachedNetworkImage(
imageUrl: book.coverUrl ??
'https://picsum.photos/seed/placeholder/400/600',
fit: BoxFit.cover,
width: double.infinity,
placeholder: (context, url) => const Center(
child: CircularProgressIndicator(
color: Color(0xFF17CF54),
),
),
errorWidget: (context, url, error) => Container(
color: const Color(0xFF1A3222),
child: const Icon(
Icons.book_outlined,
size: 48,
color: Color(0xFF93C8A5),
),
),
),
),
),
if (book.status == BookStatus.reading && book.progress != null)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
height: 4,
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.5),
),
child: FractionallySizedBox(
widthFactor: book.progress! / 100,
alignment: Alignment.centerLeft,
child: Container(
color: const Color(0xFF17CF54),
),
),
),
),
if (book.status == BookStatus.done)
Positioned(
top: 8,
left: 8,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: const Color(0xFF17CF54),
borderRadius: BorderRadius.circular(12),
),
child: const Text(
'DONE',
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
),
if (book.isFavorite)
Positioned(
top: 8,
right: 8,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
shape: BoxShape.circle,
),
child: const Icon(
Icons.favorite,
size: 14,
color: Colors.white,
),
),
),
],
),
),
const SizedBox(height: 8),
Text(
book.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
Text(
book.author,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFF93C8A5),
fontSize: 14,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,40 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'scanner_event.dart';
import 'scanner_state.dart';
class ScannerBloc extends Bloc<ScannerEvent, ScannerState> {
ScannerBloc() : super(ScannerState.initial()) {
on<StartScanning>(_onStartScanning);
on<StopScanning>(_onStopScanning);
on<BookDetected>(_onBookDetected);
on<ClearDetectedBook>(_onClearDetectedBook);
}
void _onStartScanning(StartScanning event, Emitter<ScannerState> emit) {
emit(state.copyWith(
isScanning: true,
isProcessing: false,
errorMessage: null,
));
}
void _onStopScanning(StopScanning event, Emitter<ScannerState> emit) {
emit(state.copyWith(isScanning: false));
}
void _onBookDetected(BookDetected event, Emitter<ScannerState> emit) {
emit(state.copyWith(
detectedBookData: event.bookData,
isProcessing: true,
isScanning: false,
));
}
void _onClearDetectedBook(ClearDetectedBook event, Emitter<ScannerState> emit) {
emit(state.copyWith(
isScanning: false,
clearDetectedBookData: true,
isProcessing: false,
));
}
}

View File

@@ -0,0 +1,20 @@
abstract class ScannerEvent {
const ScannerEvent();
}
class StartScanning extends ScannerEvent {
const StartScanning();
}
class StopScanning extends ScannerEvent {
const StopScanning();
}
class BookDetected extends ScannerEvent {
final Map<String, dynamic> bookData;
const BookDetected(this.bookData);
}
class ClearDetectedBook extends ScannerEvent {
const ClearDetectedBook();
}

View File

@@ -0,0 +1,42 @@
import 'package:equatable/equatable.dart';
class ScannerState extends Equatable {
final bool isScanning;
final bool isProcessing;
final Map<String, dynamic>? detectedBookData;
final String? errorMessage;
const ScannerState({
this.isScanning = false,
this.isProcessing = false,
this.detectedBookData,
this.errorMessage,
});
factory ScannerState.initial() {
return const ScannerState();
}
ScannerState copyWith({
bool? isScanning,
bool? isProcessing,
Map<String, dynamic>? detectedBookData,
String? errorMessage,
bool clearDetectedBookData = false,
}) {
return ScannerState(
isScanning: isScanning ?? this.isScanning,
isProcessing: isProcessing ?? this.isProcessing,
detectedBookData: clearDetectedBookData ? null : (detectedBookData ?? this.detectedBookData),
errorMessage: errorMessage ?? this.errorMessage,
);
}
@override
List<Object?> get props => [
isScanning,
isProcessing,
detectedBookData,
errorMessage,
];
}

View File

@@ -0,0 +1,270 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../bloc/app_bloc.dart';
import '../../bloc/app_event.dart';
import '../../../models/models.dart';
class ScannerScreen extends StatelessWidget {
const ScannerScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
// Camera placeholder
Container(
color: Colors.black87,
width: double.infinity,
height: double.infinity,
),
// Header
Positioned(
top: 48,
left: 0,
right: 0,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
onPressed: () {
context
.read<AppBloc>()
.add(const ScreenChanged(AppScreen.addBook));
},
icon: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.4),
shape: BoxShape.circle,
),
child: const Icon(Icons.close, color: Colors.white, size: 24),
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
),
),
child: const Text(
'СКАНЕР',
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w500,
letterSpacing: 1,
),
),
),
const SizedBox(width: 40),
],
),
),
),
// Scanner frame
Center(
child: Container(
width: MediaQuery.of(context).size.width * 0.75,
height: MediaQuery.of(context).size.width * 0.75 * 1.5,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Colors.white.withValues(alpha: 0.2),
width: 2,
),
),
child: Stack(
children: [
// Corner accents - top left
Positioned(
top: -2,
left: -2,
child: ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(8),
),
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
border: Border(
left: const BorderSide(color: Color(0xFF17CF54), width: 4),
top: const BorderSide(color: Color(0xFF17CF54), width: 4),
right: const BorderSide(color: Colors.transparent, width: 4),
bottom: const BorderSide(color: Colors.transparent, width: 4),
),
),
),
),
),
// Corner accents - top right
Positioned(
top: -2,
right: -2,
child: ClipRRect(
borderRadius: const BorderRadius.only(
topRight: Radius.circular(8),
),
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
border: Border(
right: const BorderSide(color: Color(0xFF17CF54), width: 4),
top: const BorderSide(color: Color(0xFF17CF54), width: 4),
left: const BorderSide(color: Colors.transparent, width: 4),
bottom: const BorderSide(color: Colors.transparent, width: 4),
),
),
),
),
),
// Corner accents - bottom left
Positioned(
bottom: -2,
left: -2,
child: ClipRRect(
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(8),
),
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
border: Border(
left: const BorderSide(color: Color(0xFF17CF54), width: 4),
bottom: const BorderSide(color: Color(0xFF17CF54), width: 4),
right: const BorderSide(color: Colors.transparent, width: 4),
top: const BorderSide(color: Colors.transparent, width: 4),
),
),
),
),
),
// Corner accents - bottom right
Positioned(
bottom: -2,
right: -2,
child: ClipRRect(
borderRadius: const BorderRadius.only(
bottomRight: Radius.circular(8),
),
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
border: Border(
right: const BorderSide(color: Color(0xFF17CF54), width: 4),
bottom: const BorderSide(color: Color(0xFF17CF54), width: 4),
left: const BorderSide(color: Colors.transparent, width: 4),
top: const BorderSide(color: Colors.transparent, width: 4),
),
),
),
),
),
// Scan line animation
Center(
child: Container(
height: 2,
width: double.infinity,
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: const Color(0xFF17CF54),
boxShadow: [
BoxShadow(
color: const Color(0xFF17CF54).withValues(alpha: 0.8),
blurRadius: 15,
spreadRadius: 2,
),
],
),
),
),
],
),
),
),
// Instructions
Positioned(
bottom: 120,
left: 0,
right: 0,
child: Column(
children: [
const Text(
'Поместите обложку в рамку',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 16),
const Text(
'Камера будет добавлена в будущих обновлениях',
style: TextStyle(
color: Color(0xFF17CF54),
fontSize: 14,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
],
),
),
// Capture button
Positioned(
bottom: 40,
left: 0,
right: 0,
child: Center(
child: GestureDetector(
onTap: () {
// Simulate detection
context.read<AppBloc>().add(
BookDetected({
'title': 'Новая книга',
'author': 'Неизвестный автор',
'genre': 'Неизвестный жанр',
'annotation': 'Добавьте описание книги',
}),
);
},
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
border: Border.all(
color: Colors.white.withValues(alpha: 0.3),
width: 4,
),
boxShadow: [
BoxShadow(
color: Colors.white.withValues(alpha: 0.3),
blurRadius: 20,
spreadRadius: 2,
),
],
),
),
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,39 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'settings_event.dart';
import 'settings_state.dart';
class SettingsBloc extends Bloc<SettingsEvent, SettingsState> {
SettingsBloc() : super(SettingsState.initial()) {
on<LoadSettings>(_onLoadSettings);
on<UpdateTheme>(_onUpdateTheme);
on<UpdateLanguage>(_onUpdateLanguage);
on<ToggleNotifications>(_onToggleNotifications);
on<ClearData>(_onClearData);
}
void _onLoadSettings(LoadSettings event, Emitter<SettingsState> emit) {
emit(state.copyWith(
isDarkMode: false,
language: 'English',
notificationsEnabled: true,
isLoading: false,
));
}
void _onUpdateTheme(UpdateTheme event, Emitter<SettingsState> emit) {
emit(state.copyWith(isDarkMode: event.isDarkMode));
}
void _onUpdateLanguage(UpdateLanguage event, Emitter<SettingsState> emit) {
emit(state.copyWith(language: event.language));
}
void _onToggleNotifications(
ToggleNotifications event, Emitter<SettingsState> emit) {
emit(state.copyWith(notificationsEnabled: !state.notificationsEnabled));
}
void _onClearData(ClearData event, Emitter<SettingsState> emit) {
emit(state.copyWith(dataCleared: true));
}
}

View File

@@ -0,0 +1,25 @@
abstract class SettingsEvent {
const SettingsEvent();
}
class LoadSettings extends SettingsEvent {
const LoadSettings();
}
class UpdateTheme extends SettingsEvent {
final bool isDarkMode;
const UpdateTheme(this.isDarkMode);
}
class UpdateLanguage extends SettingsEvent {
final String language;
const UpdateLanguage(this.language);
}
class ToggleNotifications extends SettingsEvent {
const ToggleNotifications();
}
class ClearData extends SettingsEvent {
const ClearData();
}

View File

@@ -0,0 +1,53 @@
import 'package:equatable/equatable.dart';
class SettingsState extends Equatable {
final bool isDarkMode;
final String language;
final bool notificationsEnabled;
final bool dataCleared;
final bool isLoading;
final String? errorMessage;
const SettingsState({
this.isDarkMode = false,
this.language = 'English',
this.notificationsEnabled = true,
this.dataCleared = false,
this.isLoading = false,
this.errorMessage,
});
factory SettingsState.initial() {
return const SettingsState(
isLoading: true,
);
}
SettingsState copyWith({
bool? isDarkMode,
String? language,
bool? notificationsEnabled,
bool? dataCleared,
bool? isLoading,
String? errorMessage,
}) {
return SettingsState(
isDarkMode: isDarkMode ?? this.isDarkMode,
language: language ?? this.language,
notificationsEnabled: notificationsEnabled ?? this.notificationsEnabled,
dataCleared: dataCleared ?? this.dataCleared,
isLoading: isLoading ?? this.isLoading,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@override
List<Object?> get props => [
isDarkMode,
language,
notificationsEnabled,
dataCleared,
isLoading,
errorMessage,
];
}

View File

@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF112116),
body: Center(
child: Padding(
padding: const EdgeInsets.all(40),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'Настройки',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 8),
Text(
'Персонализируйте ваше приложение.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: const Color(0xFF93C8A5),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,93 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../models/models.dart';
import 'wishlist_event.dart';
import 'wishlist_state.dart';
class WishlistBloc extends Bloc<WishlistEvent, WishlistState> {
WishlistBloc() : super(WishlistState.initial()) {
on<LoadWishlist>(_onLoadWishlist);
on<RemoveFromWishlist>(_onRemoveFromWishlist);
on<SearchWishlist>(_onSearchWishlist);
on<MoveToLibrary>(_onMoveToLibrary);
}
static List<Book> get _initialBooks => [
createBook(
id: '2',
title: '1984',
author: 'Джордж Оруэлл',
genre: 'Dystopian',
annotation:
'Антиутопия о тоталитарном государстве, где мысли контролируются, а правда переменчива.',
coverUrl: 'https://picsum.photos/seed/1984/400/600',
pages: 328,
language: 'English',
publishedYear: 1949,
rating: 4.9,
status: BookStatus.wantToRead,
isFavorite: true,
),
];
void _onLoadWishlist(LoadWishlist event, Emitter<WishlistState> emit) {
final wishlistBooks = _initialBooks.where((book) {
return book.status == BookStatus.wantToRead || book.isFavorite;
}).toList();
emit(state.copyWith(
books: wishlistBooks,
filteredBooks: wishlistBooks,
isLoading: false,
));
}
void _onRemoveFromWishlist(RemoveFromWishlist event, Emitter<WishlistState> emit) {
final updatedBooks = state.books.where((book) => book.id != event.bookId).toList();
final updatedFiltered = state.filteredBooks.where((book) => book.id != event.bookId).toList();
emit(state.copyWith(
books: updatedBooks,
filteredBooks: updatedFiltered,
));
}
void _onSearchWishlist(SearchWishlist event, Emitter<WishlistState> emit) {
final query = event.query.toLowerCase();
final filtered = state.books.where((book) {
return book.title.toLowerCase().contains(query) ||
book.author.toLowerCase().contains(query) ||
book.genre.toLowerCase().contains(query);
}).toList();
emit(state.copyWith(
searchQuery: event.query,
filteredBooks: filtered,
));
}
void _onMoveToLibrary(MoveToLibrary event, Emitter<WishlistState> emit) {
final updatedBooks = state.books.map((book) {
if (book.id == event.bookId) {
return book.copyWith(
status: BookStatus.reading,
);
}
return book;
}).toList();
final updatedFiltered = state.filteredBooks.map((book) {
if (book.id == event.bookId) {
return book.copyWith(
status: BookStatus.reading,
);
}
return book;
}).toList();
emit(state.copyWith(
books: updatedBooks,
filteredBooks: updatedFiltered,
movedBookId: event.bookId,
));
}
}

View File

@@ -0,0 +1,22 @@
abstract class WishlistEvent {
const WishlistEvent();
}
class LoadWishlist extends WishlistEvent {
const LoadWishlist();
}
class RemoveFromWishlist extends WishlistEvent {
final String bookId;
const RemoveFromWishlist(this.bookId);
}
class SearchWishlist extends WishlistEvent {
final String query;
const SearchWishlist(this.query);
}
class MoveToLibrary extends WishlistEvent {
final String bookId;
const MoveToLibrary(this.bookId);
}

View File

@@ -0,0 +1,54 @@
import 'package:equatable/equatable.dart';
import '../../../models/models.dart';
class WishlistState extends Equatable {
final List<Book> books;
final List<Book> filteredBooks;
final String searchQuery;
final String? movedBookId;
final bool isLoading;
final String? errorMessage;
const WishlistState({
this.books = const [],
this.filteredBooks = const [],
this.searchQuery = '',
this.movedBookId,
this.isLoading = false,
this.errorMessage,
});
factory WishlistState.initial() {
return const WishlistState(
isLoading: true,
);
}
WishlistState copyWith({
List<Book>? books,
List<Book>? filteredBooks,
String? searchQuery,
String? movedBookId,
bool? isLoading,
String? errorMessage,
}) {
return WishlistState(
books: books ?? this.books,
filteredBooks: filteredBooks ?? this.filteredBooks,
searchQuery: searchQuery ?? this.searchQuery,
movedBookId: movedBookId,
isLoading: isLoading ?? this.isLoading,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@override
List<Object?> get props => [
books,
filteredBooks,
searchQuery,
movedBookId,
isLoading,
errorMessage,
];
}

View File

@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
class WishlistScreen extends StatelessWidget {
const WishlistScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF112116),
body: Center(
child: Padding(
padding: const EdgeInsets.all(40),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'Вишлист',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 8),
Text(
'Здесь будут книги, которые вы хотите прочитать.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: const Color(0xFF93C8A5),
),
),
],
),
),
),
);
}
}