open ai service
This commit is contained in:
97
books_flutter/lib/bloc/add_book/add_book_bloc.dart
Normal file
97
books_flutter/lib/bloc/add_book/add_book_bloc.dart
Normal file
@@ -0,0 +1,97 @@
|
||||
import 'dart:math';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../models/models.dart';
|
||||
import 'add_book_event.dart';
|
||||
import 'add_book_state.dart';
|
||||
|
||||
class AddBookBloc extends Bloc<AddBookEvent, AddBookState> {
|
||||
final void Function(Book book) onAddBook;
|
||||
final void Function(Book book) onUpdateBook;
|
||||
|
||||
AddBookBloc({required this.onAddBook, required this.onUpdateBook})
|
||||
: super(const AddBookState()) {
|
||||
on<InitializeForm>(_onInitializeForm);
|
||||
on<UpdateTitle>(_onUpdateTitle);
|
||||
on<UpdateAuthor>(_onUpdateAuthor);
|
||||
on<UpdateAnnotation>(_onUpdateAnnotation);
|
||||
on<UpdateGenre>(_onUpdateGenre);
|
||||
on<ApplyScannedBook>(_onApplyScannedBook);
|
||||
on<SaveBook>(_onSaveBook);
|
||||
}
|
||||
|
||||
void _onInitializeForm(InitializeForm event, Emitter<AddBookState> emit) {
|
||||
final source = event.editBook ?? event.prefilledData;
|
||||
if (source != null) {
|
||||
emit(
|
||||
AddBookState(
|
||||
title: source.title,
|
||||
author: source.author,
|
||||
annotation: source.annotation,
|
||||
genre: source.genre.isNotEmpty ? source.genre : 'fiction',
|
||||
editBook: event.editBook,
|
||||
),
|
||||
);
|
||||
} else if (event.editBook != null) {
|
||||
emit(state.copyWith(editBook: event.editBook));
|
||||
}
|
||||
}
|
||||
|
||||
void _onUpdateTitle(UpdateTitle event, Emitter<AddBookState> emit) {
|
||||
emit(state.copyWith(title: event.title));
|
||||
}
|
||||
|
||||
void _onUpdateAuthor(UpdateAuthor event, Emitter<AddBookState> emit) {
|
||||
emit(state.copyWith(author: event.author));
|
||||
}
|
||||
|
||||
void _onUpdateAnnotation(UpdateAnnotation event, Emitter<AddBookState> emit) {
|
||||
emit(state.copyWith(annotation: event.annotation));
|
||||
}
|
||||
|
||||
void _onUpdateGenre(UpdateGenre event, Emitter<AddBookState> emit) {
|
||||
emit(state.copyWith(genre: event.genre));
|
||||
}
|
||||
|
||||
void _onApplyScannedBook(ApplyScannedBook event, Emitter<AddBookState> emit) {
|
||||
final scanned = event.scannedBook;
|
||||
emit(
|
||||
state.copyWith(
|
||||
title: scanned.title,
|
||||
author: scanned.author,
|
||||
annotation: scanned.annotation,
|
||||
genre: scanned.genre.isNotEmpty ? scanned.genre : 'fiction',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onSaveBook(SaveBook event, Emitter<AddBookState> emit) {
|
||||
final existing = state.editBook;
|
||||
final isEditing = existing != null;
|
||||
|
||||
final Book book = (
|
||||
id: isEditing ? existing.id : '${Random().nextInt(100000)}',
|
||||
title: state.title,
|
||||
author: state.author,
|
||||
genre: state.genre,
|
||||
annotation: state.annotation,
|
||||
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,
|
||||
);
|
||||
|
||||
if (isEditing) {
|
||||
onUpdateBook(book);
|
||||
} else {
|
||||
onAddBook(book);
|
||||
}
|
||||
|
||||
emit(state.copyWith(isSaved: true));
|
||||
}
|
||||
}
|
||||
37
books_flutter/lib/bloc/add_book/add_book_event.dart
Normal file
37
books_flutter/lib/bloc/add_book/add_book_event.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
import '../../models/models.dart';
|
||||
|
||||
sealed class AddBookEvent {}
|
||||
|
||||
class InitializeForm extends AddBookEvent {
|
||||
final Book? editBook;
|
||||
final Book? prefilledData;
|
||||
|
||||
InitializeForm({this.editBook, this.prefilledData});
|
||||
}
|
||||
|
||||
class UpdateTitle extends AddBookEvent {
|
||||
final String title;
|
||||
UpdateTitle(this.title);
|
||||
}
|
||||
|
||||
class UpdateAuthor extends AddBookEvent {
|
||||
final String author;
|
||||
UpdateAuthor(this.author);
|
||||
}
|
||||
|
||||
class UpdateAnnotation extends AddBookEvent {
|
||||
final String annotation;
|
||||
UpdateAnnotation(this.annotation);
|
||||
}
|
||||
|
||||
class UpdateGenre extends AddBookEvent {
|
||||
final String genre;
|
||||
UpdateGenre(this.genre);
|
||||
}
|
||||
|
||||
class ApplyScannedBook extends AddBookEvent {
|
||||
final Book scannedBook;
|
||||
ApplyScannedBook(this.scannedBook);
|
||||
}
|
||||
|
||||
class SaveBook extends AddBookEvent {}
|
||||
39
books_flutter/lib/bloc/add_book/add_book_state.dart
Normal file
39
books_flutter/lib/bloc/add_book/add_book_state.dart
Normal file
@@ -0,0 +1,39 @@
|
||||
import '../../models/models.dart';
|
||||
|
||||
class AddBookState {
|
||||
final String title;
|
||||
final String author;
|
||||
final String annotation;
|
||||
final String genre;
|
||||
final Book? editBook;
|
||||
final bool isSaved;
|
||||
|
||||
const AddBookState({
|
||||
this.title = '',
|
||||
this.author = '',
|
||||
this.annotation = '',
|
||||
this.genre = 'fiction',
|
||||
this.editBook,
|
||||
this.isSaved = false,
|
||||
});
|
||||
|
||||
bool get isEditing => editBook != null;
|
||||
|
||||
AddBookState copyWith({
|
||||
String? title,
|
||||
String? author,
|
||||
String? annotation,
|
||||
String? genre,
|
||||
Book? editBook,
|
||||
bool? isSaved,
|
||||
}) {
|
||||
return AddBookState(
|
||||
title: title ?? this.title,
|
||||
author: author ?? this.author,
|
||||
annotation: annotation ?? this.annotation,
|
||||
genre: genre ?? this.genre,
|
||||
editBook: editBook ?? this.editBook,
|
||||
isSaved: isSaved ?? this.isSaved,
|
||||
);
|
||||
}
|
||||
}
|
||||
47
books_flutter/lib/bloc/book/book_bloc.dart
Normal file
47
books_flutter/lib/bloc/book/book_bloc.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../constants/constants.dart';
|
||||
import 'book_event.dart';
|
||||
import 'book_state.dart';
|
||||
|
||||
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));
|
||||
});
|
||||
}
|
||||
}
|
||||
23
books_flutter/lib/bloc/book/book_event.dart
Normal file
23
books_flutter/lib/bloc/book/book_event.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
import '../../models/models.dart';
|
||||
|
||||
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);
|
||||
}
|
||||
6
books_flutter/lib/bloc/book/book_state.dart
Normal file
6
books_flutter/lib/bloc/book/book_state.dart
Normal file
@@ -0,0 +1,6 @@
|
||||
import '../../models/models.dart';
|
||||
|
||||
class BookState {
|
||||
final List<Book> books;
|
||||
const BookState({required this.books});
|
||||
}
|
||||
21
books_flutter/lib/bloc/library/library_bloc.dart
Normal file
21
books_flutter/lib/bloc/library/library_bloc.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'library_event.dart';
|
||||
import 'library_state.dart';
|
||||
|
||||
class LibraryBloc extends Bloc<LibraryEvent, LibraryState> {
|
||||
LibraryBloc() : super(const LibraryState()) {
|
||||
on<UpdateSearchQuery>(_onUpdateSearchQuery);
|
||||
on<ChangeTab>(_onChangeTab);
|
||||
}
|
||||
|
||||
void _onUpdateSearchQuery(
|
||||
UpdateSearchQuery event,
|
||||
Emitter<LibraryState> emit,
|
||||
) {
|
||||
emit(state.copyWith(searchQuery: event.query));
|
||||
}
|
||||
|
||||
void _onChangeTab(ChangeTab event, Emitter<LibraryState> emit) {
|
||||
emit(state.copyWith(tabIndex: event.tabIndex));
|
||||
}
|
||||
}
|
||||
11
books_flutter/lib/bloc/library/library_event.dart
Normal file
11
books_flutter/lib/bloc/library/library_event.dart
Normal file
@@ -0,0 +1,11 @@
|
||||
sealed class LibraryEvent {}
|
||||
|
||||
class UpdateSearchQuery extends LibraryEvent {
|
||||
final String query;
|
||||
UpdateSearchQuery(this.query);
|
||||
}
|
||||
|
||||
class ChangeTab extends LibraryEvent {
|
||||
final int tabIndex;
|
||||
ChangeTab(this.tabIndex);
|
||||
}
|
||||
13
books_flutter/lib/bloc/library/library_state.dart
Normal file
13
books_flutter/lib/bloc/library/library_state.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
class LibraryState {
|
||||
final String searchQuery;
|
||||
final int tabIndex;
|
||||
|
||||
const LibraryState({this.searchQuery = '', this.tabIndex = 0});
|
||||
|
||||
LibraryState copyWith({String? searchQuery, int? tabIndex}) {
|
||||
return LibraryState(
|
||||
searchQuery: searchQuery ?? this.searchQuery,
|
||||
tabIndex: tabIndex ?? this.tabIndex,
|
||||
);
|
||||
}
|
||||
}
|
||||
126
books_flutter/lib/bloc/scanner/scanner_bloc.dart
Normal file
126
books_flutter/lib/bloc/scanner/scanner_bloc.dart
Normal file
@@ -0,0 +1,126 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../models/models.dart';
|
||||
import '../../services/camera_service.dart';
|
||||
import '../../services/openai_service.dart';
|
||||
import 'scanner_event.dart';
|
||||
import 'scanner_state.dart';
|
||||
|
||||
class ScannerBloc extends Bloc<ScannerEvent, ScannerState> {
|
||||
final CameraService cameraService;
|
||||
|
||||
ScannerBloc({required this.cameraService}) : super(const ScannerState()) {
|
||||
on<InitializeCamera>(_onInitializeCamera);
|
||||
on<CaptureAndAnalyze>(_onCaptureAndAnalyze);
|
||||
on<SwitchCamera>(_onSwitchCamera);
|
||||
on<DismissError>(_onDismissError);
|
||||
}
|
||||
|
||||
Future<void> _onInitializeCamera(
|
||||
InitializeCamera event,
|
||||
Emitter<ScannerState> emit,
|
||||
) async {
|
||||
try {
|
||||
final initialized = await cameraService.initializeCamera();
|
||||
emit(
|
||||
state.copyWith(
|
||||
isInitialized: initialized,
|
||||
hasPermissionError: !initialized,
|
||||
errorMessage: initialized ? null : 'Нет доступа к камере',
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
hasPermissionError: true,
|
||||
errorMessage: 'Ошибка инициализации камеры: $e',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onCaptureAndAnalyze(
|
||||
CaptureAndAnalyze event,
|
||||
Emitter<ScannerState> emit,
|
||||
) async {
|
||||
if (cameraService.controller == null) return;
|
||||
|
||||
emit(state.copyWith(isCapturing: true));
|
||||
|
||||
try {
|
||||
// Capture image
|
||||
final imagePath = await cameraService.captureImage();
|
||||
if (imagePath == null) {
|
||||
throw Exception('Не удалось сделать снимок');
|
||||
}
|
||||
|
||||
emit(state.copyWith(isAnalyzing: true, isCapturing: false));
|
||||
|
||||
Book? book;
|
||||
|
||||
// Try OpenAI first if available
|
||||
if (event.openaiApiKey != null && event.openaiApiKey!.isNotEmpty) {
|
||||
print('Using OpenAI service for analysis');
|
||||
final openaiService = OpenAIService(
|
||||
apiKey: event.openaiApiKey!,
|
||||
baseUrl: event.openaiBaseUrl,
|
||||
);
|
||||
book = await openaiService.analyzeBookCover(imagePath);
|
||||
}
|
||||
|
||||
// Fall back to Gemini if OpenAI failed or is not configured
|
||||
// if (book == null) {
|
||||
// if (event.geminiApiKey == null || event.geminiApiKey!.isEmpty) {
|
||||
// throw Exception('API ключ не настроен (ни OpenAI, ни Gemini)');
|
||||
// }
|
||||
// print('Using Gemini service for analysis');
|
||||
// final geminiService = GeminiService(apiKey: event.geminiApiKey!);
|
||||
// book = await geminiService.analyzeBookCover(imagePath);
|
||||
// }
|
||||
|
||||
if (book == null) {
|
||||
throw Exception('Не удалось распознать книгу');
|
||||
}
|
||||
|
||||
// Clean up temporary image
|
||||
try {
|
||||
await File(imagePath).delete();
|
||||
} catch (e) {
|
||||
print('Error deleting temporary file: $e');
|
||||
}
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
analyzedBook: book,
|
||||
isAnalyzing: false,
|
||||
isCapturing: false,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
errorMessage: e.toString(),
|
||||
isCapturing: false,
|
||||
isAnalyzing: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onSwitchCamera(
|
||||
SwitchCamera event,
|
||||
Emitter<ScannerState> emit,
|
||||
) async {
|
||||
await cameraService.switchCamera();
|
||||
}
|
||||
|
||||
void _onDismissError(DismissError event, Emitter<ScannerState> emit) {
|
||||
emit(state.copyWith(clearError: true));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
cameraService.dispose();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
21
books_flutter/lib/bloc/scanner/scanner_event.dart
Normal file
21
books_flutter/lib/bloc/scanner/scanner_event.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
import 'package:books_flutter/config/api_config.dart';
|
||||
|
||||
sealed class ScannerEvent {}
|
||||
|
||||
class InitializeCamera extends ScannerEvent {}
|
||||
|
||||
class CaptureAndAnalyze extends ScannerEvent {
|
||||
final String? openaiApiKey;
|
||||
final String openaiBaseUrl;
|
||||
final String? geminiApiKey;
|
||||
|
||||
CaptureAndAnalyze({
|
||||
this.openaiApiKey,
|
||||
this.openaiBaseUrl = ApiConfig.openaiBaseUrl,
|
||||
this.geminiApiKey,
|
||||
});
|
||||
}
|
||||
|
||||
class SwitchCamera extends ScannerEvent {}
|
||||
|
||||
class DismissError extends ScannerEvent {}
|
||||
39
books_flutter/lib/bloc/scanner/scanner_state.dart
Normal file
39
books_flutter/lib/bloc/scanner/scanner_state.dart
Normal file
@@ -0,0 +1,39 @@
|
||||
import '../../models/models.dart';
|
||||
|
||||
class ScannerState {
|
||||
final bool isInitialized;
|
||||
final bool isCapturing;
|
||||
final bool isAnalyzing;
|
||||
final bool hasPermissionError;
|
||||
final String? errorMessage;
|
||||
final Book? analyzedBook;
|
||||
|
||||
const ScannerState({
|
||||
this.isInitialized = false,
|
||||
this.isCapturing = false,
|
||||
this.isAnalyzing = false,
|
||||
this.hasPermissionError = false,
|
||||
this.errorMessage,
|
||||
this.analyzedBook,
|
||||
});
|
||||
|
||||
ScannerState copyWith({
|
||||
bool? isInitialized,
|
||||
bool? isCapturing,
|
||||
bool? isAnalyzing,
|
||||
bool? hasPermissionError,
|
||||
String? errorMessage,
|
||||
Book? analyzedBook,
|
||||
bool clearError = false,
|
||||
bool clearBook = false,
|
||||
}) {
|
||||
return ScannerState(
|
||||
isInitialized: isInitialized ?? this.isInitialized,
|
||||
isCapturing: isCapturing ?? this.isCapturing,
|
||||
isAnalyzing: isAnalyzing ?? this.isAnalyzing,
|
||||
hasPermissionError: hasPermissionError ?? this.hasPermissionError,
|
||||
errorMessage: clearError ? null : (errorMessage ?? this.errorMessage),
|
||||
analyzedBook: clearBook ? null : (analyzedBook ?? this.analyzedBook),
|
||||
);
|
||||
}
|
||||
}
|
||||
19
books_flutter/lib/config/api_config.dart
Normal file
19
books_flutter/lib/config/api_config.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
/// API Configuration
|
||||
///
|
||||
/// Replace YOUR_GEMINI_API_KEY_HERE with your actual Google Gemini API key
|
||||
/// Get your API key from: https://makersuite.google.com/app/apikey
|
||||
///
|
||||
/// Replace YOUR_OPENAI_API_KEY_HERE with your actual OpenAI API key
|
||||
/// The default endpoint is set to http://localhost:8317/v1/chat/completions
|
||||
/// You can configure your OpenAI endpoint below if needed
|
||||
class ApiConfig {
|
||||
// TODO: Replace with your actual Gemini API key
|
||||
static const String geminiApiKey = 'YOUR_GEMINI_API_KEY_HERE';
|
||||
|
||||
static const String openaiApiKey = 'sk-openai-api-key';
|
||||
|
||||
// OpenAI API endpoint (default: http://localhost:8317/v1/chat/completions)
|
||||
static const String openaiBaseUrl = 'http://192.168.102.158:8317';
|
||||
|
||||
static const String openaiModel = 'gemini-3-pro-image';
|
||||
}
|
||||
15
books_flutter/lib/models/book.dart
Normal file
15
books_flutter/lib/models/book.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
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,
|
||||
});
|
||||
10
books_flutter/lib/models/category.dart
Normal file
10
books_flutter/lib/models/category.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
typedef Category = ({
|
||||
String id,
|
||||
String name,
|
||||
int count,
|
||||
IconData icon,
|
||||
Color iconColor,
|
||||
Color backgroundColor,
|
||||
});
|
||||
90
books_flutter/lib/services/camera_service.dart
Normal file
90
books_flutter/lib/services/camera_service.dart
Normal file
@@ -0,0 +1,90 @@
|
||||
import 'package:camera/camera.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
class CameraService {
|
||||
CameraController? _controller;
|
||||
List<CameraDescription>? _cameras;
|
||||
bool _isInitialized = false;
|
||||
|
||||
bool get isInitialized => _isInitialized;
|
||||
CameraController? get controller => _controller;
|
||||
|
||||
Future<bool> requestPermissions() async {
|
||||
final cameraStatus = await Permission.camera.request();
|
||||
return cameraStatus.isGranted;
|
||||
}
|
||||
|
||||
Future<bool> initializeCamera() async {
|
||||
try {
|
||||
// Request camera permissions
|
||||
final hasPermission = await requestPermissions();
|
||||
if (!hasPermission) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get available cameras
|
||||
_cameras = await availableCameras();
|
||||
if (_cameras == null || _cameras!.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Initialize the back camera (first camera is usually the back one)
|
||||
_controller = CameraController(
|
||||
_cameras!.first,
|
||||
ResolutionPreset.high,
|
||||
enableAudio: false,
|
||||
);
|
||||
|
||||
await _controller!.initialize();
|
||||
_isInitialized = true;
|
||||
return true;
|
||||
} catch (e) {
|
||||
print('Error initializing camera: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> captureImage() async {
|
||||
if (_controller == null || !_isInitialized) {
|
||||
print('Camera not initialized');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
final image = await _controller!.takePicture();
|
||||
return image.path;
|
||||
} catch (e) {
|
||||
print('Error capturing image: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await _controller?.dispose();
|
||||
_controller = null;
|
||||
_isInitialized = false;
|
||||
}
|
||||
|
||||
Future<void> switchCamera() async {
|
||||
if (_cameras == null || _cameras!.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final currentCameraIndex = _cameras!.indexOf(_controller!.description);
|
||||
final nextCameraIndex = (currentCameraIndex + 1) % _cameras!.length;
|
||||
|
||||
await _controller?.dispose();
|
||||
|
||||
_controller = CameraController(
|
||||
_cameras![nextCameraIndex],
|
||||
ResolutionPreset.high,
|
||||
enableAudio: false,
|
||||
);
|
||||
|
||||
await _controller!.initialize();
|
||||
} catch (e) {
|
||||
print('Error switching camera: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
152
books_flutter/lib/services/openai_service.dart
Normal file
152
books_flutter/lib/services/openai_service.dart
Normal file
@@ -0,0 +1,152 @@
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
import 'package:books_flutter/config/api_config.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../models/models.dart';
|
||||
|
||||
class OpenAIService {
|
||||
final String apiKey;
|
||||
final String baseUrl;
|
||||
final String model;
|
||||
late final String _endpoint;
|
||||
|
||||
OpenAIService({
|
||||
required this.apiKey,
|
||||
this.baseUrl = ApiConfig.openaiApiKey,
|
||||
this.model = ApiConfig.openaiModel,
|
||||
}) {
|
||||
_endpoint = '$baseUrl/v1/chat/completions';
|
||||
}
|
||||
|
||||
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
|
||||
- Return result in russian language
|
||||
''';
|
||||
|
||||
// Create the request body for OpenAI API
|
||||
final requestBody = {
|
||||
'model': model, // Use the configured model
|
||||
'messages': [
|
||||
{
|
||||
'role': 'user',
|
||||
'content': [
|
||||
{'type': 'text', 'text': prompt},
|
||||
{
|
||||
'type': 'image_url',
|
||||
'image_url': {'url': 'data:image/jpeg;base64,$base64Image'},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Make the API request
|
||||
final response = await http.post(
|
||||
Uri.parse(_endpoint),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $apiKey',
|
||||
},
|
||||
body: json.encode(requestBody),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
print('OpenAI API error: ${response.statusCode}');
|
||||
print('Response body: ${response.body}');
|
||||
return null;
|
||||
}
|
||||
|
||||
final responseData = json.decode(response.body);
|
||||
|
||||
// Extract the message content
|
||||
final responseText = responseData['choices']?[0]?['message']?['content']
|
||||
?.toString()
|
||||
.trim();
|
||||
|
||||
if (responseText == null || responseText.isEmpty) {
|
||||
print('Empty response from OpenAI');
|
||||
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 with OpenAI: $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;
|
||||
}
|
||||
}
|
||||
99
books_flutter/lib/widgets/bottom_nav_shell.dart
Normal file
99
books_flutter/lib/widgets/bottom_nav_shell.dart
Normal file
@@ -0,0 +1,99 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'bottom_nav.dart';
|
||||
import '../screens/library_screen.dart';
|
||||
import '../screens/categories_screen.dart';
|
||||
|
||||
/// Shell widget with bottom navigation and nested navigators for each tab.
|
||||
/// Uses IndexedStack to preserve navigation state when switching tabs.
|
||||
class BottomNavShell extends StatefulWidget {
|
||||
const BottomNavShell({super.key});
|
||||
|
||||
@override
|
||||
State<BottomNavShell> createState() => _BottomNavShellState();
|
||||
}
|
||||
|
||||
class _BottomNavShellState extends State<BottomNavShell> {
|
||||
int _currentIndex = 0;
|
||||
|
||||
// Each tab gets its own navigator key to maintain independent navigation stacks
|
||||
final _navigatorKeys = List.generate(4, (_) => GlobalKey<NavigatorState>());
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, result) async {
|
||||
if (didPop) return;
|
||||
final shouldPop = await _onWillPop();
|
||||
if (shouldPop && context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
body: IndexedStack(
|
||||
index: _currentIndex,
|
||||
children: [
|
||||
_buildNavigator(0, (_) => const LibraryScreen()),
|
||||
_buildNavigator(1, (_) => const CategoriesScreen()),
|
||||
_buildNavigator(2, (_) => _buildPlaceholder('Избранное')),
|
||||
_buildNavigator(3, (_) => _buildPlaceholder('Настройки')),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: BottomNav(
|
||||
currentIndex: _currentIndex,
|
||||
onTap: _onTabTapped,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds a nested navigator for a tab
|
||||
Widget _buildNavigator(int index, WidgetBuilder builder) {
|
||||
return Navigator(
|
||||
key: _navigatorKeys[index],
|
||||
onGenerateRoute: (settings) {
|
||||
return MaterialPageRoute(builder: builder, settings: settings);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Placeholder screen for tabs not yet implemented
|
||||
Widget _buildPlaceholder(String title) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(title), automaticallyImplyLeading: false),
|
||||
body: Center(
|
||||
child: Text(title, style: Theme.of(context).textTheme.headlineMedium),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle tab selection
|
||||
void _onTabTapped(int index) {
|
||||
if (_currentIndex == index) {
|
||||
// If tapping the current tab, pop to root of that tab's navigator
|
||||
final navigator = _navigatorKeys[index].currentState;
|
||||
if (navigator != null && navigator.canPop()) {
|
||||
navigator.popUntil((route) => route.isFirst);
|
||||
}
|
||||
} else {
|
||||
// Switch to the selected tab
|
||||
setState(() {
|
||||
_currentIndex = index;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle system back button
|
||||
Future<bool> _onWillPop() async {
|
||||
final navigator = _navigatorKeys[_currentIndex].currentState;
|
||||
|
||||
// If the current tab's navigator can pop, pop it
|
||||
if (navigator != null && navigator.canPop()) {
|
||||
navigator.pop();
|
||||
return false; // Don't exit app
|
||||
}
|
||||
|
||||
// If on root of current tab, allow app to exit
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user