openai service

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

View File

@@ -31,12 +31,29 @@ android {
} }
buildTypes { 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 { release {
// TODO: Add your own signing config for the release build. // TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works. // Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug") signingConfig = signingConfigs.getByName("debug")
} }
} }
packagingOptions {
jniLibs {
useLegacyPackaging = true
}
}
} }
flutter { flutter {

View File

@@ -1,8 +1,13 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Camera permission for book scanning -->
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.INTERNET"/>
<application <application
android:label="books_flutter" android:label="books_flutter"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher"
android:extractNativeLibs="true">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"

View File

@@ -2,20 +2,26 @@ PODS:
- camera_avfoundation (0.0.1): - camera_avfoundation (0.0.1):
- Flutter - Flutter
- Flutter (1.0.0) - Flutter (1.0.0)
- permission_handler_apple (9.3.0):
- Flutter
DEPENDENCIES: DEPENDENCIES:
- camera_avfoundation (from `.symlinks/plugins/camera_avfoundation/ios`) - camera_avfoundation (from `.symlinks/plugins/camera_avfoundation/ios`)
- Flutter (from `Flutter`) - Flutter (from `Flutter`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
EXTERNAL SOURCES: EXTERNAL SOURCES:
camera_avfoundation: camera_avfoundation:
:path: ".symlinks/plugins/camera_avfoundation/ios" :path: ".symlinks/plugins/camera_avfoundation/ios"
Flutter: Flutter:
:path: Flutter :path: Flutter
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
SPEC CHECKSUMS: SPEC CHECKSUMS:
camera_avfoundation: 5675ca25298b6f81fa0a325188e7df62cc217741 camera_avfoundation: 5675ca25298b6f81fa0a325188e7df62cc217741
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e

View File

@@ -199,6 +199,7 @@
9705A1C41CF9048500538489 /* Embed Frameworks */, 9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */,
C307D3F810A8F8C8E009259C /* [CP] Embed Pods Frameworks */, C307D3F810A8F8C8E009259C /* [CP] Embed Pods Frameworks */,
4F49CC92542C7E033C781E4A /* [CP] Copy Pods Resources */,
); );
buildRules = ( buildRules = (
); );
@@ -286,6 +287,23 @@
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
}; };
4F49CC92542C7E033C781E4A /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
showEnvVarsInLog = 0;
};
8BB9969949EF7BC4EB129C1A /* [CP] Check Pods Manifest.lock */ = { 8BB9969949EF7BC4EB129C1A /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;

View File

@@ -45,5 +45,7 @@
<true/> <true/>
<key>UIApplicationSupportsIndirectInputEvents</key> <key>UIApplicationSupportsIndirectInputEvents</key>
<true/> <true/>
<key>NSCameraUsageDescription</key>
<string>Для сканирования обложек книг и автоматического определения информации о книге</string>
</dict> </dict>
</plist> </plist>

View File

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

View File

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

View File

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

View File

@@ -1,36 +1,3 @@
import 'package:flutter/material.dart'; // Barrel file - exports all models
export 'book.dart';
typedef Book = ({ export 'category.dart';
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,
}

View File

@@ -1,42 +1,44 @@
import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/book_bloc.dart'; import '../bloc/book/book_bloc.dart';
import '../bloc/navigation_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 '../models/models.dart';
import '../theme/app_spacing.dart'; import '../theme/app_spacing.dart';
import 'scanner_screen.dart';
class AddBookScreen extends StatefulWidget { class AddBookScreen extends StatelessWidget {
const AddBookScreen({super.key}); final Book? editBook;
final Book? prefilledData;
const AddBookScreen({super.key, this.editBook, this.prefilledData});
@override @override
State<AddBookScreen> createState() => _AddBookScreenState(); Widget build(BuildContext context) {
return BlocProvider(
create: (context) => AddBookBloc(
onAddBook: (book) => context.read<BookBloc>().add(AddBook(book)),
onUpdateBook: (book) => context.read<BookBloc>().add(UpdateBook(book)),
)..add(InitializeForm(editBook: editBook, prefilledData: prefilledData)),
child: const _AddBookScreenContent(),
);
}
} }
class _AddBookScreenState extends State<AddBookScreen> { class _AddBookScreenContent extends StatefulWidget {
const _AddBookScreenContent();
@override
State<_AddBookScreenContent> createState() => _AddBookScreenContentState();
}
class _AddBookScreenContentState extends State<_AddBookScreenContent> {
final _titleController = TextEditingController(); final _titleController = TextEditingController();
final _authorController = TextEditingController(); final _authorController = TextEditingController();
final _annotationController = TextEditingController(); final _annotationController = TextEditingController();
String _genre = 'fiction';
bool _initialized = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_initialized) {
_initialized = true;
final navState = context.read<NavigationBloc>().state;
final book = navState.selectedBook;
final prefilled = navState.prefilledData;
final source = book ?? prefilled;
if (source != null) {
_titleController.text = source.title;
_authorController.text = source.author;
_annotationController.text = source.annotation;
_genre = source.genre.isNotEmpty ? source.genre : 'fiction';
}
}
}
@override @override
void dispose() { void dispose() {
@@ -50,12 +52,31 @@ class _AddBookScreenState extends State<AddBookScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
final navState = context.read<NavigationBloc>().state;
final isEditing =
navState.selectedBook != null && navState.prefilledData == null;
final title = isEditing ? 'Редактировать' : 'Добавить книгу';
return SafeArea( return BlocListener<AddBookBloc, AddBookState>(
listener: (context, state) {
// Update controllers when state changes (e.g., from scanned book)
if (_titleController.text != state.title) {
_titleController.text = state.title;
}
if (_authorController.text != state.author) {
_authorController.text = state.author;
}
if (_annotationController.text != state.annotation) {
_annotationController.text = state.annotation;
}
// Navigate back when saved
if (state.isSaved) {
Navigator.pop(context);
}
},
child: BlocBuilder<AddBookBloc, AddBookState>(
builder: (context, state) {
final title = state.isEditing ? 'Редактировать' : 'Добавить книгу';
return Material(
child: SafeArea(
child: Column( child: Column(
children: [ children: [
// Header // Header
@@ -70,11 +91,7 @@ class _AddBookScreenState extends State<AddBookScreen> {
children: [ children: [
IconButton( IconButton(
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
onPressed: () => context.read<NavigationBloc>().add( onPressed: () => Navigator.pop(context),
isEditing
? NavigateTo(AppScreen.details)
: NavigateTo(AppScreen.library),
),
), ),
Text(title, style: textTheme.headlineMedium), Text(title, style: textTheme.headlineMedium),
], ],
@@ -86,9 +103,7 @@ class _AddBookScreenState extends State<AddBookScreen> {
children: [ children: [
// Cover placeholder / scanner trigger // Cover placeholder / scanner trigger
GestureDetector( GestureDetector(
onTap: () => context.read<NavigationBloc>().add( onTap: () => _openScanner(context),
NavigateTo(AppScreen.scanner),
),
child: Container( child: Container(
height: 160, height: 160,
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -122,15 +137,30 @@ class _AddBookScreenState extends State<AddBookScreen> {
), ),
), ),
const SizedBox(height: AppSpacing.lg), const SizedBox(height: AppSpacing.lg),
_field('Название', _titleController, textTheme), _field(
context,
'Название',
_titleController,
textTheme,
(value) =>
context.read<AddBookBloc>().add(UpdateTitle(value)),
),
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
_field('Автор', _authorController, textTheme), _field(
context,
'Автор',
_authorController,
textTheme,
(value) => context.read<AddBookBloc>().add(
UpdateAuthor(value),
),
),
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
// Genre dropdown // Genre dropdown
Text('Жанр', style: textTheme.labelMedium), Text('Жанр', style: textTheme.labelMedium),
const SizedBox(height: AppSpacing.xs), const SizedBox(height: AppSpacing.xs),
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
initialValue: _genre, value: state.genre,
dropdownColor: colorScheme.surface, dropdownColor: colorScheme.surface,
decoration: const InputDecoration(), decoration: const InputDecoration(),
items: const [ items: const [
@@ -138,8 +168,14 @@ class _AddBookScreenState extends State<AddBookScreen> {
value: 'fiction', value: 'fiction',
child: Text('Фантастика'), child: Text('Фантастика'),
), ),
DropdownMenuItem(value: 'fantasy', child: Text('Фэнтези')), DropdownMenuItem(
DropdownMenuItem(value: 'science', child: Text('Научпоп')), value: 'fantasy',
child: Text('Фэнтези'),
),
DropdownMenuItem(
value: 'science',
child: Text('Научпоп'),
),
DropdownMenuItem( DropdownMenuItem(
value: 'biography', value: 'biography',
child: Text('Биография'), child: Text('Биография'),
@@ -148,14 +184,27 @@ class _AddBookScreenState extends State<AddBookScreen> {
value: 'detective', value: 'detective',
child: Text('Детектив'), child: Text('Детектив'),
), ),
DropdownMenuItem(value: 'other', child: Text('Другое')), DropdownMenuItem(
value: 'other',
child: Text('Другое'),
),
], ],
onChanged: (v) => setState(() => _genre = v ?? _genre), onChanged: (v) {
if (v != null) {
context.read<AddBookBloc>().add(UpdateGenre(v));
}
},
), ),
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
Text('Аннотация', style: textTheme.labelMedium), Text('Аннотация', style: textTheme.labelMedium),
const SizedBox(height: AppSpacing.xs), const SizedBox(height: AppSpacing.xs),
TextField(controller: _annotationController, maxLines: 4), TextField(
controller: _annotationController,
maxLines: 4,
onChanged: (value) => context.read<AddBookBloc>().add(
UpdateAnnotation(value),
),
),
const SizedBox(height: 100), const SizedBox(height: 100),
], ],
), ),
@@ -183,11 +232,7 @@ class _AddBookScreenState extends State<AddBookScreen> {
children: [ children: [
Expanded( Expanded(
child: OutlinedButton( child: OutlinedButton(
onPressed: () => context.read<NavigationBloc>().add( onPressed: () => Navigator.pop(context),
isEditing
? NavigateTo(AppScreen.details)
: NavigateTo(AppScreen.library),
),
child: const Text('Отмена'), child: const Text('Отмена'),
), ),
), ),
@@ -195,7 +240,8 @@ class _AddBookScreenState extends State<AddBookScreen> {
Expanded( Expanded(
flex: 2, flex: 2,
child: ElevatedButton( child: ElevatedButton(
onPressed: _save, onPressed: () =>
context.read<AddBookBloc>().add(SaveBook()),
child: const Text('Сохранить'), child: const Text('Сохранить'),
), ),
), ),
@@ -204,52 +250,46 @@ class _AddBookScreenState extends State<AddBookScreen> {
), ),
], ],
), ),
),
);
},
),
); );
} }
Widget _field( Widget _field(
BuildContext context,
String label, String label,
TextEditingController controller, TextEditingController controller,
TextTheme textTheme, TextTheme textTheme,
void Function(String) onChanged,
) { ) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(label, style: textTheme.labelMedium), Text(label, style: textTheme.labelMedium),
const SizedBox(height: AppSpacing.xs), const SizedBox(height: AppSpacing.xs),
TextField(controller: controller), TextField(controller: controller, onChanged: onChanged),
], ],
); );
} }
void _save() { Future<void> _openScanner(BuildContext context) async {
final navState = context.read<NavigationBloc>().state; if (!context.mounted) return;
final existing = navState.selectedBook;
final isEditing = existing != null && navState.prefilledData == null;
final Book book = ( final scannedBook = await Navigator.of(context, rootNavigator: true)
id: isEditing ? existing.id : '${Random().nextInt(100000)}', .push<Book>(
title: _titleController.text, MaterialPageRoute(
author: _authorController.text, builder: (_) => ScannerScreen(
genre: _genre, geminiApiKey: ApiConfig.geminiApiKey,
annotation: _annotationController.text, openaiApiKey: ApiConfig.openaiApiKey,
coverUrl: isEditing openaiBaseUrl: ApiConfig.openaiBaseUrl,
? 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) { if (scannedBook != null && context.mounted) {
context.read<BookBloc>().add(UpdateBook(book)); context.read<AddBookBloc>().add(ApplyScannedBook(scannedBook));
} else { }
context.read<BookBloc>().add(AddBook(book));
}
context.read<NavigationBloc>().add(NavigateTo(AppScreen.library));
} }
} }

View File

@@ -1,24 +1,22 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/book_bloc.dart'; import '../bloc/book/book_bloc.dart';
import '../bloc/navigation_bloc.dart'; import '../bloc/book/book_event.dart';
import '../models/models.dart'; import '../models/models.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
import '../theme/app_spacing.dart'; import '../theme/app_spacing.dart';
import 'add_book_screen.dart';
class BookDetailsScreen extends StatelessWidget { class BookDetailsScreen extends StatelessWidget {
const BookDetailsScreen({super.key}); final Book book;
const BookDetailsScreen({super.key, required this.book});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
return BlocBuilder<NavigationBloc, NavigationState>(
builder: (context, navState) {
final book = navState.selectedBook;
if (book == null) return const SizedBox.shrink();
final statusLabel = switch (book.status) { final statusLabel = switch (book.status) {
'reading' => 'Читаю', 'reading' => 'Читаю',
'done' => 'Прочитано', 'done' => 'Прочитано',
@@ -27,6 +25,7 @@ class BookDetailsScreen extends StatelessWidget {
}; };
return SingleChildScrollView( return SingleChildScrollView(
child: Material(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -60,9 +59,7 @@ class BookDetailsScreen extends StatelessWidget {
children: [ children: [
IconButton( IconButton(
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
onPressed: () => context.read<NavigationBloc>().add( onPressed: () => Navigator.pop(context),
NavigateTo(AppScreen.library),
),
), ),
IconButton( IconButton(
icon: const Icon(Icons.more_vert), icon: const Icon(Icons.more_vert),
@@ -92,10 +89,7 @@ class BookDetailsScreen extends StatelessWidget {
AppSpacing.radiusLarge, AppSpacing.radiusLarge,
), ),
child: book.coverUrl != null child: book.coverUrl != null
? Image.network( ? Image.network(book.coverUrl!, fit: BoxFit.cover)
book.coverUrl!,
fit: BoxFit.cover,
)
: Container( : Container(
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
child: Center( child: Center(
@@ -161,9 +155,7 @@ class BookDetailsScreen extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
border: Border.all(color: colorScheme.outline), border: Border.all(color: colorScheme.outline),
borderRadius: BorderRadius.circular( borderRadius: BorderRadius.circular(AppSpacing.radiusSmall),
AppSpacing.radiusSmall,
),
), ),
child: Text(book.genre, style: textTheme.labelMedium), child: Text(book.genre, style: textTheme.labelMedium),
), ),
@@ -174,10 +166,9 @@ class BookDetailsScreen extends StatelessWidget {
Expanded( Expanded(
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: () { onPressed: () {
context.read<NavigationBloc>().add( Navigator.of(context).push(
NavigateTo( MaterialPageRoute(
AppScreen.addBook, builder: (_) => AddBookScreen(editBook: book),
selectedBook: book,
), ),
); );
}, },
@@ -190,9 +181,7 @@ class BookDetailsScreen extends StatelessWidget {
child: OutlinedButton.icon( child: OutlinedButton.icon(
onPressed: () { onPressed: () {
context.read<BookBloc>().add(DeleteBook(book.id)); context.read<BookBloc>().add(DeleteBook(book.id));
context.read<NavigationBloc>().add( Navigator.pop(context);
NavigateTo(AppScreen.library),
);
}, },
icon: const Icon(Icons.delete_outline, size: 18), icon: const Icon(Icons.delete_outline, size: 18),
label: const Text('Удалить'), label: const Text('Удалить'),
@@ -255,8 +244,7 @@ class BookDetailsScreen extends StatelessWidget {
), ),
], ],
), ),
); ),
},
); );
} }

View File

@@ -1,36 +1,48 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/book_bloc.dart'; import '../bloc/book/book_bloc.dart';
import '../bloc/navigation_bloc.dart'; import '../bloc/book/book_state.dart';
import '../models/models.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 '../widgets/book_card.dart';
import '../theme/app_spacing.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}); const LibraryScreen({super.key});
@override @override
State<LibraryScreen> createState() => _LibraryScreenState(); Widget build(BuildContext context) {
return BlocProvider(
create: (context) => LibraryBloc(),
child: const _LibraryScreenContent(),
);
}
} }
class _LibraryScreenState extends State<LibraryScreen> { class _LibraryScreenContent extends StatelessWidget {
String _search = ''; const _LibraryScreenContent();
int _tabIndex = 0;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
return BlocBuilder<LibraryBloc, LibraryState>(
builder: (context, libraryState) {
return BlocBuilder<BookBloc, BookState>( return BlocBuilder<BookBloc, BookState>(
builder: (context, state) { builder: (context, bookState) {
final filtered = state.books.where((b) { final filtered = bookState.books.where((b) {
final q = _search.toLowerCase(); final q = libraryState.searchQuery.toLowerCase();
return b.title.toLowerCase().contains(q) || return b.title.toLowerCase().contains(q) ||
b.author.toLowerCase().contains(q); b.author.toLowerCase().contains(q);
}).toList(); }).toList();
return SafeArea( return Stack(
children: [
SafeArea(
child: Column( child: Column(
children: [ children: [
// Header // Header
@@ -44,7 +56,10 @@ class _LibraryScreenState extends State<LibraryScreen> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text('Книжная полка', style: textTheme.displayMedium), Text(
'Книжная полка',
style: textTheme.displayMedium,
),
IconButton( IconButton(
icon: const Icon(Icons.notifications_outlined), icon: const Icon(Icons.notifications_outlined),
onPressed: () {}, onPressed: () {},
@@ -61,10 +76,17 @@ class _LibraryScreenState extends State<LibraryScreen> {
0, 0,
), ),
child: TextField( child: TextField(
onChanged: (v) => setState(() => _search = v), onChanged: (v) {
context.read<LibraryBloc>().add(
UpdateSearchQuery(v),
);
},
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Поиск книг...', hintText: 'Поиск книг...',
prefixIcon: Icon(Icons.search, color: colorScheme.primary), prefixIcon: Icon(
Icons.search,
color: colorScheme.primary,
),
), ),
), ),
), ),
@@ -78,16 +100,26 @@ class _LibraryScreenState extends State<LibraryScreen> {
), ),
child: Row( child: Row(
children: [ children: [
_tab('Все книги', 0), _tab(
context,
'Все книги',
0,
libraryState.tabIndex,
),
const SizedBox(width: AppSpacing.sm), const SizedBox(width: AppSpacing.sm),
_tab('Категории', 1), _tab(
context,
'Категории',
1,
libraryState.tabIndex,
),
], ],
), ),
), ),
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
// Grid // Grid
Expanded( Expanded(
child: _tabIndex == 0 child: libraryState.tabIndex == 0
? GridView.builder( ? GridView.builder(
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(
AppSpacing.lg, AppSpacing.lg,
@@ -106,10 +138,11 @@ class _LibraryScreenState extends State<LibraryScreen> {
itemBuilder: (context, i) => BookCard( itemBuilder: (context, i) => BookCard(
book: filtered[i], book: filtered[i],
onTap: () { onTap: () {
context.read<NavigationBloc>().add( Navigator.of(context).push(
NavigateTo( MaterialPageRoute(
AppScreen.details, builder: (_) => BookDetailsScreen(
selectedBook: filtered[i], book: filtered[i],
),
), ),
); );
}, },
@@ -131,7 +164,9 @@ class _LibraryScreenState extends State<LibraryScreen> {
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surface, color: colorScheme.surface,
border: Border.all(color: colorScheme.outline), border: Border.all(
color: colorScheme.outline,
),
borderRadius: BorderRadius.circular( borderRadius: BorderRadius.circular(
AppSpacing.radiusMedium, AppSpacing.radiusMedium,
), ),
@@ -144,9 +179,8 @@ class _LibraryScreenState extends State<LibraryScreen> {
trailing: Text( trailing: Text(
'${filtered.where((b) => b.genre == genre).length}', '${filtered.where((b) => b.genre == genre).length}',
style: textTheme.bodyMedium?.copyWith( style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withValues( color: colorScheme.onSurface
alpha: 0.6, .withValues(alpha: 0.6),
),
), ),
), ),
), ),
@@ -156,19 +190,37 @@ class _LibraryScreenState extends State<LibraryScreen> {
), ),
], ],
), ),
),
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) { Widget _tab(BuildContext context, String label, int index, int currentIndex) {
final selected = _tabIndex == index; final selected = currentIndex == index;
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
final disableAnimations = MediaQuery.of(context).disableAnimations; final disableAnimations = MediaQuery.of(context).disableAnimations;
return GestureDetector( return GestureDetector(
onTap: () => setState(() => _tabIndex = index), onTap: () => context.read<LibraryBloc>().add(ChangeTab(index)),
child: AnimatedContainer( child: AnimatedContainer(
duration: disableAnimations duration: disableAnimations
? Duration.zero ? Duration.zero

View File

@@ -1,21 +1,175 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/navigation_bloc.dart'; import 'package:camera/camera.dart';
import '../models/models.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 { 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider(
create: (context) =>
ScannerBloc(cameraService: CameraService())..add(InitializeCamera()),
child: _ScannerScreenContent(
geminiApiKey: geminiApiKey,
openaiApiKey: openaiApiKey,
openaiBaseUrl: openaiBaseUrl,
),
);
}
}
class _ScannerScreenContent extends StatelessWidget {
final String? geminiApiKey;
final String? openaiApiKey;
final String openaiBaseUrl;
const _ScannerScreenContent({
this.geminiApiKey,
this.openaiApiKey,
this.openaiBaseUrl = 'http://localhost:8317',
});
void _showErrorDialog(BuildContext context, String errorMessage) {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Ошибка'),
content: Text(errorMessage),
actions: [
TextButton(
onPressed: () {
Navigator.of(dialogContext).pop();
context.read<ScannerBloc>().add(DismissError());
},
child: const Text('OK'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return BlocListener<ScannerBloc, ScannerState>(
listener: (context, state) {
// Show error dialog when error message is present
if (state.errorMessage != null) {
_showErrorDialog(context, state.errorMessage!);
}
// Navigate back with analyzed book
if (state.analyzedBook != null) {
Navigator.pop(context, state.analyzedBook);
}
},
child: BlocBuilder<ScannerBloc, ScannerState>(
builder: (context, state) {
final cameraService = context.read<ScannerBloc>().cameraService;
return Scaffold( return Scaffold(
backgroundColor: Colors.black, backgroundColor: Colors.black,
body: Stack( body: Stack(
children: [ children: [
// Camera placeholder // Camera preview
Container(color: Colors.black87), if (state.isInitialized && cameraService.controller != null)
// Scan frame Center(child: CameraPreview(cameraService.controller!))
Center( else if (state.hasPermissionError)
_buildPermissionError(context)
else
_buildLoading(),
// Scan frame overlay
if (state.isInitialized && !state.isAnalyzing)
_buildScanFrame(),
// Header
_buildHeader(context),
// Processing overlay
if (state.isAnalyzing) _buildProcessingOverlay(),
// Controls
if (state.isInitialized && !state.isAnalyzing)
_buildControls(context, state.isCapturing),
// Instructions
if (state.isInitialized && !state.isAnalyzing)
_buildInstructions(),
],
),
);
},
),
);
}
Widget _buildLoading() {
return const Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
);
}
Widget _buildPermissionError(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.camera_alt_outlined,
size: 64,
color: Colors.white38,
),
const SizedBox(height: 16),
const Text(
'Нет доступа к камере',
style: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
const Text(
'Разрешите доступ к камере для сканирования обложек книг',
style: TextStyle(color: Colors.white70, fontSize: 14),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () {
context.read<ScannerBloc>().add(InitializeCamera());
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF17CF54),
),
child: const Text('Повторить'),
),
],
),
),
);
}
Widget _buildScanFrame() {
return Center(
child: FractionallySizedBox( child: FractionallySizedBox(
widthFactor: 0.75, widthFactor: 0.75,
child: AspectRatio( child: AspectRatio(
@@ -25,19 +179,79 @@ class ScannerScreen extends StatelessWidget {
border: Border.all(color: Colors.white30, width: 2), border: Border.all(color: Colors.white30, width: 2),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: const Center( // Corner accents
child: Text( child: Stack(
'Камера недоступна\n(заглушка)', children: [
textAlign: TextAlign.center, // Top left corner
style: TextStyle(color: Colors.white38), 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),
), ),
), ),
), ),
), ),
// 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),
), ),
), ),
// Header ),
SafeArea( ),
// 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),
),
),
),
),
],
),
),
),
),
);
}
Widget _buildHeader(BuildContext context) {
return SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: Row( child: Row(
@@ -45,15 +259,10 @@ class ScannerScreen extends StatelessWidget {
children: [ children: [
IconButton( IconButton(
icon: const Icon(Icons.close, color: Colors.white), icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => context.read<NavigationBloc>().add( onPressed: () => Navigator.pop(context),
NavigateTo(AppScreen.addBook),
),
), ),
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF17CF54), color: const Color(0xFF17CF54),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
@@ -63,6 +272,7 @@ class ScannerScreen extends StatelessWidget {
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 12, fontSize: 12,
color: Colors.white,
), ),
), ),
), ),
@@ -70,30 +280,69 @@ class ScannerScreen extends StatelessWidget {
], ],
), ),
), ),
);
}
Widget _buildProcessingOverlay() {
return Container(
color: Colors.black87,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF17CF54)),
), ),
// Instructions const SizedBox(height: 24),
Positioned( const Text(
bottom: 140, 'Анализ обложки...',
left: 0, style: TextStyle(
right: 0, color: Colors.white,
child: Text( fontSize: 18,
'Поместите обложку в рамку', fontWeight: FontWeight.bold,
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey.shade400),
), ),
), ),
// Capture button const SizedBox(height: 8),
Positioned( Text(
'Это может занять несколько секунд',
style: TextStyle(color: Colors.grey.shade400, fontSize: 14),
),
],
),
),
);
}
Widget _buildControls(BuildContext context, bool isCapturing) {
return Positioned(
bottom: 50, bottom: 50,
left: 0, left: 0,
right: 0, right: 0,
child: Center( child: Row(
child: GestureDetector( mainAxisAlignment: MainAxisAlignment.center,
onTap: () { children: [
// Placeholder - no actual camera capture // Flash button (placeholder)
ScaffoldMessenger.of(context).showSnackBar( Container(
const SnackBar( width: 50,
content: Text('Камера не подключена (заглушка)'), 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
GestureDetector(
onTap: isCapturing
? null
: () {
context.read<ScannerBloc>().add(
CaptureAndAnalyze(
openaiApiKey: openaiApiKey,
openaiBaseUrl: openaiBaseUrl,
geminiApiKey: geminiApiKey,
), ),
); );
}, },
@@ -108,18 +357,55 @@ class ScannerScreen extends StatelessWidget {
child: Container( child: Container(
width: 64, width: 64,
height: 64, height: 64,
decoration: const BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: Colors.white, color: isCapturing ? Colors.white38 : Colors.white,
), ),
), ),
), ),
), ),
), ),
// Camera switch button
Container(
width: 50,
height: 50,
margin: const EdgeInsets.only(left: 20),
child: IconButton(
icon: const Icon(Icons.flip_camera_ios, color: Colors.white),
onPressed: () {
context.read<ScannerBloc>().add(SwitchCamera());
},
), ),
), ),
], ],
), ),
); );
} }
Widget _buildInstructions() {
return Positioned(
bottom: 140,
left: 0,
right: 0,
child: Column(
children: [
Text(
'Поместите обложку в рамку',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.grey.shade400,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
Text(
'Убедитесь, что текст читается четко',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey.shade500, fontSize: 12),
),
],
),
);
}
} }

