openai service

This commit is contained in:
2026-02-08 12:04:45 +06:00
parent 5c7b65a0d3
commit d7722ad81d
19 changed files with 1372 additions and 1008 deletions

View File

@@ -1,76 +0,0 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../models/models.dart';
import '../constants/constants.dart';
// Events
sealed class BookEvent {}
class AddBook extends BookEvent {
final Book book;
AddBook(this.book);
}
class UpdateBook extends BookEvent {
final Book book;
UpdateBook(this.book);
}
class DeleteBook extends BookEvent {
final String id;
DeleteBook(this.id);
}
class ToggleFavorite extends BookEvent {
final String id;
ToggleFavorite(this.id);
}
// State
class BookState {
final List<Book> books;
const BookState({required this.books});
}
// Bloc
class BookBloc extends Bloc<BookEvent, BookState> {
BookBloc() : super(const BookState(books: initialBooks)) {
on<AddBook>((event, emit) {
emit(BookState(books: [...state.books, event.book]));
});
on<UpdateBook>((event, emit) {
final updated = state.books.map((b) {
return b.id == event.book.id ? event.book : b;
}).toList();
emit(BookState(books: updated));
});
on<DeleteBook>((event, emit) {
emit(
BookState(books: state.books.where((b) => b.id != event.id).toList()),
);
});
on<ToggleFavorite>((event, emit) {
final updated = state.books.map((b) {
if (b.id != event.id) return b;
return (
id: b.id,
title: b.title,
author: b.author,
genre: b.genre,
annotation: b.annotation,
coverUrl: b.coverUrl,
pages: b.pages,
language: b.language,
publishedYear: b.publishedYear,
rating: b.rating,
status: b.status,
progress: b.progress,
isFavorite: !b.isFavorite,
);
}).toList();
emit(BookState(books: updated));
});
}
}

View File

@@ -1,53 +0,0 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../models/models.dart';
// Events
sealed class NavigationEvent {}
class NavigateTo extends NavigationEvent {
final AppScreen screen;
final Book? selectedBook;
final Book? prefilledData;
NavigateTo(this.screen, {this.selectedBook, this.prefilledData});
}
// State
class NavigationState {
final AppScreen screen;
final Book? selectedBook;
final Book? prefilledData;
const NavigationState({
this.screen = AppScreen.library,
this.selectedBook,
this.prefilledData,
});
NavigationState copyWith({
AppScreen? screen,
Book? Function()? selectedBook,
Book? Function()? prefilledData,
}) {
return NavigationState(
screen: screen ?? this.screen,
selectedBook: selectedBook != null ? selectedBook() : this.selectedBook,
prefilledData: prefilledData != null
? prefilledData()
: this.prefilledData,
);
}
}
// Bloc
class NavigationBloc extends Bloc<NavigationEvent, NavigationState> {
NavigationBloc() : super(const NavigationState()) {
on<NavigateTo>((event, emit) {
emit(
NavigationState(
screen: event.screen,
selectedBook: event.selectedBook ?? state.selectedBook,
prefilledData: event.prefilledData,
),
);
});
}
}

View File

@@ -1,15 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:google_fonts/google_fonts.dart';
import 'bloc/book_bloc.dart';
import 'bloc/navigation_bloc.dart';
import 'models/models.dart';
import 'widgets/layout.dart';
import 'screens/library_screen.dart';
import 'screens/categories_screen.dart';
import 'screens/book_details_screen.dart';
import 'screens/add_book_screen.dart';
import 'screens/scanner_screen.dart';
import 'bloc/book/book_bloc.dart';
import 'widgets/bottom_nav_shell.dart';
import 'theme/app_theme.dart';
void main() async {
@@ -23,101 +16,15 @@ class BookshelfApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider(create: (_) => BookBloc()),
BlocProvider(create: (_) => NavigationBloc()),
],
return BlocProvider(
create: (_) => BookBloc(),
child: MaterialApp(
title: 'Книжная полка',
debugShowCheckedModeBanner: false,
theme: AppTheme.lightTheme(),
themeMode: ThemeMode.light,
home: const _AppShell(),
home: const BottomNavShell(),
),
);
}
}
class _AppShell extends StatelessWidget {
const _AppShell();
@override
Widget build(BuildContext context) {
return BlocBuilder<NavigationBloc, NavigationState>(
builder: (context, state) {
final showNav = switch (state.screen) {
AppScreen.library ||
AppScreen.categories ||
AppScreen.wishlist ||
AppScreen.settings => true,
_ => false,
};
final screen = switch (state.screen) {
AppScreen.library => _libraryWithFab(context),
AppScreen.categories => const CategoriesScreen(),
AppScreen.wishlist => _placeholder(
'Здесь будут книги, которые вы хотите прочитать.',
),
AppScreen.settings => _placeholder(
'Персонализируйте ваше приложение.',
),
AppScreen.details => const BookDetailsScreen(),
AppScreen.addBook => const AddBookScreen(),
AppScreen.scanner => const ScannerScreen(),
};
if (state.screen == AppScreen.scanner) {
return screen;
}
return AppLayout(showBottomNav: showNav, child: screen);
},
);
}
Widget _libraryWithFab(BuildContext context) {
return Stack(
children: [
const LibraryScreen(),
Positioned(
bottom: 16,
right: 20,
child: FloatingActionButton(
onPressed: () {
context.read<NavigationBloc>().add(
NavigateTo(
AppScreen.addBook,
selectedBook: null,
prefilledData: null,
),
);
},
child: const Icon(Icons.add),
),
),
],
);
}
Widget _placeholder(String message) {
return Builder(
builder: (context) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(48),
child: Text(
message,
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
),
),
),
);
},
);
}
}

