From d7722ad81d26495b6d315f6934df9c14821f6daf Mon Sep 17 00:00:00 2001 From: Yuriy Panov Date: Sun, 8 Feb 2026 12:04:45 +0600 Subject: [PATCH] openai service --- books_flutter/android/app/build.gradle.kts | 17 + .../android/app/src/main/AndroidManifest.xml | 7 +- books_flutter/ios/Podfile.lock | 6 + .../ios/Runner.xcodeproj/project.pbxproj | 18 + books_flutter/ios/Runner/Info.plist | 2 + books_flutter/lib/bloc/book_bloc.dart | 76 --- books_flutter/lib/bloc/navigation_bloc.dart | 53 -- books_flutter/lib/main.dart | 103 +--- books_flutter/lib/models/models.dart | 39 +- .../lib/screens/add_book_screen.dart | 434 ++++++++-------- .../lib/screens/book_details_screen.dart | 454 ++++++++--------- books_flutter/lib/screens/library_screen.dart | 312 +++++++----- books_flutter/lib/screens/scanner_screen.dart | 476 ++++++++++++++---- .../lib/services/gemini_service.dart | 115 ++++- books_flutter/lib/widgets/book_card.dart | 3 +- books_flutter/lib/widgets/bottom_nav.dart | 106 ++-- books_flutter/lib/widgets/layout.dart | 17 - books_flutter/pubspec.lock | 130 ++++- books_flutter/pubspec.yaml | 12 + 19 files changed, 1372 insertions(+), 1008 deletions(-) delete mode 100644 books_flutter/lib/bloc/book_bloc.dart delete mode 100644 books_flutter/lib/bloc/navigation_bloc.dart delete mode 100644 books_flutter/lib/widgets/layout.dart diff --git a/books_flutter/android/app/build.gradle.kts b/books_flutter/android/app/build.gradle.kts index 859c0f7..d77b59a 100644 --- a/books_flutter/android/app/build.gradle.kts +++ b/books_flutter/android/app/build.gradle.kts @@ -31,12 +31,29 @@ android { } buildTypes { + debug { + // Fix for Samsung device crash dump error + isDebuggable = true + isJniDebuggable = false + isMinifyEnabled = false + ndk { + // Disable crash dump on Samsung devices + abiFilters.clear() + abiFilters.addAll(listOf("arm64-v8a", "armeabi-v7a")) + } + } release { // TODO: Add your own signing config for the release build. // Signing with the debug keys for now, so `flutter run --release` works. signingConfig = signingConfigs.getByName("debug") } } + + packagingOptions { + jniLibs { + useLegacyPackaging = true + } + } } flutter { diff --git a/books_flutter/android/app/src/main/AndroidManifest.xml b/books_flutter/android/app/src/main/AndroidManifest.xml index 5be2191..253504c 100644 --- a/books_flutter/android/app/src/main/AndroidManifest.xml +++ b/books_flutter/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,13 @@ + + + + + android:icon="@mipmap/ic_launcher" + android:extractNativeLibs="true"> UIApplicationSupportsIndirectInputEvents + NSCameraUsageDescription + Для сканирования обложек книг и автоматического определения информации о книге diff --git a/books_flutter/lib/bloc/book_bloc.dart b/books_flutter/lib/bloc/book_bloc.dart deleted file mode 100644 index f9d6043..0000000 --- a/books_flutter/lib/bloc/book_bloc.dart +++ /dev/null @@ -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 books; - const BookState({required this.books}); -} - -// Bloc -class BookBloc extends Bloc { - BookBloc() : super(const BookState(books: initialBooks)) { - on((event, emit) { - emit(BookState(books: [...state.books, event.book])); - }); - - on((event, emit) { - final updated = state.books.map((b) { - return b.id == event.book.id ? event.book : b; - }).toList(); - emit(BookState(books: updated)); - }); - - on((event, emit) { - emit( - BookState(books: state.books.where((b) => b.id != event.id).toList()), - ); - }); - - on((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)); - }); - } -} diff --git a/books_flutter/lib/bloc/navigation_bloc.dart b/books_flutter/lib/bloc/navigation_bloc.dart deleted file mode 100644 index e694ded..0000000 --- a/books_flutter/lib/bloc/navigation_bloc.dart +++ /dev/null @@ -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 { - NavigationBloc() : super(const NavigationState()) { - on((event, emit) { - emit( - NavigationState( - screen: event.screen, - selectedBook: event.selectedBook ?? state.selectedBook, - prefilledData: event.prefilledData, - ), - ); - }); - } -} diff --git a/books_flutter/lib/main.dart b/books_flutter/lib/main.dart index 298f630..fa04559 100644 --- a/books_flutter/lib/main.dart +++ b/books_flutter/lib/main.dart @@ -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( - 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().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), - ), - ), - ), - ); - }, - ); - } -} diff --git a/books_flutter/lib/models/models.dart b/books_flutter/lib/models/models.dart index 21bcb71..401b34f 100644 --- a/books_flutter/lib/models/models.dart +++ b/books_flutter/lib/models/models.dart @@ -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'; diff --git a/books_flutter/lib/screens/add_book_screen.dart b/books_flutter/lib/screens/add_book_screen.dart index 8dea7d5..cb9cd9f 100644 --- a/books_flutter/lib/screens/add_book_screen.dart +++ b/books_flutter/lib/screens/add_book_screen.dart @@ -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 createState() => _AddBookScreenState(); + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => AddBookBloc( + onAddBook: (book) => context.read().add(AddBook(book)), + onUpdateBook: (book) => context.read().add(UpdateBook(book)), + )..add(InitializeForm(editBook: editBook, prefilledData: prefilledData)), + child: const _AddBookScreenContent(), + ); + } } -class _AddBookScreenState extends State { +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().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 { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; - final navState = context.read().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().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().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( + 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( + 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().add(UpdateTitle(value)), + ), + const SizedBox(height: AppSpacing.md), + _field( + context, + 'Автор', + _authorController, + textTheme, + (value) => context.read().add( + UpdateAuthor(value), + ), + ), + const SizedBox(height: AppSpacing.md), + // Genre dropdown + Text('Жанр', style: textTheme.labelMedium), + const SizedBox(height: AppSpacing.xs), + DropdownButtonFormField( + 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().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().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().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( - 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().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().state; - final existing = navState.selectedBook; - final isEditing = existing != null && navState.prefilledData == null; + Future _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( + MaterialPageRoute( + builder: (_) => ScannerScreen( + geminiApiKey: ApiConfig.geminiApiKey, + openaiApiKey: ApiConfig.openaiApiKey, + openaiBaseUrl: ApiConfig.openaiBaseUrl, + ), + ), + ); - if (isEditing) { - context.read().add(UpdateBook(book)); - } else { - context.read().add(AddBook(book)); + if (scannedBook != null && context.mounted) { + context.read().add(ApplyScannedBook(scannedBook)); } - context.read().add(NavigateTo(AppScreen.library)); } } diff --git a/books_flutter/lib/screens/book_details_screen.dart b/books_flutter/lib/screens/book_details_screen.dart index 3f25e45..4bd1532 100644 --- a/books_flutter/lib/screens/book_details_screen.dart +++ b/books_flutter/lib/screens/book_details_screen.dart @@ -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( - 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().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().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().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().add(DeleteBook(book.id)); - context.read().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), - ], - ), - ), - ], - ), - ); - }, + ), + ], + ), + ), ); } diff --git a/books_flutter/lib/screens/library_screen.dart b/books_flutter/lib/screens/library_screen.dart index 495782b..a9f1855 100644 --- a/books_flutter/lib/screens/library_screen.dart +++ b/books_flutter/lib/screens/library_screen.dart @@ -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 createState() => _LibraryScreenState(); + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => LibraryBloc(), + child: const _LibraryScreenContent(), + ); + } } -class _LibraryScreenState extends State { - 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( - 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( + builder: (context, libraryState) { + return BlocBuilder( + 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().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().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().add(ChangeTab(index)), child: AnimatedContainer( duration: disableAnimations ? Duration.zero diff --git a/books_flutter/lib/screens/scanner_screen.dart b/books_flutter/lib/screens/scanner_screen.dart index b7bf336..1e2300d 100644 --- a/books_flutter/lib/screens/scanner_screen.dart +++ b/books_flutter/lib/screens/scanner_screen.dart @@ -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().add(DismissError()); + }, + child: const Text('OK'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + 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( + builder: (context, state) { + final cameraService = context.read().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(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().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().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(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().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().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), + ), ], ), ); diff --git a/books_flutter/lib/services/gemini_service.dart b/books_flutter/lib/services/gemini_service.dart index 1371d3f..073183a 100644 --- a/books_flutter/lib/services/gemini_service.dart +++ b/books_flutter/lib/services/gemini_service.dart @@ -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 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 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 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; } } diff --git a/books_flutter/lib/widgets/book_card.dart b/books_flutter/lib/widgets/book_card.dart index 3db2d85..7a695da 100644 --- a/books_flutter/lib/widgets/book_card.dart +++ b/books_flutter/lib/widgets/book_card.dart @@ -62,8 +62,9 @@ class _BookCardState extends State { height: double.infinity, loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) + if (loadingProgress == null) { return child; + } return ShimmerLoading( borderRadius: AppSpacing.radiusMedium, height: double.infinity, diff --git a/books_flutter/lib/widgets/bottom_nav.dart b/books_flutter/lib/widgets/bottom_nav.dart index 4038fa4..b55c355 100644 --- a/books_flutter/lib/widgets/bottom_nav.dart +++ b/books_flutter/lib/widgets/bottom_nav.dart @@ -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 onTap; + + const BottomNav({super.key, required this.currentIndex, required this.onTap}); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - return BlocBuilder( - 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().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: 'Настройки', + ), + ], + ), ); } } diff --git a/books_flutter/lib/widgets/layout.dart b/books_flutter/lib/widgets/layout.dart deleted file mode 100644 index 1ec2aea..0000000 --- a/books_flutter/lib/widgets/layout.dart +++ /dev/null @@ -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, - ); - } -} diff --git a/books_flutter/pubspec.lock b/books_flutter/pubspec.lock index 3b9ac5c..4ff6a0a 100644 --- a/books_flutter/pubspec.lock +++ b/books_flutter/pubspec.lock @@ -1,6 +1,22 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + archive: + dependency: transitive + description: + name: archive + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + url: "https://pub.dev" + source: hosted + version: "4.0.7" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -73,6 +89,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" clock: dependency: transitive description: @@ -158,6 +190,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.1.1" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" + url: "https://pub.dev" + source: hosted + version: "0.14.4" flutter_lints: dependency: "direct dev" description: @@ -217,7 +257,7 @@ packages: source: hosted version: "1.0.0" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -232,6 +272,22 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c" + url: "https://pub.dev" + source: hosted + version: "4.7.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "805fa86df56383000f640384b282ce0cb8431f1a7a2396de92fb66186d8c57df" + url: "https://pub.dev" + source: hosted + version: "4.10.0" leak_tracker: dependency: transitive description: @@ -376,6 +432,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849" + url: "https://pub.dev" + source: hosted + version: "11.4.0" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc + url: "https://pub.dev" + source: hosted + version: "12.1.0" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + url: "https://pub.dev" + source: hosted + version: "9.4.7" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + url: "https://pub.dev" + source: hosted + version: "7.0.1" platform: dependency: transitive description: @@ -392,6 +504,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + url: "https://pub.dev" + source: hosted + version: "6.0.3" provider: dependency: transitive description: @@ -509,6 +629,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" yaml: dependency: transitive description: diff --git a/books_flutter/pubspec.yaml b/books_flutter/pubspec.yaml index 088eefb..d969f1c 100644 --- a/books_flutter/pubspec.yaml +++ b/books_flutter/pubspec.yaml @@ -36,6 +36,8 @@ dependencies: camera: ^0.11.1 google_generative_ai: ^0.4.6 google_fonts: ^6.2.1 + permission_handler: ^11.0.0 + http: ^1.2.0 dev_dependencies: flutter_test: @@ -47,6 +49,7 @@ dev_dependencies: # package. See that file for information about deactivating specific lint # rules and activating additional ones. flutter_lints: ^6.0.0 + flutter_launcher_icons: ^0.14.1 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec @@ -89,3 +92,12 @@ flutter: # # For details regarding fonts from package dependencies, # see https://flutter.dev/to/font-from-package + +# Flutter Launcher Icons configuration +flutter_launcher_icons: + android: true + ios: true + image_path: "assets/icon/app_icon.png" + adaptive_icon_background: "#0891B2" # Your app's cyan theme color + adaptive_icon_foreground: "assets/icon/app_icon_foreground.png" + remove_alpha_ios: true