refactor: create separate BLoCs for each screen with comprehensive tests

- Created 8 separate BLoCs (Home, Library, BookDetails, AddBook, Scanner, Categories, Wishlist, Settings)
- Each BLoC has its own event, state, and bloc files
- Added 70 comprehensive tests covering all BLoC functionality
- All tests passing (70/70)
- Fixed linting issues and updated deprecated APIs
- Improved code organization and maintainability
This commit is contained in:
Yuriy Panov
2026-02-04 14:40:00 +06:00
parent 3004f712f3
commit 310463e89a
177 changed files with 9718 additions and 0 deletions

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,179 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../models/models.dart';
import 'app_event.dart';
import 'app_state.dart';
class AppBloc extends Bloc<AppEvent, AppState> {
AppBloc() : super(AppState(books: _initialBooks)) {
on<ScreenChanged>(_onScreenChanged);
on<BookClicked>(_onBookClicked);
on<AddBookClicked>(_onAddBookClicked);
on<BookSaved>(_onBookSaved);
on<BookDeleted>(_onBookDeleted);
on<BookDetected>(_onBookDetected);
on<SearchChanged>(_onSearchChanged);
}
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 _onScreenChanged(ScreenChanged event, Emitter<AppState> emit) {
emit(state.copyWith(currentScreen: event.screen));
}
void _onBookClicked(BookClicked event, Emitter<AppState> emit) {
emit(state.copyWith(
selectedBook: event.book,
currentScreen: AppScreen.details,
));
}
void _onAddBookClicked(AddBookClicked event, Emitter<AppState> emit) {
emit(state.copyWith(
prefilledData: null,
selectedBook: null,
currentScreen: AppScreen.addBook,
));
}
void _onBookSaved(BookSaved event, Emitter<AppState> emit) {
final bookData = event.bookData;
if (state.selectedBook != null) {
// Edit existing book
final updatedBooks = state.books.map((book) {
if (book.id == state.selectedBook!.id) {
return book.copyWith(
title: bookData['title'] as String? ?? book.title,
author: bookData['author'] as String? ?? book.author,
genre: bookData['genre'] as String? ?? book.genre,
annotation: bookData['annotation'] as String? ?? book.annotation,
coverUrl: bookData['coverUrl'] as String?,
pages: bookData['pages'] as int?,
language: bookData['language'] as String?,
publishedYear: bookData['publishedYear'] as int?,
rating: bookData['rating'] as double?,
status: bookData['status'] as BookStatus? ?? book.status,
progress: bookData['progress'] as int?,
isFavorite: bookData['isFavorite'] as bool? ?? book.isFavorite,
);
}
return book;
}).toList();
emit(state.copyWith(
books: updatedBooks,
currentScreen: AppScreen.library,
selectedBook: null,
prefilledData: null,
));
} else {
// Add new book
final newBook = createBook(
id: DateTime.now().millisecondsSinceEpoch.toString(),
title: bookData['title'] as String? ?? 'Unknown',
author: bookData['author'] as String? ?? 'Unknown',
genre: bookData['genre'] as String? ?? 'Unknown',
annotation: bookData['annotation'] as String? ?? '',
coverUrl: bookData['coverUrl'] as String? ??
'https://picsum.photos/seed/${DateTime.now().millisecondsSinceEpoch}/400/600',
pages: bookData['pages'] as int?,
language: bookData['language'] as String?,
publishedYear: bookData['publishedYear'] as int?,
rating: bookData['rating'] as double?,
status: bookData['status'] as BookStatus? ?? BookStatus.wantToRead,
progress: bookData['progress'] as int?,
isFavorite: bookData['isFavorite'] as bool? ?? false,
);
emit(state.copyWith(
books: [...state.books, newBook],
currentScreen: AppScreen.library,
selectedBook: null,
prefilledData: null,
));
}
}
void _onBookDeleted(BookDeleted event, Emitter<AppState> emit) {
final updatedBooks = state.books.where((book) => book.id != event.id).toList();
emit(state.copyWith(
books: updatedBooks,
currentScreen: AppScreen.library,
selectedBook: null,
));
}
void _onBookDetected(BookDetected event, Emitter<AppState> emit) {
emit(state.copyWith(
prefilledData: event.bookData,
currentScreen: AppScreen.addBook,
));
}
void _onSearchChanged(SearchChanged event, Emitter<AppState> emit) {
emit(state.copyWith(searchQuery: event.query));
}
}