View File

@@ -1,36 +1,3 @@
import 'package:flutter/material.dart';
typedef Book = ({
String id,
String title,
String author,
String genre,
String annotation,
String? coverUrl,
int? pages,
String? language,
int? publishedYear,
double? rating,
String status,
double? progress,
bool isFavorite,
});
typedef Category = ({
String id,
String name,
int count,
IconData icon,
Color iconColor,
Color backgroundColor,
});
enum AppScreen {
library,
categories,
wishlist,
settings,
details,
addBook,
scanner,
}
// Barrel file - exports all models
export 'book.dart';
export 'category.dart';

View File

@@ -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));
}
}

View File

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

View File

@@ -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

View File

@@ -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),
),
],
),
);

View File

@@ -1,12 +1,115 @@
import 'dart:io';
import 'dart:convert';
import 'package:google_generative_ai/google_generative_ai.dart';
import '../models/models.dart';
class GeminiService {
GeminiService({required String apiKey});
final String apiKey;
late final GenerativeModel _model;
Future<Book?> analyzeBookCover(String base64Image) async {
// Placeholder - Gemini API integration would go here
// Would use google_generative_ai package to send the image
// and extract book metadata via structured JSON output
return null;
GeminiService({required this.apiKey}) {
_model = GenerativeModel(model: 'gemini-1.5-flash', apiKey: apiKey);
}
Future<Book?> analyzeBookCover(String imagePath) async {
try {
// Read the image file
final imageFile = File(imagePath);
final imageBytes = await imageFile.readAsBytes();
final base64Image = base64Encode(imageBytes);
// Create the prompt for book analysis
const prompt = '''
Analyze this book cover image and extract the following information in JSON format:
{
"title": "book title (required)",
"author": "author name (required)",
"genre": "fiction/fantasy/science/detective/biography/other",
"annotation": "brief description or summary if visible, otherwise generate a generic one"
}
Rules:
- Extract exact text from the cover
- If genre is unclear, choose the most appropriate one
- If annotation is not visible, create a brief generic description
- Return ONLY valid JSON, no additional text
- Ensure all required fields are present
''';
// Create the image part for the model
final imagePart = Content.data('image/jpeg', imageBytes);
// Generate content with both text and image
final response = await _model.generateContent([
Content.text(prompt),
imagePart,
]);
final responseText = response.text?.trim();
if (responseText == null || responseText.isEmpty) {
print('Empty response from Gemini');
return null;
}
// Extract JSON from response (handle potential markdown formatting)
String jsonString = responseText;
if (jsonString.contains('```json')) {
jsonString = jsonString.split('```json')[1].split('```')[0].trim();
} else if (jsonString.contains('```')) {
jsonString = jsonString.split('```')[1].split('```')[0].trim();
}
// Parse JSON response
final Map<String, dynamic> jsonData = json.decode(jsonString);
// Create Book object with extracted data
final Book book = (
id: DateTime.now().millisecondsSinceEpoch.toString(),
title: jsonData['title']?.toString() ?? 'Неизвестная книга',
author: jsonData['author']?.toString() ?? 'Неизвестный автор',
genre: _normalizeGenre(jsonData['genre']?.toString()),
annotation: jsonData['annotation']?.toString() ?? 'Нет описания',
coverUrl: null, // Will be set by the caller
pages: null,
language: 'Russian',
publishedYear: DateTime.now().year,
rating: 5.0,
status: 'want_to_read',
progress: null,
isFavorite: false,
);
return book;
} catch (e) {
print('Error analyzing book cover: $e');
return null;
}
}
String _normalizeGenre(String? genre) {
if (genre == null || genre.isEmpty) return 'other';
final normalized = genre.toLowerCase().trim();
// Map various genre names to our standard genres
final genreMap = {
'фантастика': 'fiction',
'fantasy': 'fantasy',
'фэнтези': 'fantasy',
'science': 'science',
'научпоп': 'science',
'научная': 'science',
'biography': 'biography',
'биография': 'biography',
'detective': 'detective',
'детектив': 'detective',
'роман': 'other',
'novel': 'other',
'poetry': 'other',
'поэзия': 'other',
};
return genreMap[normalized] ?? normalized;
}
}

View File

@@ -62,8 +62,9 @@ class _BookCardState extends State<BookCard> {
height: double.infinity,
loadingBuilder:
(context, child, loadingProgress) {
if (loadingProgress == null)
if (loadingProgress == null) {
return child;
}
return ShimmerLoading(
borderRadius: AppSpacing.radiusMedium,
height: double.infinity,

View File

@@ -1,77 +1,55 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../models/models.dart';
import '../bloc/navigation_bloc.dart';
class BottomNav extends StatelessWidget {
const BottomNav({super.key});
final int currentIndex;
final ValueChanged<int> onTap;
const BottomNav({super.key, required this.currentIndex, required this.onTap});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return BlocBuilder<NavigationBloc, NavigationState>(
builder: (context, state) {
final currentIndex = switch (state.screen) {
AppScreen.library ||
AppScreen.details ||
AppScreen.addBook ||
AppScreen.scanner => 0,
AppScreen.categories => 1,
AppScreen.wishlist => 2,
AppScreen.settings => 3,
};
return Container(
decoration: BoxDecoration(
color: colorScheme.surface,
border: Border(
top: BorderSide(color: colorScheme.outlineVariant, width: 1),
),
boxShadow: [
BoxShadow(
color: colorScheme.shadow,
blurRadius: 8,
offset: const Offset(0, -2),
),
],
return Container(
decoration: BoxDecoration(
color: colorScheme.surface,
border: Border(
top: BorderSide(color: colorScheme.outlineVariant, width: 1),
),
boxShadow: [
BoxShadow(
color: colorScheme.shadow,
blurRadius: 8,
offset: const Offset(0, -2),
),
child: BottomNavigationBar(
currentIndex: currentIndex,
backgroundColor: Colors.transparent,
elevation: 0,
selectedItemColor: colorScheme.primary,
unselectedItemColor: colorScheme.onSurface.withValues(alpha: 0.6),
onTap: (index) {
final screen = [
AppScreen.library,
AppScreen.categories,
AppScreen.wishlist,
AppScreen.settings,
][index];
context.read<NavigationBloc>().add(NavigateTo(screen));
},
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.local_library),
label: 'Библиотека',
),
BottomNavigationBarItem(
icon: Icon(Icons.category),
label: 'Категории',
),
BottomNavigationBarItem(
icon: Icon(Icons.bookmark),
label: 'Избранное',
),
BottomNavigationBarItem(
icon: Icon(Icons.settings),
label: 'Настройки',
),
],
],
),
child: BottomNavigationBar(
currentIndex: currentIndex,
onTap: onTap,
backgroundColor: Colors.transparent,
elevation: 0,
selectedItemColor: colorScheme.primary,
unselectedItemColor: colorScheme.onSurface.withValues(alpha: 0.6),
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.local_library),
label: 'Библиотека',
),
);
},
BottomNavigationBarItem(
icon: Icon(Icons.category),
label: 'Категории',
),
BottomNavigationBarItem(
icon: Icon(Icons.bookmark),
label: 'Избранное',
),
BottomNavigationBarItem(
icon: Icon(Icons.settings),
label: 'Настройки',
),
],
),
);
}
}

View File

@@ -1,17 +0,0 @@
import 'package:flutter/material.dart';
import 'bottom_nav.dart';
class AppLayout extends StatelessWidget {
final Widget child;
final bool showBottomNav;
const AppLayout({super.key, required this.child, this.showBottomNav = true});
@override
Widget build(BuildContext context) {
return Scaffold(
body: child,
bottomNavigationBar: showBottomNav ? const BottomNav() : null,
);
}
}