412 lines
12 KiB
Dart
412 lines
12 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||
import 'package:camera/camera.dart';
|
||
import '../bloc/scanner/scanner_bloc.dart';
|
||
import '../bloc/scanner/scanner_event.dart';
|
||
import '../bloc/scanner/scanner_state.dart';
|
||
import '../services/camera_service.dart';
|
||
|
||
class ScannerScreen extends StatelessWidget {
|
||
final String? geminiApiKey;
|
||
final String? openaiApiKey;
|
||
final String openaiBaseUrl;
|
||
|
||
const ScannerScreen({
|
||
super.key,
|
||
this.geminiApiKey,
|
||
this.openaiApiKey,
|
||
this.openaiBaseUrl = 'http://localhost:8317',
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return 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(
|
||
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),
|
||
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(
|
||
widthFactor: 0.75,
|
||
child: AspectRatio(
|
||
aspectRatio: 2 / 3,
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
border: Border.all(color: Colors.white30, width: 2),
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
// Corner accents
|
||
child: Stack(
|
||
children: [
|
||
// Top left corner
|
||
Positioned(
|
||
top: -2,
|
||
left: -2,
|
||
child: Container(
|
||
width: 20,
|
||
height: 20,
|
||
decoration: const BoxDecoration(
|
||
border: Border(
|
||
left: BorderSide(color: Color(0xFF17CF54), width: 4),
|
||
top: BorderSide(color: Color(0xFF17CF54), width: 4),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
// 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),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildHeader(BuildContext context) {
|
||
return SafeArea(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(8),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
IconButton(
|
||
icon: const Icon(Icons.close, color: Colors.white),
|
||
onPressed: () => Navigator.pop(context),
|
||
),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFF17CF54),
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: const Text(
|
||
'СКАНЕР',
|
||
style: TextStyle(
|
||
fontWeight: FontWeight.bold,
|
||
fontSize: 12,
|
||
color: Colors.white,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 48),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildProcessingOverlay() {
|
||
return Container(
|
||
color: Colors.black87,
|
||
child: Center(
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
const CircularProgressIndicator(
|
||
valueColor: AlwaysStoppedAnimation<Color>(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
|
||
GestureDetector(
|
||
onTap: isCapturing
|
||
? null
|
||
: () {
|
||
context.read<ScannerBloc>().add(
|
||
CaptureAndAnalyze(
|
||
openaiApiKey: openaiApiKey,
|
||
openaiBaseUrl: openaiBaseUrl,
|
||
geminiApiKey: geminiApiKey,
|
||
),
|
||
);
|
||
},
|
||
child: Container(
|
||
width: 80,
|
||
height: 80,
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
border: Border.all(color: Colors.white, width: 4),
|
||
),
|
||
child: Center(
|
||
child: Container(
|
||
width: 64,
|
||
height: 64,
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
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),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|