View File

@@ -0,0 +1,39 @@
import '../models/models.dart';
abstract class AppEvent {
const AppEvent();
}
class ScreenChanged extends AppEvent {
final AppScreen screen;
const ScreenChanged(this.screen);
}
class BookClicked extends AppEvent {
final Book book;
const BookClicked(this.book);
}
class AddBookClicked extends AppEvent {
const AddBookClicked();
}
class BookSaved extends AppEvent {
final Map<String, dynamic> bookData;
const BookSaved(this.bookData);
}
class BookDeleted extends AppEvent {
final String id;
const BookDeleted(this.id);
}
class BookDetected extends AppEvent {
final Map<String, dynamic> bookData;
const BookDetected(this.bookData);
}
class SearchChanged extends AppEvent {
final String query;
const SearchChanged(this.query);
}

View File

@@ -0,0 +1,53 @@
import 'package:equatable/equatable.dart';
import '../models/models.dart';
class AppState extends Equatable {
final AppScreen currentScreen;
final List<Book> books;
final Book? selectedBook;
final Map<String, dynamic>? prefilledData;
final String searchQuery;
final bool isLoading;
final String? errorMessage;
const AppState({
this.currentScreen = AppScreen.library,
this.books = const [],
this.selectedBook,
this.prefilledData,
this.searchQuery = '',
this.isLoading = false,
this.errorMessage,
});
AppState copyWith({
AppScreen? currentScreen,
List<Book>? books,
Book? selectedBook,
Map<String, dynamic>? prefilledData,
String? searchQuery,
bool? isLoading,
String? errorMessage,
}) {
return AppState(
currentScreen: currentScreen ?? this.currentScreen,
books: books ?? this.books,
selectedBook: selectedBook,
prefilledData: prefilledData ?? this.prefilledData,
searchQuery: searchQuery ?? this.searchQuery,
isLoading: isLoading ?? this.isLoading,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@override
List<Object?> get props => [
currentScreen,
books,
selectedBook,
prefilledData,
searchQuery,
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,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,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,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) {
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) {
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,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,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,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,43 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'bloc/app_bloc.dart';
import 'screens/home_screen.dart';
void main() {
runApp(const BookshelfApp());
}
class BookshelfApp extends StatelessWidget {
const BookshelfApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Книжная полка',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
scaffoldBackgroundColor: const Color(0xFF112116),
colorScheme: ColorScheme.dark(
primary: const Color(0xFF17CF54),
surface: const Color(0xFF1A3222),
surfaceContainer: const Color(0xFF244730),
onSurface: Colors.white,
onSurfaceVariant: const Color(0xFF93C8A5),
),
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xFF112116),
elevation: 0,
centerTitle: true,
),
cardColor: const Color(0xFF1A3023),
fontFamily: 'Inter',
),
home: BlocProvider(
create: (context) => AppBloc(),
child: const HomeScreen(),
),
);
}
}

View File

