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:
320
bookshelf_flutter/lib/features/add_book/add_book_screen.dart
Normal file
320
bookshelf_flutter/lib/features/add_book/add_book_screen.dart
Normal 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)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
110
bookshelf_flutter/lib/features/add_book/bloc/add_book_bloc.dart
Normal file
110
bookshelf_flutter/lib/features/add_book/bloc/add_book_bloc.dart
Normal 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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
109
bookshelf_flutter/lib/features/add_book/bloc/add_book_state.dart
Normal file
109
bookshelf_flutter/lib/features/add_book/bloc/add_book_state.dart
Normal 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,
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user