View File

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

View File

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

View File

@@ -1,27 +1,15 @@
import 'package:flutter/material.dart'; 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 { class BottomNav extends StatelessWidget {
const BottomNav({super.key}); final int currentIndex;
final ValueChanged<int> onTap;
const BottomNav({super.key, required this.currentIndex, required this.onTap});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return BlocBuilder<NavigationBloc, NavigationState>(
builder: (context, state) {
final currentIndex = switch (state.screen) {
AppScreen.library ||
AppScreen.details ||
AppScreen.addBook ||
AppScreen.scanner => 0,
AppScreen.categories => 1,
AppScreen.wishlist => 2,
AppScreen.settings => 3,
};
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surface, color: colorScheme.surface,
@@ -38,19 +26,11 @@ class BottomNav extends StatelessWidget {
), ),
child: BottomNavigationBar( child: BottomNavigationBar(
currentIndex: currentIndex, currentIndex: currentIndex,
onTap: onTap,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
elevation: 0, elevation: 0,
selectedItemColor: colorScheme.primary, selectedItemColor: colorScheme.primary,
unselectedItemColor: colorScheme.onSurface.withValues(alpha: 0.6), unselectedItemColor: colorScheme.onSurface.withValues(alpha: 0.6),
onTap: (index) {
final screen = [
AppScreen.library,
AppScreen.categories,
AppScreen.wishlist,
AppScreen.settings,
][index];
context.read<NavigationBloc>().add(NavigateTo(screen));
},
items: const [ items: const [
BottomNavigationBarItem( BottomNavigationBarItem(
icon: Icon(Icons.local_library), icon: Icon(Icons.local_library),
@@ -71,7 +51,5 @@ class BottomNav extends StatelessWidget {
], ],
), ),
); );
},
);
} }
} }

