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,
];
}