openai service
This commit is contained in:
@@ -1,42 +1,44 @@
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../bloc/book_bloc.dart';
|
||||
import '../bloc/navigation_bloc.dart';
|
||||
import '../bloc/book/book_bloc.dart';
|
||||
import '../bloc/book/book_event.dart';
|
||||
import '../bloc/add_book/add_book_bloc.dart';
|
||||
import '../bloc/add_book/add_book_event.dart';
|
||||
import '../bloc/add_book/add_book_state.dart';
|
||||
import '../config/api_config.dart';
|
||||
import '../models/models.dart';
|
||||
import '../theme/app_spacing.dart';
|
||||
import 'scanner_screen.dart';
|
||||
|
||||
class AddBookScreen extends StatefulWidget {
|
||||
const AddBookScreen({super.key});
|
||||
class AddBookScreen extends StatelessWidget {
|
||||
final Book? editBook;
|
||||
final Book? prefilledData;
|
||||
|
||||
const AddBookScreen({super.key, this.editBook, this.prefilledData});
|
||||
|
||||
@override
|
||||
State<AddBookScreen> createState() => _AddBookScreenState();
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => AddBookBloc(
|
||||
onAddBook: (book) => context.read<BookBloc>().add(AddBook(book)),
|
||||
onUpdateBook: (book) => context.read<BookBloc>().add(UpdateBook(book)),
|
||||
)..add(InitializeForm(editBook: editBook, prefilledData: prefilledData)),
|
||||
child: const _AddBookScreenContent(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AddBookScreenState extends State<AddBookScreen> {
|
||||
class _AddBookScreenContent extends StatefulWidget {
|
||||
const _AddBookScreenContent();
|
||||
|
||||
@override
|
||||
State<_AddBookScreenContent> createState() => _AddBookScreenContentState();
|
||||
}
|
||||
|
||||
class _AddBookScreenContentState extends State<_AddBookScreenContent> {
|
||||
final _titleController = TextEditingController();
|
||||
final _authorController = TextEditingController();
|
||||
final _annotationController = TextEditingController();
|
||||
String _genre = 'fiction';
|
||||
bool _initialized = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (!_initialized) {
|
||||
_initialized = true;
|
||||
final navState = context.read<NavigationBloc>().state;
|
||||
final book = navState.selectedBook;
|
||||
final prefilled = navState.prefilledData;
|
||||
final source = book ?? prefilled;
|
||||
if (source != null) {
|
||||
_titleController.text = source.title;
|
||||
_authorController.text = source.author;
|
||||
_annotationController.text = source.annotation;
|
||||
_genre = source.genre.isNotEmpty ? source.genre : 'fiction';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -50,206 +52,244 @@ class _AddBookScreenState extends State<AddBookScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final navState = context.read<NavigationBloc>().state;
|
||||
final isEditing =
|
||||
navState.selectedBook != null && navState.prefilledData == null;
|
||||
final title = isEditing ? 'Редактировать' : 'Добавить книгу';
|
||||
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.sm,
|
||||
AppSpacing.sm,
|
||||
AppSpacing.sm,
|
||||
0,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.read<NavigationBloc>().add(
|
||||
isEditing
|
||||
? NavigateTo(AppScreen.details)
|
||||
: NavigateTo(AppScreen.library),
|
||||
),
|
||||
),
|
||||
Text(title, style: textTheme.headlineMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
children: [
|
||||
// Cover placeholder / scanner trigger
|
||||
GestureDetector(
|
||||
onTap: () => context.read<NavigationBloc>().add(
|
||||
NavigateTo(AppScreen.scanner),
|
||||
),
|
||||
child: Container(
|
||||
height: 160,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: colorScheme.outline),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppSpacing.radiusMedium,
|
||||
),
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
return BlocListener<AddBookBloc, AddBookState>(
|
||||
listener: (context, state) {
|
||||
// Update controllers when state changes (e.g., from scanned book)
|
||||
if (_titleController.text != state.title) {
|
||||
_titleController.text = state.title;
|
||||
}
|
||||
if (_authorController.text != state.author) {
|
||||
_authorController.text = state.author;
|
||||
}
|
||||
if (_annotationController.text != state.annotation) {
|
||||
_annotationController.text = state.annotation;
|
||||
}
|
||||
|
||||
// Navigate back when saved
|
||||
if (state.isSaved) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
child: BlocBuilder<AddBookBloc, AddBookState>(
|
||||
builder: (context, state) {
|
||||
final title = state.isEditing ? 'Редактировать' : 'Добавить книгу';
|
||||
|
||||
return Material(
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.sm,
|
||||
AppSpacing.sm,
|
||||
AppSpacing.sm,
|
||||
0,
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.camera_alt,
|
||||
size: 40,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text(
|
||||
'Загрузить или отсканировать',
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(
|
||||
alpha: 0.6,
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
Text(title, style: textTheme.headlineMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
children: [
|
||||
// Cover placeholder / scanner trigger
|
||||
GestureDetector(
|
||||
onTap: () => _openScanner(context),
|
||||
child: Container(
|
||||
height: 160,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: colorScheme.outline),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppSpacing.radiusMedium,
|
||||
),
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.camera_alt,
|
||||
size: 40,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text(
|
||||
'Загрузить или отсканировать',
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(
|
||||
alpha: 0.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
_field(
|
||||
context,
|
||||
'Название',
|
||||
_titleController,
|
||||
textTheme,
|
||||
(value) =>
|
||||
context.read<AddBookBloc>().add(UpdateTitle(value)),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_field(
|
||||
context,
|
||||
'Автор',
|
||||
_authorController,
|
||||
textTheme,
|
||||
(value) => context.read<AddBookBloc>().add(
|
||||
UpdateAuthor(value),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
// Genre dropdown
|
||||
Text('Жанр', style: textTheme.labelMedium),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
DropdownButtonFormField<String>(
|
||||
value: state.genre,
|
||||
dropdownColor: colorScheme.surface,
|
||||
decoration: const InputDecoration(),
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: 'fiction',
|
||||
child: Text('Фантастика'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'fantasy',
|
||||
child: Text('Фэнтези'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'science',
|
||||
child: Text('Научпоп'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'biography',
|
||||
child: Text('Биография'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'detective',
|
||||
child: Text('Детектив'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'other',
|
||||
child: Text('Другое'),
|
||||
),
|
||||
],
|
||||
onChanged: (v) {
|
||||
if (v != null) {
|
||||
context.read<AddBookBloc>().add(UpdateGenre(v));
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Text('Аннотация', style: textTheme.labelMedium),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
TextField(
|
||||
controller: _annotationController,
|
||||
maxLines: 4,
|
||||
onChanged: (value) => context.read<AddBookBloc>().add(
|
||||
UpdateAnnotation(value),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 100),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Bottom actions
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.screenPadding,
|
||||
vertical: AppSpacing.md,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
border: Border(
|
||||
top: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colorScheme.shadow,
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: ElevatedButton(
|
||||
onPressed: () =>
|
||||
context.read<AddBookBloc>().add(SaveBook()),
|
||||
child: const Text('Сохранить'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
_field('Название', _titleController, textTheme),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_field('Автор', _authorController, textTheme),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
// Genre dropdown
|
||||
Text('Жанр', style: textTheme.labelMedium),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _genre,
|
||||
dropdownColor: colorScheme.surface,
|
||||
decoration: const InputDecoration(),
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: 'fiction',
|
||||
child: Text('Фантастика'),
|
||||
),
|
||||
DropdownMenuItem(value: 'fantasy', child: Text('Фэнтези')),
|
||||
DropdownMenuItem(value: 'science', child: Text('Научпоп')),
|
||||
DropdownMenuItem(
|
||||
value: 'biography',
|
||||
child: Text('Биография'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'detective',
|
||||
child: Text('Детектив'),
|
||||
),
|
||||
DropdownMenuItem(value: 'other', child: Text('Другое')),
|
||||
],
|
||||
onChanged: (v) => setState(() => _genre = v ?? _genre),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Text('Аннотация', style: textTheme.labelMedium),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
TextField(controller: _annotationController, maxLines: 4),
|
||||
const SizedBox(height: 100),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Bottom actions
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.screenPadding,
|
||||
vertical: AppSpacing.md,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
border: Border(
|
||||
top: BorderSide(color: colorScheme.outlineVariant),
|
||||
],
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colorScheme.shadow,
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => context.read<NavigationBloc>().add(
|
||||
isEditing
|
||||
? NavigateTo(AppScreen.details)
|
||||
: NavigateTo(AppScreen.library),
|
||||
),
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: ElevatedButton(
|
||||
onPressed: _save,
|
||||
child: const Text('Сохранить'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _field(
|
||||
BuildContext context,
|
||||
String label,
|
||||
TextEditingController controller,
|
||||
TextTheme textTheme,
|
||||
void Function(String) onChanged,
|
||||
) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: textTheme.labelMedium),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
TextField(controller: controller),
|
||||
TextField(controller: controller, onChanged: onChanged),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _save() {
|
||||
final navState = context.read<NavigationBloc>().state;
|
||||
final existing = navState.selectedBook;
|
||||
final isEditing = existing != null && navState.prefilledData == null;
|
||||
Future<void> _openScanner(BuildContext context) async {
|
||||
if (!context.mounted) return;
|
||||
|
||||
final Book book = (
|
||||
id: isEditing ? existing.id : '${Random().nextInt(100000)}',
|
||||
title: _titleController.text,
|
||||
author: _authorController.text,
|
||||
genre: _genre,
|
||||
annotation: _annotationController.text,
|
||||
coverUrl: isEditing
|
||||
? existing.coverUrl
|
||||
: 'https://picsum.photos/seed/newbook/400/600',
|
||||
pages: isEditing ? existing.pages : 0,
|
||||
language: isEditing ? existing.language : 'Russian',
|
||||
publishedYear: isEditing ? existing.publishedYear : DateTime.now().year,
|
||||
rating: isEditing ? existing.rating : 5.0,
|
||||
status: isEditing ? existing.status : 'want_to_read',
|
||||
progress: isEditing ? existing.progress : null,
|
||||
isFavorite: isEditing ? existing.isFavorite : false,
|
||||
);
|
||||
final scannedBook = await Navigator.of(context, rootNavigator: true)
|
||||
.push<Book>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ScannerScreen(
|
||||
geminiApiKey: ApiConfig.geminiApiKey,
|
||||
openaiApiKey: ApiConfig.openaiApiKey,
|
||||
openaiBaseUrl: ApiConfig.openaiBaseUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (isEditing) {
|
||||
context.read<BookBloc>().add(UpdateBook(book));
|
||||
} else {
|
||||
context.read<BookBloc>().add(AddBook(book));
|
||||
if (scannedBook != null && context.mounted) {
|
||||
context.read<AddBookBloc>().add(ApplyScannedBook(scannedBook));
|
||||
}
|
||||
context.read<NavigationBloc>().add(NavigateTo(AppScreen.library));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,262 +1,250 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../bloc/book_bloc.dart';
|
||||
import '../bloc/navigation_bloc.dart';
|
||||
import '../bloc/book/book_bloc.dart';
|
||||
import '../bloc/book/book_event.dart';
|
||||
import '../models/models.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../theme/app_spacing.dart';
|
||||
import 'add_book_screen.dart';
|
||||
|
||||
class BookDetailsScreen extends StatelessWidget {
|
||||
const BookDetailsScreen({super.key});
|
||||
final Book book;
|
||||
|
||||
const BookDetailsScreen({super.key, required this.book});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return BlocBuilder<NavigationBloc, NavigationState>(
|
||||
builder: (context, navState) {
|
||||
final book = navState.selectedBook;
|
||||
if (book == null) return const SizedBox.shrink();
|
||||
final statusLabel = switch (book.status) {
|
||||
'reading' => 'Читаю',
|
||||
'done' => 'Прочитано',
|
||||
'want_to_read' => 'Хочу прочитать',
|
||||
_ => book.status,
|
||||
};
|
||||
|
||||
final statusLabel = switch (book.status) {
|
||||
'reading' => 'Читаю',
|
||||
'done' => 'Прочитано',
|
||||
'want_to_read' => 'Хочу прочитать',
|
||||
_ => book.status,
|
||||
};
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Hero section
|
||||
Stack(
|
||||
children: [
|
||||
if (book.coverUrl != null)
|
||||
SizedBox(
|
||||
height: 300,
|
||||
width: double.infinity,
|
||||
child: ShaderMask(
|
||||
shaderCallback: (rect) => LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [colorScheme.surface, Colors.transparent],
|
||||
).createShader(rect),
|
||||
blendMode: BlendMode.dstIn,
|
||||
child: Image.network(book.coverUrl!, fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.sm,
|
||||
AppSpacing.sm,
|
||||
AppSpacing.sm,
|
||||
0,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.read<NavigationBloc>().add(
|
||||
NavigateTo(AppScreen.library),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
onPressed: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
return SingleChildScrollView(
|
||||
child: Material(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Hero section
|
||||
Stack(
|
||||
children: [
|
||||
if (book.coverUrl != null)
|
||||
SizedBox(
|
||||
height: 300,
|
||||
width: double.infinity,
|
||||
child: ShaderMask(
|
||||
shaderCallback: (rect) => LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [colorScheme.surface, Colors.transparent],
|
||||
).createShader(rect),
|
||||
blendMode: BlendMode.dstIn,
|
||||
child: Image.network(book.coverUrl!, fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Hero(
|
||||
tag: 'book-cover-${book.id}',
|
||||
child: Container(
|
||||
width: 140,
|
||||
height: 210,
|
||||
margin: const EdgeInsets.only(bottom: 0),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppSpacing.radiusLarge,
|
||||
),
|
||||
boxShadow: AppTheme.shadowXl,
|
||||
SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.sm,
|
||||
AppSpacing.sm,
|
||||
AppSpacing.sm,
|
||||
0,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
onPressed: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Hero(
|
||||
tag: 'book-cover-${book.id}',
|
||||
child: Container(
|
||||
width: 140,
|
||||
height: 210,
|
||||
margin: const EdgeInsets.only(bottom: 0),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppSpacing.radiusLarge,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppSpacing.radiusLarge,
|
||||
),
|
||||
child: book.coverUrl != null
|
||||
? Image.network(
|
||||
book.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.book,
|
||||
color: colorScheme.primary.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
size: 48,
|
||||
boxShadow: AppTheme.shadowXl,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppSpacing.radiusLarge,
|
||||
),
|
||||
child: book.coverUrl != null
|
||||
? Image.network(book.coverUrl!, fit: BoxFit.cover)
|
||||
: Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.book,
|
||||
color: colorScheme.primary.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
size: 48,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
// Status badge
|
||||
Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: AppSpacing.sm,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
border: Border.all(color: colorScheme.primary),
|
||||
borderRadius: BorderRadius.circular(AppSpacing.radiusPill),
|
||||
),
|
||||
child: Text(
|
||||
statusLabel,
|
||||
style: textTheme.labelMedium?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
// Title & Author
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(book.title, style: textTheme.displayMedium),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
book.author,
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
// Genre tag
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: AppSpacing.sm,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
border: Border.all(color: colorScheme.outline),
|
||||
borderRadius: BorderRadius.circular(AppSpacing.radiusSmall),
|
||||
),
|
||||
child: Text(book.genre, style: textTheme.labelMedium),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
// Action buttons
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => AddBookScreen(editBook: book),
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.edit, size: 18),
|
||||
label: const Text('Изменить'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
context.read<BookBloc>().add(DeleteBook(book.id));
|
||||
Navigator.pop(context);
|
||||
},
|
||||
icon: const Icon(Icons.delete_outline, size: 18),
|
||||
label: const Text('Удалить'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: colorScheme.error,
|
||||
side: BorderSide(color: colorScheme.error),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
// About
|
||||
Text('О книге', style: textTheme.headlineMedium),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text(book.annotation, style: textTheme.bodyLarge),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
// Info grid
|
||||
GridView.count(
|
||||
crossAxisCount: 2,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
mainAxisSpacing: AppSpacing.md,
|
||||
crossAxisSpacing: AppSpacing.md,
|
||||
childAspectRatio: 2.5,
|
||||
children: [
|
||||
_infoTile(
|
||||
context,
|
||||
Icons.menu_book,
|
||||
Colors.blue,
|
||||
'Страницы',
|
||||
'${book.pages ?? "—"}',
|
||||
),
|
||||
_infoTile(
|
||||
context,
|
||||
Icons.language,
|
||||
Colors.purple,
|
||||
'Язык',
|
||||
book.language ?? '—',
|
||||
),
|
||||
_infoTile(
|
||||
context,
|
||||
Icons.calendar_month,
|
||||
Colors.orange,
|
||||
'Год',
|
||||
'${book.publishedYear ?? "—"}',
|
||||
),
|
||||
_infoTile(
|
||||
context,
|
||||
Icons.star,
|
||||
Colors.amber,
|
||||
'Рейтинг',
|
||||
'${book.rating ?? "—"}',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
// Status badge
|
||||
Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: AppSpacing.sm,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
border: Border.all(color: colorScheme.primary),
|
||||
borderRadius: BorderRadius.circular(AppSpacing.radiusPill),
|
||||
),
|
||||
child: Text(
|
||||
statusLabel,
|
||||
style: textTheme.labelMedium?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
// Title & Author
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(book.title, style: textTheme.displayMedium),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
book.author,
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
// Genre tag
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: AppSpacing.sm,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
border: Border.all(color: colorScheme.outline),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppSpacing.radiusSmall,
|
||||
),
|
||||
),
|
||||
child: Text(book.genre, style: textTheme.labelMedium),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
// Action buttons
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
context.read<NavigationBloc>().add(
|
||||
NavigateTo(
|
||||
AppScreen.addBook,
|
||||
selectedBook: book,
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.edit, size: 18),
|
||||
label: const Text('Изменить'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
context.read<BookBloc>().add(DeleteBook(book.id));
|
||||
context.read<NavigationBloc>().add(
|
||||
NavigateTo(AppScreen.library),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.delete_outline, size: 18),
|
||||
label: const Text('Удалить'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: colorScheme.error,
|
||||
side: BorderSide(color: colorScheme.error),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
// About
|
||||
Text('О книге', style: textTheme.headlineMedium),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text(book.annotation, style: textTheme.bodyLarge),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
// Info grid
|
||||
GridView.count(
|
||||
crossAxisCount: 2,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
mainAxisSpacing: AppSpacing.md,
|
||||
crossAxisSpacing: AppSpacing.md,
|
||||
childAspectRatio: 2.5,
|
||||
children: [
|
||||
_infoTile(
|
||||
context,
|
||||
Icons.menu_book,
|
||||
Colors.blue,
|
||||
'Страницы',
|
||||
'${book.pages ?? "—"}',
|
||||
),
|
||||
_infoTile(
|
||||
context,
|
||||
Icons.language,
|
||||
Colors.purple,
|
||||
'Язык',
|
||||
book.language ?? '—',
|
||||
),
|
||||
_infoTile(
|
||||
context,
|
||||
Icons.calendar_month,
|
||||
Colors.orange,
|
||||
'Год',
|
||||
'${book.publishedYear ?? "—"}',
|
||||
),
|
||||
_infoTile(
|
||||
context,
|
||||
Icons.star,
|
||||
Colors.amber,
|
||||
'Рейтинг',
|
||||
'${book.rating ?? "—"}',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,174 +1,226 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../bloc/book_bloc.dart';
|
||||
import '../bloc/navigation_bloc.dart';
|
||||
import '../models/models.dart';
|
||||
import '../bloc/book/book_bloc.dart';
|
||||
import '../bloc/book/book_state.dart';
|
||||
import '../bloc/library/library_bloc.dart';
|
||||
import '../bloc/library/library_event.dart';
|
||||
import '../bloc/library/library_state.dart';
|
||||
import '../widgets/book_card.dart';
|
||||
import '../theme/app_spacing.dart';
|
||||
import 'book_details_screen.dart';
|
||||
import 'add_book_screen.dart';
|
||||
|
||||
class LibraryScreen extends StatefulWidget {
|
||||
class LibraryScreen extends StatelessWidget {
|
||||
const LibraryScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LibraryScreen> createState() => _LibraryScreenState();
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => LibraryBloc(),
|
||||
child: const _LibraryScreenContent(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LibraryScreenState extends State<LibraryScreen> {
|
||||
String _search = '';
|
||||
int _tabIndex = 0;
|
||||
class _LibraryScreenContent extends StatelessWidget {
|
||||
const _LibraryScreenContent();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return BlocBuilder<BookBloc, BookState>(
|
||||
builder: (context, state) {
|
||||
final filtered = state.books.where((b) {
|
||||
final q = _search.toLowerCase();
|
||||
return b.title.toLowerCase().contains(q) ||
|
||||
b.author.toLowerCase().contains(q);
|
||||
}).toList();
|
||||
return BlocBuilder<LibraryBloc, LibraryState>(
|
||||
builder: (context, libraryState) {
|
||||
return BlocBuilder<BookBloc, BookState>(
|
||||
builder: (context, bookState) {
|
||||
final filtered = bookState.books.where((b) {
|
||||
final q = libraryState.searchQuery.toLowerCase();
|
||||
return b.title.toLowerCase().contains(q) ||
|
||||
b.author.toLowerCase().contains(q);
|
||||
}).toList();
|
||||
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.lg,
|
||||
AppSpacing.md,
|
||||
AppSpacing.lg,
|
||||
0,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Книжная полка', style: textTheme.displayMedium),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.notifications_outlined),
|
||||
onPressed: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Search
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.lg,
|
||||
AppSpacing.md,
|
||||
AppSpacing.lg,
|
||||
0,
|
||||
),
|
||||
child: TextField(
|
||||
onChanged: (v) => setState(() => _search = v),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Поиск книг...',
|
||||
prefixIcon: Icon(Icons.search, color: colorScheme.primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Tabs
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.lg,
|
||||
AppSpacing.md,
|
||||
AppSpacing.lg,
|
||||
0,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_tab('Все книги', 0),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
_tab('Категории', 1),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
// Grid
|
||||
Expanded(
|
||||
child: _tabIndex == 0
|
||||
? GridView.builder(
|
||||
return Stack(
|
||||
children: [
|
||||
SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.lg,
|
||||
0,
|
||||
AppSpacing.md,
|
||||
AppSpacing.lg,
|
||||
100,
|
||||
0,
|
||||
),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 0.55,
|
||||
crossAxisSpacing: AppSpacing.md,
|
||||
mainAxisSpacing: AppSpacing.md,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Книжная полка',
|
||||
style: textTheme.displayMedium,
|
||||
),
|
||||
itemCount: filtered.length,
|
||||
itemBuilder: (context, i) => BookCard(
|
||||
book: filtered[i],
|
||||
onTap: () {
|
||||
context.read<NavigationBloc>().add(
|
||||
NavigateTo(
|
||||
AppScreen.details,
|
||||
selectedBook: filtered[i],
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.notifications_outlined),
|
||||
onPressed: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Search
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.lg,
|
||||
AppSpacing.md,
|
||||
AppSpacing.lg,
|
||||
0,
|
||||
),
|
||||
child: TextField(
|
||||
onChanged: (v) {
|
||||
context.read<LibraryBloc>().add(
|
||||
UpdateSearchQuery(v),
|
||||
);
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Поиск книг...',
|
||||
prefixIcon: Icon(
|
||||
Icons.search,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView(
|
||||
),
|
||||
// Tabs
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.lg,
|
||||
0,
|
||||
AppSpacing.md,
|
||||
AppSpacing.lg,
|
||||
100,
|
||||
0,
|
||||
),
|
||||
children: [
|
||||
for (final genre
|
||||
in filtered.map((b) => b.genre).toSet())
|
||||
Container(
|
||||
margin: const EdgeInsets.only(
|
||||
bottom: AppSpacing.sm,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
border: Border.all(color: colorScheme.outline),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppSpacing.radiusMedium,
|
||||
),
|
||||
),
|
||||
child: ListTile(
|
||||
title: Text(
|
||||
genre,
|
||||
style: textTheme.titleMedium,
|
||||
),
|
||||
trailing: Text(
|
||||
'${filtered.where((b) => b.genre == genre).length}',
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(
|
||||
alpha: 0.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_tab(
|
||||
context,
|
||||
'Все книги',
|
||||
0,
|
||||
libraryState.tabIndex,
|
||||
),
|
||||
],
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
_tab(
|
||||
context,
|
||||
'Категории',
|
||||
1,
|
||||
libraryState.tabIndex,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
// Grid
|
||||
Expanded(
|
||||
child: libraryState.tabIndex == 0
|
||||
? GridView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.lg,
|
||||
0,
|
||||
AppSpacing.lg,
|
||||
100,
|
||||
),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 0.55,
|
||||
crossAxisSpacing: AppSpacing.md,
|
||||
mainAxisSpacing: AppSpacing.md,
|
||||
),
|
||||
itemCount: filtered.length,
|
||||
itemBuilder: (context, i) => BookCard(
|
||||
book: filtered[i],
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => BookDetailsScreen(
|
||||
book: filtered[i],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.lg,
|
||||
0,
|
||||
AppSpacing.lg,
|
||||
100,
|
||||
),
|
||||
children: [
|
||||
for (final genre
|
||||
in filtered.map((b) => b.genre).toSet())
|
||||
Container(
|
||||
margin: const EdgeInsets.only(
|
||||
bottom: AppSpacing.sm,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
border: Border.all(
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppSpacing.radiusMedium,
|
||||
),
|
||||
),
|
||||
child: ListTile(
|
||||
title: Text(
|
||||
genre,
|
||||
style: textTheme.titleMedium,
|
||||
),
|
||||
trailing: Text(
|
||||
'${filtered.where((b) => b.genre == genre).length}',
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface
|
||||
.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
right: 20,
|
||||
child: FloatingActionButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const AddBookScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tab(String label, int index) {
|
||||
final selected = _tabIndex == index;
|
||||
Widget _tab(BuildContext context, String label, int index, int currentIndex) {
|
||||
final selected = currentIndex == index;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final disableAnimations = MediaQuery.of(context).disableAnimations;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _tabIndex = index),
|
||||
onTap: () => context.read<LibraryBloc>().add(ChangeTab(index)),
|
||||
child: AnimatedContainer(
|
||||
duration: disableAnimations
|
||||
? Duration.zero
|
||||
|
||||
@@ -1,123 +1,409 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../bloc/navigation_bloc.dart';
|
||||
import '../models/models.dart';
|
||||
import 'package:camera/camera.dart';
|
||||
import '../bloc/scanner/scanner_bloc.dart';
|
||||
import '../bloc/scanner/scanner_event.dart';
|
||||
import '../bloc/scanner/scanner_state.dart';
|
||||
import '../services/camera_service.dart';
|
||||
|
||||
class ScannerScreen extends StatelessWidget {
|
||||
const ScannerScreen({super.key});
|
||||
final String? geminiApiKey;
|
||||
final String? openaiApiKey;
|
||||
final String openaiBaseUrl;
|
||||
|
||||
const ScannerScreen({
|
||||
super.key,
|
||||
this.geminiApiKey,
|
||||
this.openaiApiKey,
|
||||
this.openaiBaseUrl = 'http://localhost:8317',
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
children: [
|
||||
// Camera placeholder
|
||||
Container(color: Colors.black87),
|
||||
// Scan frame
|
||||
Center(
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: 0.75,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 2 / 3,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.white30, width: 2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Камера недоступна\n(заглушка)',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.white38),
|
||||
),
|
||||
),
|
||||
),
|
||||
return BlocProvider(
|
||||
create: (context) =>
|
||||
ScannerBloc(cameraService: CameraService())..add(InitializeCamera()),
|
||||
child: _ScannerScreenContent(
|
||||
geminiApiKey: geminiApiKey,
|
||||
openaiApiKey: openaiApiKey,
|
||||
openaiBaseUrl: openaiBaseUrl,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ScannerScreenContent extends StatelessWidget {
|
||||
final String? geminiApiKey;
|
||||
final String? openaiApiKey;
|
||||
final String openaiBaseUrl;
|
||||
|
||||
const _ScannerScreenContent({
|
||||
this.geminiApiKey,
|
||||
this.openaiApiKey,
|
||||
this.openaiBaseUrl = 'http://localhost:8317',
|
||||
});
|
||||
|
||||
void _showErrorDialog(BuildContext context, String errorMessage) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Ошибка'),
|
||||
content: Text(errorMessage),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(dialogContext).pop();
|
||||
context.read<ScannerBloc>().add(DismissError());
|
||||
},
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<ScannerBloc, ScannerState>(
|
||||
listener: (context, state) {
|
||||
// Show error dialog when error message is present
|
||||
if (state.errorMessage != null) {
|
||||
_showErrorDialog(context, state.errorMessage!);
|
||||
}
|
||||
|
||||
// Navigate back with analyzed book
|
||||
if (state.analyzedBook != null) {
|
||||
Navigator.pop(context, state.analyzedBook);
|
||||
}
|
||||
},
|
||||
child: BlocBuilder<ScannerBloc, ScannerState>(
|
||||
builder: (context, state) {
|
||||
final cameraService = context.read<ScannerBloc>().cameraService;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
children: [
|
||||
// Camera preview
|
||||
if (state.isInitialized && cameraService.controller != null)
|
||||
Center(child: CameraPreview(cameraService.controller!))
|
||||
else if (state.hasPermissionError)
|
||||
_buildPermissionError(context)
|
||||
else
|
||||
_buildLoading(),
|
||||
|
||||
// Scan frame overlay
|
||||
if (state.isInitialized && !state.isAnalyzing)
|
||||
_buildScanFrame(),
|
||||
|
||||
// Header
|
||||
_buildHeader(context),
|
||||
|
||||
// Processing overlay
|
||||
if (state.isAnalyzing) _buildProcessingOverlay(),
|
||||
|
||||
// Controls
|
||||
if (state.isInitialized && !state.isAnalyzing)
|
||||
_buildControls(context, state.isCapturing),
|
||||
|
||||
// Instructions
|
||||
if (state.isInitialized && !state.isAnalyzing)
|
||||
_buildInstructions(),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoading() {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPermissionError(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.camera_alt_outlined,
|
||||
size: 64,
|
||||
color: Colors.white38,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Нет доступа к камере',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Header
|
||||
SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
onPressed: () => context.read<NavigationBloc>().add(
|
||||
NavigateTo(AppScreen.addBook),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF17CF54),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Text(
|
||||
'СКАНЕР',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Разрешите доступ к камере для сканирования обложек книг',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 14),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
context.read<ScannerBloc>().add(InitializeCamera());
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF17CF54),
|
||||
),
|
||||
child: const Text('Повторить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildScanFrame() {
|
||||
return Center(
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: 0.75,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 2 / 3,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.white30, width: 2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
// Corner accents
|
||||
child: Stack(
|
||||
children: [
|
||||
// Top left corner
|
||||
Positioned(
|
||||
top: -2,
|
||||
left: -2,
|
||||
child: Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
left: BorderSide(color: Color(0xFF17CF54), width: 4),
|
||||
top: BorderSide(color: Color(0xFF17CF54), width: 4),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 48),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Top right corner
|
||||
Positioned(
|
||||
top: -2,
|
||||
right: -2,
|
||||
child: Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
right: BorderSide(color: Color(0xFF17CF54), width: 4),
|
||||
top: BorderSide(color: Color(0xFF17CF54), width: 4),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Bottom left corner
|
||||
Positioned(
|
||||
bottom: -2,
|
||||
left: -2,
|
||||
child: Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
left: BorderSide(color: Color(0xFF17CF54), width: 4),
|
||||
bottom: BorderSide(color: Color(0xFF17CF54), width: 4),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Bottom right corner
|
||||
Positioned(
|
||||
bottom: -2,
|
||||
right: -2,
|
||||
child: Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
right: BorderSide(color: Color(0xFF17CF54), width: 4),
|
||||
bottom: BorderSide(color: Color(0xFF17CF54), width: 4),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Instructions
|
||||
Positioned(
|
||||
bottom: 140,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Text(
|
||||
'Поместите обложку в рамку',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey.shade400),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF17CF54),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Text(
|
||||
'СКАНЕР',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 48),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProcessingOverlay() {
|
||||
return Container(
|
||||
color: Colors.black87,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF17CF54)),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'Анализ обложки...',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Это может занять несколько секунд',
|
||||
style: TextStyle(color: Colors.grey.shade400, fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildControls(BuildContext context, bool isCapturing) {
|
||||
return Positioned(
|
||||
bottom: 50,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Flash button (placeholder)
|
||||
Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
margin: const EdgeInsets.only(right: 20),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.flash_off, color: Colors.white),
|
||||
onPressed: () {
|
||||
// Flash functionality can be added later
|
||||
},
|
||||
),
|
||||
),
|
||||
// Capture button
|
||||
Positioned(
|
||||
bottom: 50,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
// Placeholder - no actual camera capture
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Камера не подключена (заглушка)'),
|
||||
),
|
||||
);
|
||||
},
|
||||
GestureDetector(
|
||||
onTap: isCapturing
|
||||
? null
|
||||
: () {
|
||||
context.read<ScannerBloc>().add(
|
||||
CaptureAndAnalyze(
|
||||
openaiApiKey: openaiApiKey,
|
||||
openaiBaseUrl: openaiBaseUrl,
|
||||
geminiApiKey: geminiApiKey,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 4),
|
||||
),
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 4),
|
||||
),
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
color: isCapturing ? Colors.white38 : Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Camera switch button
|
||||
Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
margin: const EdgeInsets.only(left: 20),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.flip_camera_ios, color: Colors.white),
|
||||
onPressed: () {
|
||||
context.read<ScannerBloc>().add(SwitchCamera());
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInstructions() {
|
||||
return Positioned(
|
||||
bottom: 140,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'Поместите обложку в рамку',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.grey.shade400,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Убедитесь, что текст читается четко',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey.shade500, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user