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