@@ -0,0 +1,185 @@
import 'package:flutter/material.dart';
// App screen enum
enum AppScreen {
library,
categories,
wishlist,
settings,
details,
addBook,
scanner,
}
// Book status enum
enum BookStatus {
reading,
done,
wantToRead,
}
// Extension for BookStatus to get display name
extension BookStatusExtension on BookStatus {
String get displayName {
switch (this) {
case BookStatus.reading:
return 'Reading Now';
case BookStatus.done:
return 'Completed';
case BookStatus.wantToRead:
return 'Wishlist';
}
}
}
// Category record - using Dart records as data classes
typedef Category = ({
String id,
String name,
int count,
IconData icon,
String colorClass,
});
// Book record - using Dart records as data classes
typedef Book = ({
String id,
String title,
String author,
String genre,
String annotation,
String? coverUrl,
int? pages,
String? language,
int? publishedYear,
double? rating,
BookStatus status,
int? progress,
bool isFavorite,
});
// Helper function to create a Category record
Category createCategory({
required String id,
required String name,
required int count,
required IconData icon,
required String colorClass,
}) {
return (
id: id,
name: name,
count: count,
icon: icon,
colorClass: colorClass,
);
}
// Helper function to create a Book record
Book createBook({
required String id,
required String title,
required String author,
required String genre,
required String annotation,
String? coverUrl,
int? pages,
String? language,
int? publishedYear,
double? rating,
required BookStatus status,
int? progress,
required bool isFavorite,
}) {
return (
id: id,
title: title,
author: author,
genre: genre,
annotation: annotation,
coverUrl: coverUrl,
pages: pages,
language: language,
publishedYear: publishedYear,
rating: rating,
status: status,
progress: progress,
isFavorite: isFavorite,
);
}
// Extension to create Book from partial data
extension BookExtension on Book {
Book copyWith({
String? id,
String? title,
String? author,
String? genre,
String? annotation,
String? coverUrl,
int? pages,
String? language,
int? publishedYear,
double? rating,
BookStatus? status,
int? progress,
bool? isFavorite,
}) {
return createBook(
id: id ?? this.id,
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,
);
}
// Convert to JSON for storage/transport
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'author': author,
'genre': genre,
'annotation': annotation,
'coverUrl': coverUrl,
'pages': pages,
'language': language,
'publishedYear': publishedYear,
'rating': rating,
'status': status.name,
'progress': progress,
'isFavorite': isFavorite,
};
}
// Create Book from JSON
static Book fromJson(Map<String, dynamic> json) {
return (
id: json['id'] as String,
title: json['title'] as String,
author: json['author'] as String,
genre: json['genre'] as String,
annotation: json['annotation'] as String,
coverUrl: json['coverUrl'] as String?,
pages: json['pages'] as int?,
language: json['language'] as String?,
publishedYear: json['publishedYear'] as int?,
rating: (json['rating'] as num?)?.toDouble(),
status: BookStatus.values.firstWhere(
(e) => e.name == json['status'],
orElse: () => BookStatus.wantToRead,
),
progress: json['progress'] as int?,
isFavorite: json['isFavorite'] as bool? ?? false,
);
}
}

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,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,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,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_screen.dart';
import 'categories_screen.dart';
import 'book_details_screen.dart';
import 'add_book_screen.dart';
import 'scanner_screen.dart';
import 'wishlist_screen.dart';
import '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,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,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/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,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),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,71 @@
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 BottomNav extends StatelessWidget {
final AppScreen currentScreen;
const BottomNav({super.key, required this.currentScreen});
@override
Widget build(BuildContext context) {
final navItems = [
(id: AppScreen.library, label: 'Полка', icon: Icons.menu_book),
(id: AppScreen.categories, label: 'Категории', icon: Icons.category),
(id: AppScreen.wishlist, label: 'Вишлист', icon: Icons.bookmark),
(id: AppScreen.settings, label: 'Настройки', icon: Icons.settings),
];
return Container(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.85),
border: Border(
top: BorderSide(color: Colors.white.withValues(alpha: 0.05), width: 1),
),
),
child: SafeArea(
child: SizedBox(
height: 64,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: navItems.map((item) {
final isSelected = currentScreen == item.id;
return Expanded(
child: GestureDetector(
onTap: () {
context.read<AppBloc>().add(ScreenChanged(item.id));
},
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
item.icon,
color: isSelected
? const Color(0xFF17CF54)
: Colors.grey.withValues(alpha: 0.6),
size: 24,
),
const SizedBox(height: 2),
Text(
item.label,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: isSelected
? const Color(0xFF17CF54)
: Colors.grey.withValues(alpha: 0.6),
),
),
],
),
),
);
}).toList(),
),
),
),
);
}
}