openai service
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -45,5 +45,7 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>Для сканирования обложек книг и автоматического определения информации о книге</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -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));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,206 +52,244 @@ 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>(
|
||||||
child: Column(
|
listener: (context, state) {
|
||||||
children: [
|
// Update controllers when state changes (e.g., from scanned book)
|
||||||
// Header
|
if (_titleController.text != state.title) {
|
||||||
Padding(
|
_titleController.text = state.title;
|
||||||
padding: const EdgeInsets.fromLTRB(
|
}
|
||||||
AppSpacing.sm,
|
if (_authorController.text != state.author) {
|
||||||
AppSpacing.sm,
|
_authorController.text = state.author;
|
||||||
AppSpacing.sm,
|
}
|
||||||
0,
|
if (_annotationController.text != state.annotation) {
|
||||||
),
|
_annotationController.text = state.annotation;
|
||||||
child: Row(
|
}
|
||||||
children: [
|
|
||||||
IconButton(
|
// Navigate back when saved
|
||||||
icon: const Icon(Icons.arrow_back),
|
if (state.isSaved) {
|
||||||
onPressed: () => context.read<NavigationBloc>().add(
|
Navigator.pop(context);
|
||||||
isEditing
|
}
|
||||||
? NavigateTo(AppScreen.details)
|
},
|
||||||
: NavigateTo(AppScreen.library),
|
child: BlocBuilder<AddBookBloc, AddBookState>(
|
||||||
),
|
builder: (context, state) {
|
||||||
),
|
final title = state.isEditing ? 'Редактировать' : 'Добавить книгу';
|
||||||
Text(title, style: textTheme.headlineMedium),
|
|
||||||
],
|
return Material(
|
||||||
),
|
child: SafeArea(
|
||||||
),
|
child: Column(
|
||||||
Expanded(
|
children: [
|
||||||
child: ListView(
|
// Header
|
||||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
Padding(
|
||||||
children: [
|
padding: const EdgeInsets.fromLTRB(
|
||||||
// Cover placeholder / scanner trigger
|
AppSpacing.sm,
|
||||||
GestureDetector(
|
AppSpacing.sm,
|
||||||
onTap: () => context.read<NavigationBloc>().add(
|
AppSpacing.sm,
|
||||||
NavigateTo(AppScreen.scanner),
|
0,
|
||||||
),
|
|
||||||
child: Container(
|
|
||||||
height: 160,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border.all(color: colorScheme.outline),
|
|
||||||
borderRadius: BorderRadius.circular(
|
|
||||||
AppSpacing.radiusMedium,
|
|
||||||
),
|
|
||||||
color: colorScheme.surfaceContainerHighest,
|
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Row(
|
||||||
child: Column(
|
children: [
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
IconButton(
|
||||||
children: [
|
icon: const Icon(Icons.arrow_back),
|
||||||
Icon(
|
onPressed: () => Navigator.pop(context),
|
||||||
Icons.camera_alt,
|
),
|
||||||
size: 40,
|
Text(title, style: textTheme.headlineMedium),
|
||||||
color: colorScheme.primary,
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.sm),
|
),
|
||||||
Text(
|
Expanded(
|
||||||
'Загрузить или отсканировать',
|
child: ListView(
|
||||||
style: textTheme.bodyMedium?.copyWith(
|
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||||
color: colorScheme.onSurface.withValues(
|
children: [
|
||||||
alpha: 0.6,
|
// Cover placeholder / scanner trigger
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => _openScanner(context),
|
||||||
|
child: Container(
|
||||||
|
height: 160,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: colorScheme.outline),
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
AppSpacing.radiusMedium,
|
||||||
|
),
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.camera_alt,
|
||||||
|
size: 40,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.sm),
|
||||||
|
Text(
|
||||||
|
'Загрузить или отсканировать',
|
||||||
|
style: textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withValues(
|
||||||
|
alpha: 0.6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
_field(
|
||||||
|
context,
|
||||||
|
'Название',
|
||||||
|
_titleController,
|
||||||
|
textTheme,
|
||||||
|
(value) =>
|
||||||
|
context.read<AddBookBloc>().add(UpdateTitle(value)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
_field(
|
||||||
|
context,
|
||||||
|
'Автор',
|
||||||
|
_authorController,
|
||||||
|
textTheme,
|
||||||
|
(value) => context.read<AddBookBloc>().add(
|
||||||
|
UpdateAuthor(value),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
// Genre dropdown
|
||||||
|
Text('Жанр', style: textTheme.labelMedium),
|
||||||
|
const SizedBox(height: AppSpacing.xs),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
value: state.genre,
|
||||||
|
dropdownColor: colorScheme.surface,
|
||||||
|
decoration: const InputDecoration(),
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 'fiction',
|
||||||
|
child: Text('Фантастика'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 'fantasy',
|
||||||
|
child: Text('Фэнтези'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 'science',
|
||||||
|
child: Text('Научпоп'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 'biography',
|
||||||
|
child: Text('Биография'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 'detective',
|
||||||
|
child: Text('Детектив'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 'other',
|
||||||
|
child: Text('Другое'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: (v) {
|
||||||
|
if (v != null) {
|
||||||
|
context.read<AddBookBloc>().add(UpdateGenre(v));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
Text('Аннотация', style: textTheme.labelMedium),
|
||||||
|
const SizedBox(height: AppSpacing.xs),
|
||||||
|
TextField(
|
||||||
|
controller: _annotationController,
|
||||||
|
maxLines: 4,
|
||||||
|
onChanged: (value) => context.read<AddBookBloc>().add(
|
||||||
|
UpdateAnnotation(value),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 100),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Bottom actions
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: AppSpacing.screenPadding,
|
||||||
|
vertical: AppSpacing.md,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surface,
|
||||||
|
border: Border(
|
||||||
|
top: BorderSide(color: colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: colorScheme.shadow,
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, -2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Отмена'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: AppSpacing.md),
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () =>
|
||||||
|
context.read<AddBookBloc>().add(SaveBook()),
|
||||||
|
child: const Text('Сохранить'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
const SizedBox(height: AppSpacing.lg),
|
|
||||||
_field('Название', _titleController, textTheme),
|
|
||||||
const SizedBox(height: AppSpacing.md),
|
|
||||||
_field('Автор', _authorController, textTheme),
|
|
||||||
const SizedBox(height: AppSpacing.md),
|
|
||||||
// Genre dropdown
|
|
||||||
Text('Жанр', style: textTheme.labelMedium),
|
|
||||||
const SizedBox(height: AppSpacing.xs),
|
|
||||||
DropdownButtonFormField<String>(
|
|
||||||
initialValue: _genre,
|
|
||||||
dropdownColor: colorScheme.surface,
|
|
||||||
decoration: const InputDecoration(),
|
|
||||||
items: const [
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: 'fiction',
|
|
||||||
child: Text('Фантастика'),
|
|
||||||
),
|
|
||||||
DropdownMenuItem(value: 'fantasy', child: Text('Фэнтези')),
|
|
||||||
DropdownMenuItem(value: 'science', child: Text('Научпоп')),
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: 'biography',
|
|
||||||
child: Text('Биография'),
|
|
||||||
),
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: 'detective',
|
|
||||||
child: Text('Детектив'),
|
|
||||||
),
|
|
||||||
DropdownMenuItem(value: 'other', child: Text('Другое')),
|
|
||||||
],
|
|
||||||
onChanged: (v) => setState(() => _genre = v ?? _genre),
|
|
||||||
),
|
|
||||||
const SizedBox(height: AppSpacing.md),
|
|
||||||
Text('Аннотация', style: textTheme.labelMedium),
|
|
||||||
const SizedBox(height: AppSpacing.xs),
|
|
||||||
TextField(controller: _annotationController, maxLines: 4),
|
|
||||||
const SizedBox(height: 100),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Bottom actions
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: AppSpacing.screenPadding,
|
|
||||||
vertical: AppSpacing.md,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.surface,
|
|
||||||
border: Border(
|
|
||||||
top: BorderSide(color: colorScheme.outlineVariant),
|
|
||||||
),
|
),
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: colorScheme.shadow,
|
|
||||||
blurRadius: 8,
|
|
||||||
offset: const Offset(0, -2),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
child: Row(
|
);
|
||||||
children: [
|
},
|
||||||
Expanded(
|
|
||||||
child: OutlinedButton(
|
|
||||||
onPressed: () => context.read<NavigationBloc>().add(
|
|
||||||
isEditing
|
|
||||||
? NavigateTo(AppScreen.details)
|
|
||||||
: NavigateTo(AppScreen.library),
|
|
||||||
),
|
|
||||||
child: const Text('Отмена'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: AppSpacing.md),
|
|
||||||
Expanded(
|
|
||||||
flex: 2,
|
|
||||||
child: ElevatedButton(
|
|
||||||
onPressed: _save,
|
|
||||||
child: const Text('Сохранить'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _field(
|
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));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,262 +1,250 @@
|
|||||||
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>(
|
final statusLabel = switch (book.status) {
|
||||||
builder: (context, navState) {
|
'reading' => 'Читаю',
|
||||||
final book = navState.selectedBook;
|
'done' => 'Прочитано',
|
||||||
if (book == null) return const SizedBox.shrink();
|
'want_to_read' => 'Хочу прочитать',
|
||||||
|
_ => book.status,
|
||||||
|
};
|
||||||
|
|
||||||
final statusLabel = switch (book.status) {
|
return SingleChildScrollView(
|
||||||
'reading' => 'Читаю',
|
child: Material(
|
||||||
'done' => 'Прочитано',
|
child: Column(
|
||||||
'want_to_read' => 'Хочу прочитать',
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
_ => book.status,
|
children: [
|
||||||
};
|
// Hero section
|
||||||
|
Stack(
|
||||||
return SingleChildScrollView(
|
children: [
|
||||||
child: Column(
|
if (book.coverUrl != null)
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
SizedBox(
|
||||||
children: [
|
height: 300,
|
||||||
// Hero section
|
width: double.infinity,
|
||||||
Stack(
|
child: ShaderMask(
|
||||||
children: [
|
shaderCallback: (rect) => LinearGradient(
|
||||||
if (book.coverUrl != null)
|
begin: Alignment.topCenter,
|
||||||
SizedBox(
|
end: Alignment.bottomCenter,
|
||||||
height: 300,
|
colors: [colorScheme.surface, Colors.transparent],
|
||||||
width: double.infinity,
|
).createShader(rect),
|
||||||
child: ShaderMask(
|
blendMode: BlendMode.dstIn,
|
||||||
shaderCallback: (rect) => LinearGradient(
|
child: Image.network(book.coverUrl!, fit: BoxFit.cover),
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: Alignment.bottomCenter,
|
|
||||||
colors: [colorScheme.surface, Colors.transparent],
|
|
||||||
).createShader(rect),
|
|
||||||
blendMode: BlendMode.dstIn,
|
|
||||||
child: Image.network(book.coverUrl!, fit: BoxFit.cover),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SafeArea(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(
|
|
||||||
AppSpacing.sm,
|
|
||||||
AppSpacing.sm,
|
|
||||||
AppSpacing.sm,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.arrow_back),
|
|
||||||
onPressed: () => context.read<NavigationBloc>().add(
|
|
||||||
NavigateTo(AppScreen.library),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.more_vert),
|
|
||||||
onPressed: () {},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Positioned.fill(
|
SafeArea(
|
||||||
child: Align(
|
child: Padding(
|
||||||
alignment: Alignment.bottomCenter,
|
padding: const EdgeInsets.fromLTRB(
|
||||||
child: Hero(
|
AppSpacing.sm,
|
||||||
tag: 'book-cover-${book.id}',
|
AppSpacing.sm,
|
||||||
child: Container(
|
AppSpacing.sm,
|
||||||
width: 140,
|
0,
|
||||||
height: 210,
|
),
|
||||||
margin: const EdgeInsets.only(bottom: 0),
|
child: Row(
|
||||||
decoration: BoxDecoration(
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
borderRadius: BorderRadius.circular(
|
children: [
|
||||||
AppSpacing.radiusLarge,
|
IconButton(
|
||||||
),
|
icon: const Icon(Icons.arrow_back),
|
||||||
boxShadow: AppTheme.shadowXl,
|
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(
|
boxShadow: AppTheme.shadowXl,
|
||||||
borderRadius: BorderRadius.circular(
|
),
|
||||||
AppSpacing.radiusLarge,
|
child: ClipRRect(
|
||||||
),
|
borderRadius: BorderRadius.circular(
|
||||||
child: book.coverUrl != null
|
AppSpacing.radiusLarge,
|
||||||
? Image.network(
|
),
|
||||||
book.coverUrl!,
|
child: book.coverUrl != null
|
||||||
fit: BoxFit.cover,
|
? Image.network(book.coverUrl!, fit: BoxFit.cover)
|
||||||
)
|
: Container(
|
||||||
: Container(
|
color: colorScheme.surfaceContainerHighest,
|
||||||
color: colorScheme.surfaceContainerHighest,
|
child: Center(
|
||||||
child: Center(
|
child: Icon(
|
||||||
child: Icon(
|
Icons.book,
|
||||||
Icons.book,
|
color: colorScheme.primary.withValues(
|
||||||
color: colorScheme.primary.withValues(
|
alpha: 0.3,
|
||||||
alpha: 0.3,
|
|
||||||
),
|
|
||||||
size: 48,
|
|
||||||
),
|
),
|
||||||
|
size: 48,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
// Status badge
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: AppSpacing.md,
|
||||||
|
vertical: AppSpacing.sm,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primaryContainer,
|
||||||
|
border: Border.all(color: colorScheme.primary),
|
||||||
|
borderRadius: BorderRadius.circular(AppSpacing.radiusPill),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
statusLabel,
|
||||||
|
style: textTheme.labelMedium?.copyWith(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
// Title & Author
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(book.title, style: textTheme.displayMedium),
|
||||||
|
const SizedBox(height: AppSpacing.xs),
|
||||||
|
Text(
|
||||||
|
book.author,
|
||||||
|
style: textTheme.titleLarge?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
// Genre tag
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: AppSpacing.md,
|
||||||
|
vertical: AppSpacing.sm,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
border: Border.all(color: colorScheme.outline),
|
||||||
|
borderRadius: BorderRadius.circular(AppSpacing.radiusSmall),
|
||||||
|
),
|
||||||
|
child: Text(book.genre, style: textTheme.labelMedium),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
// Action buttons
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => AddBookScreen(editBook: book),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.edit, size: 18),
|
||||||
|
label: const Text('Изменить'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: AppSpacing.md),
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
context.read<BookBloc>().add(DeleteBook(book.id));
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.delete_outline, size: 18),
|
||||||
|
label: const Text('Удалить'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: colorScheme.error,
|
||||||
|
side: BorderSide(color: colorScheme.error),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
// About
|
||||||
|
Text('О книге', style: textTheme.headlineMedium),
|
||||||
|
const SizedBox(height: AppSpacing.sm),
|
||||||
|
Text(book.annotation, style: textTheme.bodyLarge),
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
// Info grid
|
||||||
|
GridView.count(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
mainAxisSpacing: AppSpacing.md,
|
||||||
|
crossAxisSpacing: AppSpacing.md,
|
||||||
|
childAspectRatio: 2.5,
|
||||||
|
children: [
|
||||||
|
_infoTile(
|
||||||
|
context,
|
||||||
|
Icons.menu_book,
|
||||||
|
Colors.blue,
|
||||||
|
'Страницы',
|
||||||
|
'${book.pages ?? "—"}',
|
||||||
|
),
|
||||||
|
_infoTile(
|
||||||
|
context,
|
||||||
|
Icons.language,
|
||||||
|
Colors.purple,
|
||||||
|
'Язык',
|
||||||
|
book.language ?? '—',
|
||||||
|
),
|
||||||
|
_infoTile(
|
||||||
|
context,
|
||||||
|
Icons.calendar_month,
|
||||||
|
Colors.orange,
|
||||||
|
'Год',
|
||||||
|
'${book.publishedYear ?? "—"}',
|
||||||
|
),
|
||||||
|
_infoTile(
|
||||||
|
context,
|
||||||
|
Icons.star,
|
||||||
|
Colors.amber,
|
||||||
|
'Рейтинг',
|
||||||
|
'${book.rating ?? "—"}',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 40),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.md),
|
),
|
||||||
// Status badge
|
],
|
||||||
Center(
|
),
|
||||||
child: Container(
|
),
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: AppSpacing.md,
|
|
||||||
vertical: AppSpacing.sm,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.primaryContainer,
|
|
||||||
border: Border.all(color: colorScheme.primary),
|
|
||||||
borderRadius: BorderRadius.circular(AppSpacing.radiusPill),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
statusLabel,
|
|
||||||
style: textTheme.labelMedium?.copyWith(
|
|
||||||
color: colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: AppSpacing.md),
|
|
||||||
// Title & Author
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(book.title, style: textTheme.displayMedium),
|
|
||||||
const SizedBox(height: AppSpacing.xs),
|
|
||||||
Text(
|
|
||||||
book.author,
|
|
||||||
style: textTheme.titleLarge?.copyWith(
|
|
||||||
color: colorScheme.onSurface.withValues(alpha: 0.7),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: AppSpacing.md),
|
|
||||||
// Genre tag
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: AppSpacing.md,
|
|
||||||
vertical: AppSpacing.sm,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.surfaceContainerHighest,
|
|
||||||
border: Border.all(color: colorScheme.outline),
|
|
||||||
borderRadius: BorderRadius.circular(
|
|
||||||
AppSpacing.radiusSmall,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Text(book.genre, style: textTheme.labelMedium),
|
|
||||||
),
|
|
||||||
const SizedBox(height: AppSpacing.lg),
|
|
||||||
// Action buttons
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: ElevatedButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
context.read<NavigationBloc>().add(
|
|
||||||
NavigateTo(
|
|
||||||
AppScreen.addBook,
|
|
||||||
selectedBook: book,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.edit, size: 18),
|
|
||||||
label: const Text('Изменить'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: AppSpacing.md),
|
|
||||||
Expanded(
|
|
||||||
child: OutlinedButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
context.read<BookBloc>().add(DeleteBook(book.id));
|
|
||||||
context.read<NavigationBloc>().add(
|
|
||||||
NavigateTo(AppScreen.library),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.delete_outline, size: 18),
|
|
||||||
label: const Text('Удалить'),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
foregroundColor: colorScheme.error,
|
|
||||||
side: BorderSide(color: colorScheme.error),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: AppSpacing.lg),
|
|
||||||
// About
|
|
||||||
Text('О книге', style: textTheme.headlineMedium),
|
|
||||||
const SizedBox(height: AppSpacing.sm),
|
|
||||||
Text(book.annotation, style: textTheme.bodyLarge),
|
|
||||||
const SizedBox(height: AppSpacing.lg),
|
|
||||||
// Info grid
|
|
||||||
GridView.count(
|
|
||||||
crossAxisCount: 2,
|
|
||||||
shrinkWrap: true,
|
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
|
||||||
mainAxisSpacing: AppSpacing.md,
|
|
||||||
crossAxisSpacing: AppSpacing.md,
|
|
||||||
childAspectRatio: 2.5,
|
|
||||||
children: [
|
|
||||||
_infoTile(
|
|
||||||
context,
|
|
||||||
Icons.menu_book,
|
|
||||||
Colors.blue,
|
|
||||||
'Страницы',
|
|
||||||
'${book.pages ?? "—"}',
|
|
||||||
),
|
|
||||||
_infoTile(
|
|
||||||
context,
|
|
||||||
Icons.language,
|
|
||||||
Colors.purple,
|
|
||||||
'Язык',
|
|
||||||
book.language ?? '—',
|
|
||||||
),
|
|
||||||
_infoTile(
|
|
||||||
context,
|
|
||||||
Icons.calendar_month,
|
|
||||||
Colors.orange,
|
|
||||||
'Год',
|
|
||||||
'${book.publishedYear ?? "—"}',
|
|
||||||
),
|
|
||||||
_infoTile(
|
|
||||||
context,
|
|
||||||
Icons.star,
|
|
||||||
Colors.amber,
|
|
||||||
'Рейтинг',
|
|
||||||
'${book.rating ?? "—"}',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 40),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,174 +1,226 @@
|
|||||||
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<BookBloc, BookState>(
|
return BlocBuilder<LibraryBloc, LibraryState>(
|
||||||
builder: (context, state) {
|
builder: (context, libraryState) {
|
||||||
final filtered = state.books.where((b) {
|
return BlocBuilder<BookBloc, BookState>(
|
||||||
final q = _search.toLowerCase();
|
builder: (context, bookState) {
|
||||||
return b.title.toLowerCase().contains(q) ||
|
final filtered = bookState.books.where((b) {
|
||||||
b.author.toLowerCase().contains(q);
|
final q = libraryState.searchQuery.toLowerCase();
|
||||||
}).toList();
|
return b.title.toLowerCase().contains(q) ||
|
||||||
|
b.author.toLowerCase().contains(q);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
return SafeArea(
|
return Stack(
|
||||||
child: Column(
|
children: [
|
||||||
children: [
|
SafeArea(
|
||||||
// Header
|
child: Column(
|
||||||
Padding(
|
children: [
|
||||||
padding: const EdgeInsets.fromLTRB(
|
// Header
|
||||||
AppSpacing.lg,
|
Padding(
|
||||||
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(
|
|
||||||
padding: const EdgeInsets.fromLTRB(
|
padding: const EdgeInsets.fromLTRB(
|
||||||
AppSpacing.lg,
|
AppSpacing.lg,
|
||||||
0,
|
AppSpacing.md,
|
||||||
AppSpacing.lg,
|
AppSpacing.lg,
|
||||||
100,
|
0,
|
||||||
),
|
),
|
||||||
gridDelegate:
|
child: Row(
|
||||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
crossAxisCount: 2,
|
children: [
|
||||||
childAspectRatio: 0.55,
|
Text(
|
||||||
crossAxisSpacing: AppSpacing.md,
|
'Книжная полка',
|
||||||
mainAxisSpacing: AppSpacing.md,
|
style: textTheme.displayMedium,
|
||||||
),
|
),
|
||||||
itemCount: filtered.length,
|
IconButton(
|
||||||
itemBuilder: (context, i) => BookCard(
|
icon: const Icon(Icons.notifications_outlined),
|
||||||
book: filtered[i],
|
onPressed: () {},
|
||||||
onTap: () {
|
),
|
||||||
context.read<NavigationBloc>().add(
|
],
|
||||||
NavigateTo(
|
),
|
||||||
AppScreen.details,
|
),
|
||||||
selectedBook: filtered[i],
|
// Search
|
||||||
),
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
AppSpacing.lg,
|
||||||
|
AppSpacing.md,
|
||||||
|
AppSpacing.lg,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
child: TextField(
|
||||||
|
onChanged: (v) {
|
||||||
|
context.read<LibraryBloc>().add(
|
||||||
|
UpdateSearchQuery(v),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Поиск книг...',
|
||||||
|
prefixIcon: Icon(
|
||||||
|
Icons.search,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
: ListView(
|
// Tabs
|
||||||
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(
|
padding: const EdgeInsets.fromLTRB(
|
||||||
AppSpacing.lg,
|
AppSpacing.lg,
|
||||||
0,
|
AppSpacing.md,
|
||||||
AppSpacing.lg,
|
AppSpacing.lg,
|
||||||
100,
|
0,
|
||||||
),
|
),
|
||||||
children: [
|
child: Row(
|
||||||
for (final genre
|
children: [
|
||||||
in filtered.map((b) => b.genre).toSet())
|
_tab(
|
||||||
Container(
|
context,
|
||||||
margin: const EdgeInsets.only(
|
'Все книги',
|
||||||
bottom: AppSpacing.sm,
|
0,
|
||||||
),
|
libraryState.tabIndex,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
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) {
|
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
|
||||||
|
|||||||
@@ -1,123 +1,409 @@
|
|||||||
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 Scaffold(
|
return BlocProvider(
|
||||||
backgroundColor: Colors.black,
|
create: (context) =>
|
||||||
body: Stack(
|
ScannerBloc(cameraService: CameraService())..add(InitializeCamera()),
|
||||||
children: [
|
child: _ScannerScreenContent(
|
||||||
// Camera placeholder
|
geminiApiKey: geminiApiKey,
|
||||||
Container(color: Colors.black87),
|
openaiApiKey: openaiApiKey,
|
||||||
// Scan frame
|
openaiBaseUrl: openaiBaseUrl,
|
||||||
Center(
|
),
|
||||||
child: FractionallySizedBox(
|
);
|
||||||
widthFactor: 0.75,
|
}
|
||||||
child: AspectRatio(
|
}
|
||||||
aspectRatio: 2 / 3,
|
|
||||||
child: Container(
|
class _ScannerScreenContent extends StatelessWidget {
|
||||||
decoration: BoxDecoration(
|
final String? geminiApiKey;
|
||||||
border: Border.all(color: Colors.white30, width: 2),
|
final String? openaiApiKey;
|
||||||
borderRadius: BorderRadius.circular(12),
|
final String openaiBaseUrl;
|
||||||
),
|
|
||||||
child: const Center(
|
const _ScannerScreenContent({
|
||||||
child: Text(
|
this.geminiApiKey,
|
||||||
'Камера недоступна\n(заглушка)',
|
this.openaiApiKey,
|
||||||
textAlign: TextAlign.center,
|
this.openaiBaseUrl = 'http://localhost:8317',
|
||||||
style: TextStyle(color: Colors.white38),
|
});
|
||||||
),
|
|
||||||
),
|
void _showErrorDialog(BuildContext context, String errorMessage) {
|
||||||
),
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: const Text('Ошибка'),
|
||||||
|
content: Text(errorMessage),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(dialogContext).pop();
|
||||||
|
context.read<ScannerBloc>().add(DismissError());
|
||||||
|
},
|
||||||
|
child: const Text('OK'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocListener<ScannerBloc, ScannerState>(
|
||||||
|
listener: (context, state) {
|
||||||
|
// Show error dialog when error message is present
|
||||||
|
if (state.errorMessage != null) {
|
||||||
|
_showErrorDialog(context, state.errorMessage!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate back with analyzed book
|
||||||
|
if (state.analyzedBook != null) {
|
||||||
|
Navigator.pop(context, state.analyzedBook);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: BlocBuilder<ScannerBloc, ScannerState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
final cameraService = context.read<ScannerBloc>().cameraService;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
// Camera preview
|
||||||
|
if (state.isInitialized && cameraService.controller != null)
|
||||||
|
Center(child: CameraPreview(cameraService.controller!))
|
||||||
|
else if (state.hasPermissionError)
|
||||||
|
_buildPermissionError(context)
|
||||||
|
else
|
||||||
|
_buildLoading(),
|
||||||
|
|
||||||
|
// Scan frame overlay
|
||||||
|
if (state.isInitialized && !state.isAnalyzing)
|
||||||
|
_buildScanFrame(),
|
||||||
|
|
||||||
|
// Header
|
||||||
|
_buildHeader(context),
|
||||||
|
|
||||||
|
// Processing overlay
|
||||||
|
if (state.isAnalyzing) _buildProcessingOverlay(),
|
||||||
|
|
||||||
|
// Controls
|
||||||
|
if (state.isInitialized && !state.isAnalyzing)
|
||||||
|
_buildControls(context, state.isCapturing),
|
||||||
|
|
||||||
|
// Instructions
|
||||||
|
if (state.isInitialized && !state.isAnalyzing)
|
||||||
|
_buildInstructions(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLoading() {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPermissionError(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.camera_alt_outlined,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.white38,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text(
|
||||||
|
'Нет доступа к камере',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 8),
|
||||||
// Header
|
const Text(
|
||||||
SafeArea(
|
'Разрешите доступ к камере для сканирования обложек книг',
|
||||||
child: Padding(
|
style: TextStyle(color: Colors.white70, fontSize: 14),
|
||||||
padding: const EdgeInsets.all(8),
|
textAlign: TextAlign.center,
|
||||||
child: Row(
|
),
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
const SizedBox(height: 24),
|
||||||
children: [
|
ElevatedButton(
|
||||||
IconButton(
|
onPressed: () {
|
||||||
icon: const Icon(Icons.close, color: Colors.white),
|
context.read<ScannerBloc>().add(InitializeCamera());
|
||||||
onPressed: () => context.read<NavigationBloc>().add(
|
},
|
||||||
NavigateTo(AppScreen.addBook),
|
style: ElevatedButton.styleFrom(
|
||||||
),
|
backgroundColor: const Color(0xFF17CF54),
|
||||||
),
|
),
|
||||||
Container(
|
child: const Text('Повторить'),
|
||||||
padding: const EdgeInsets.symmetric(
|
),
|
||||||
horizontal: 12,
|
],
|
||||||
vertical: 6,
|
),
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
);
|
||||||
color: const Color(0xFF17CF54),
|
}
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
Widget _buildScanFrame() {
|
||||||
child: const Text(
|
return Center(
|
||||||
'СКАНЕР',
|
child: FractionallySizedBox(
|
||||||
style: TextStyle(
|
widthFactor: 0.75,
|
||||||
fontWeight: FontWeight.bold,
|
child: AspectRatio(
|
||||||
fontSize: 12,
|
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(
|
Widget _buildHeader(BuildContext context) {
|
||||||
'Поместите обложку в рамку',
|
return SafeArea(
|
||||||
textAlign: TextAlign.center,
|
child: Padding(
|
||||||
style: TextStyle(color: Colors.grey.shade400),
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close, color: Colors.white),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF17CF54),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'СКАНЕР',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 48),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildProcessingOverlay() {
|
||||||
|
return Container(
|
||||||
|
color: Colors.black87,
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const CircularProgressIndicator(
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF17CF54)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Text(
|
||||||
|
'Анализ обложки...',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Это может занять несколько секунд',
|
||||||
|
style: TextStyle(color: Colors.grey.shade400, fontSize: 14),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildControls(BuildContext context, bool isCapturing) {
|
||||||
|
return Positioned(
|
||||||
|
bottom: 50,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// Flash button (placeholder)
|
||||||
|
Container(
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
margin: const EdgeInsets.only(right: 20),
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.flash_off, color: Colors.white),
|
||||||
|
onPressed: () {
|
||||||
|
// Flash functionality can be added later
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Capture button
|
// Capture button
|
||||||
Positioned(
|
GestureDetector(
|
||||||
bottom: 50,
|
onTap: isCapturing
|
||||||
left: 0,
|
? null
|
||||||
right: 0,
|
: () {
|
||||||
child: Center(
|
context.read<ScannerBloc>().add(
|
||||||
child: GestureDetector(
|
CaptureAndAnalyze(
|
||||||
onTap: () {
|
openaiApiKey: openaiApiKey,
|
||||||
// Placeholder - no actual camera capture
|
openaiBaseUrl: openaiBaseUrl,
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
geminiApiKey: geminiApiKey,
|
||||||
const SnackBar(
|
),
|
||||||
content: Text('Камера не подключена (заглушка)'),
|
);
|
||||||
),
|
},
|
||||||
);
|
child: Container(
|
||||||
},
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: Colors.white, width: 4),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 80,
|
width: 64,
|
||||||
height: 80,
|
height: 64,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(color: Colors.white, width: 4),
|
color: isCapturing ? Colors.white38 : Colors.white,
|
||||||
),
|
|
||||||
child: Center(
|
|
||||||
child: Container(
|
|
||||||
width: 64,
|
|
||||||
height: 64,
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: 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),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
|
||||||
return null;
|
Future<Book?> analyzeBookCover(String imagePath) async {
|
||||||
|
try {
|
||||||
|
// Read the image file
|
||||||
|
final imageFile = File(imagePath);
|
||||||
|
final imageBytes = await imageFile.readAsBytes();
|
||||||
|
final base64Image = base64Encode(imageBytes);
|
||||||
|
|
||||||
|
// Create the prompt for book analysis
|
||||||
|
const prompt = '''
|
||||||
|
Analyze this book cover image and extract the following information in JSON format:
|
||||||
|
{
|
||||||
|
"title": "book title (required)",
|
||||||
|
"author": "author name (required)",
|
||||||
|
"genre": "fiction/fantasy/science/detective/biography/other",
|
||||||
|
"annotation": "brief description or summary if visible, otherwise generate a generic one"
|
||||||
|
}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Extract exact text from the cover
|
||||||
|
- If genre is unclear, choose the most appropriate one
|
||||||
|
- If annotation is not visible, create a brief generic description
|
||||||
|
- Return ONLY valid JSON, no additional text
|
||||||
|
- Ensure all required fields are present
|
||||||
|
''';
|
||||||
|
|
||||||
|
// Create the image part for the model
|
||||||
|
final imagePart = Content.data('image/jpeg', imageBytes);
|
||||||
|
|
||||||
|
// Generate content with both text and image
|
||||||
|
final response = await _model.generateContent([
|
||||||
|
Content.text(prompt),
|
||||||
|
imagePart,
|
||||||
|
]);
|
||||||
|
|
||||||
|
final responseText = response.text?.trim();
|
||||||
|
|
||||||
|
if (responseText == null || responseText.isEmpty) {
|
||||||
|
print('Empty response from Gemini');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract JSON from response (handle potential markdown formatting)
|
||||||
|
String jsonString = responseText;
|
||||||
|
if (jsonString.contains('```json')) {
|
||||||
|
jsonString = jsonString.split('```json')[1].split('```')[0].trim();
|
||||||
|
} else if (jsonString.contains('```')) {
|
||||||
|
jsonString = jsonString.split('```')[1].split('```')[0].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JSON response
|
||||||
|
final Map<String, dynamic> jsonData = json.decode(jsonString);
|
||||||
|
|
||||||
|
// Create Book object with extracted data
|
||||||
|
final Book book = (
|
||||||
|
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
|
title: jsonData['title']?.toString() ?? 'Неизвестная книга',
|
||||||
|
author: jsonData['author']?.toString() ?? 'Неизвестный автор',
|
||||||
|
genre: _normalizeGenre(jsonData['genre']?.toString()),
|
||||||
|
annotation: jsonData['annotation']?.toString() ?? 'Нет описания',
|
||||||
|
coverUrl: null, // Will be set by the caller
|
||||||
|
pages: null,
|
||||||
|
language: 'Russian',
|
||||||
|
publishedYear: DateTime.now().year,
|
||||||
|
rating: 5.0,
|
||||||
|
status: 'want_to_read',
|
||||||
|
progress: null,
|
||||||
|
isFavorite: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
return book;
|
||||||
|
} catch (e) {
|
||||||
|
print('Error analyzing book cover: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _normalizeGenre(String? genre) {
|
||||||
|
if (genre == null || genre.isEmpty) return 'other';
|
||||||
|
|
||||||
|
final normalized = genre.toLowerCase().trim();
|
||||||
|
|
||||||
|
// Map various genre names to our standard genres
|
||||||
|
final genreMap = {
|
||||||
|
'фантастика': 'fiction',
|
||||||
|
'fantasy': 'fantasy',
|
||||||
|
'фэнтези': 'fantasy',
|
||||||
|
'science': 'science',
|
||||||
|
'научпоп': 'science',
|
||||||
|
'научная': 'science',
|
||||||
|
'biography': 'biography',
|
||||||
|
'биография': 'biography',
|
||||||
|
'detective': 'detective',
|
||||||
|
'детектив': 'detective',
|
||||||
|
'роман': 'other',
|
||||||
|
'novel': 'other',
|
||||||
|
'poetry': 'other',
|
||||||
|
'поэзия': 'other',
|
||||||
|
};
|
||||||
|
|
||||||
|
return genreMap[normalized] ?? normalized;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,77 +1,55 @@
|
|||||||
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>(
|
return Container(
|
||||||
builder: (context, state) {
|
decoration: BoxDecoration(
|
||||||
final currentIndex = switch (state.screen) {
|
color: colorScheme.surface,
|
||||||
AppScreen.library ||
|
border: Border(
|
||||||
AppScreen.details ||
|
top: BorderSide(color: colorScheme.outlineVariant, width: 1),
|
||||||
AppScreen.addBook ||
|
),
|
||||||
AppScreen.scanner => 0,
|
boxShadow: [
|
||||||
AppScreen.categories => 1,
|
BoxShadow(
|
||||||
AppScreen.wishlist => 2,
|
color: colorScheme.shadow,
|
||||||
AppScreen.settings => 3,
|
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,
|
child: BottomNavigationBar(
|
||||||
elevation: 0,
|
currentIndex: currentIndex,
|
||||||
selectedItemColor: colorScheme.primary,
|
onTap: onTap,
|
||||||
unselectedItemColor: colorScheme.onSurface.withValues(alpha: 0.6),
|
backgroundColor: Colors.transparent,
|
||||||
onTap: (index) {
|
elevation: 0,
|
||||||
final screen = [
|
selectedItemColor: colorScheme.primary,
|
||||||
AppScreen.library,
|
unselectedItemColor: colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
AppScreen.categories,
|
items: const [
|
||||||
AppScreen.wishlist,
|
BottomNavigationBarItem(
|
||||||
AppScreen.settings,
|
icon: Icon(Icons.local_library),
|
||||||
][index];
|
label: 'Библиотека',
|
||||||
context.read<NavigationBloc>().add(NavigateTo(screen));
|
|
||||||
},
|
|
||||||
items: const [
|
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: Icon(Icons.local_library),
|
|
||||||
label: 'Библиотека',
|
|
||||||
),
|
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: Icon(Icons.category),
|
|
||||||
label: 'Категории',
|
|
||||||
),
|
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: Icon(Icons.bookmark),
|
|
||||||
label: 'Избранное',
|
|
||||||
),
|
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: Icon(Icons.settings),
|
|
||||||
label: 'Настройки',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
BottomNavigationBarItem(
|
||||||
},
|
icon: Icon(Icons.category),
|
||||||
|
label: 'Категории',
|
||||||
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.bookmark),
|
||||||
|
label: 'Избранное',
|
||||||
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.settings),
|
||||||
|
label: 'Настройки',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user