View File

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

View File

@@ -1,6 +1,22 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: 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: async:
dependency: transitive dependency: transitive
description: description:
@@ -73,6 +89,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" 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: clock:
dependency: transitive dependency: transitive
description: description:
@@ -158,6 +190,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "9.1.1" 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: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -217,7 +257,7 @@ packages:
source: hosted source: hosted
version: "1.0.0" version: "1.0.0"
http: http:
dependency: transitive dependency: "direct main"
description: description:
name: http name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
@@ -232,6 +272,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" 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: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@@ -376,6 +432,62 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" 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: platform:
dependency: transitive dependency: transitive
description: description:
@@ -392,6 +504,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
posix:
dependency: transitive
description:
name: posix
sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61"
url: "https://pub.dev"
source: hosted
version: "6.0.3"
provider: provider:
dependency: transitive dependency: transitive
description: description:
@@ -509,6 +629,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yaml: yaml:
dependency: transitive dependency: transitive
description: description:

View File

@@ -36,6 +36,8 @@ dependencies:
camera: ^0.11.1 camera: ^0.11.1
google_generative_ai: ^0.4.6 google_generative_ai: ^0.4.6
google_fonts: ^6.2.1 google_fonts: ^6.2.1
permission_handler: ^11.0.0
http: ^1.2.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -47,6 +49,7 @@ dev_dependencies:
# package. See that file for information about deactivating specific lint # package. See that file for information about deactivating specific lint
# rules and activating additional ones. # rules and activating additional ones.
flutter_lints: ^6.0.0 flutter_lints: ^6.0.0
flutter_launcher_icons: ^0.14.1
# For information on the generic Dart part of this file, see the # For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec # following page: https://dart.dev/tools/pub/pubspec
@@ -89,3 +92,12 @@ flutter:
# #
# For details regarding fonts from package dependencies, # For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package # 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