feat: implement base architecture and core repositories for weapon tracking and target analysis functionality
This commit is contained in:
@@ -1,286 +1,317 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../data/models/target_type.dart';
|
||||
import '../../services/image_crop_service.dart';
|
||||
import '../analysis/analysis_screen.dart';
|
||||
import 'widgets/crop_overlay.dart';
|
||||
|
||||
class CropScreen extends StatefulWidget {
|
||||
final String imagePath;
|
||||
final TargetType targetType;
|
||||
|
||||
const CropScreen({
|
||||
super.key,
|
||||
required this.imagePath,
|
||||
required this.targetType,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CropScreen> createState() => _CropScreenState();
|
||||
}
|
||||
|
||||
class _CropScreenState extends State<CropScreen> {
|
||||
final ImageCropService _cropService = ImageCropService();
|
||||
|
||||
// États de transformation
|
||||
double _scale = 1.0;
|
||||
double _baseScale = 1.0;
|
||||
Offset _offset = Offset.zero;
|
||||
Offset _startOffset = Offset.zero;
|
||||
Offset _startFocalPoint = Offset.zero;
|
||||
|
||||
bool _isLoading = false;
|
||||
bool _imageLoaded = false;
|
||||
Size? _imageSize;
|
||||
late Size _viewportSize;
|
||||
late double _cropSize;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadImageInfo();
|
||||
}
|
||||
|
||||
Future<void> _loadImageInfo() async {
|
||||
final file = File(widget.imagePath);
|
||||
final decodedImage = await decodeImageFromList(await file.readAsBytes());
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_imageSize = Size(
|
||||
decodedImage.width.toDouble(),
|
||||
decodedImage.height.toDouble(),
|
||||
);
|
||||
_imageLoaded = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF101214),
|
||||
appBar: AppBar(
|
||||
backgroundColor: const Color(0xFF101214),
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
title: const Text(
|
||||
'Validation image',
|
||||
style: TextStyle(color: Colors.white, fontSize: 18),
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator(color: Color(0xFF1A73E8)))
|
||||
: Column(
|
||||
children: [
|
||||
// Zone interactive de crop
|
||||
Expanded(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.white10),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: _imageLoaded ? _buildInteractiveCrop() : const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Bouton d'aide / Modifier
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.touch_app, color: Colors.white54, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Déplacez et zoomez pour cadrer',
|
||||
style: TextStyle(color: Colors.white.withOpacity(0.6), fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Boutons du bas
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 30),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: Colors.white24),
|
||||
padding: const EdgeInsets.symmetric(vertical: 15),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
child: const Text('Recommencer', style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: _onCropConfirm,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF1A73E8),
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 15),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
child: const Text('Valider', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInteractiveCrop() {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
_viewportSize = Size(constraints.maxWidth, constraints.maxHeight);
|
||||
_cropSize = math.min(constraints.maxWidth, constraints.maxHeight) * 0.9;
|
||||
|
||||
if (_scale == 1.0 && _offset == Offset.zero) {
|
||||
_initializeImagePosition();
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onScaleStart: _onScaleStart,
|
||||
onScaleUpdate: _onScaleUpdate,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Center(
|
||||
child: Transform(
|
||||
transform: Matrix4.identity()
|
||||
..setTranslationRaw(_offset.dx, _offset.dy, 0)
|
||||
..scale(_scale, _scale),
|
||||
alignment: Alignment.center,
|
||||
child: Image.file(
|
||||
File(widget.imagePath),
|
||||
fit: BoxFit.contain,
|
||||
width: _viewportSize.width,
|
||||
height: _viewportSize.height,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: CropOverlay(cropSize: _cropSize, showGrid: true),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _initializeImagePosition() {
|
||||
if (_imageSize == null) return;
|
||||
final imageAspect = _imageSize!.width / _imageSize!.height;
|
||||
final viewportAspect = _viewportSize.width / _viewportSize.height;
|
||||
|
||||
double displayWidth, displayHeight;
|
||||
if (imageAspect > viewportAspect) {
|
||||
displayWidth = _viewportSize.width;
|
||||
displayHeight = _viewportSize.width / imageAspect;
|
||||
} else {
|
||||
displayHeight = _viewportSize.height;
|
||||
displayWidth = _viewportSize.height * imageAspect;
|
||||
}
|
||||
|
||||
final minDisplayDim = math.min(displayWidth, displayHeight);
|
||||
_scale = _cropSize / minDisplayDim;
|
||||
if (_scale < 1.0) _scale = 1.0;
|
||||
}
|
||||
|
||||
void _onScaleStart(ScaleStartDetails details) {
|
||||
_baseScale = _scale;
|
||||
_startFocalPoint = details.focalPoint;
|
||||
_startOffset = _offset;
|
||||
}
|
||||
|
||||
void _onScaleUpdate(ScaleUpdateDetails details) {
|
||||
setState(() {
|
||||
_scale = (_baseScale * details.scale).clamp(0.5, 5.0);
|
||||
final delta = details.focalPoint - _startFocalPoint;
|
||||
_offset = _startOffset + delta;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onCropConfirm() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final cropRect = _calculateCropRect();
|
||||
final croppedPath = await _cropService.cropToSquare(widget.imagePath, cropRect);
|
||||
if (!mounted) return;
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => AnalysisScreen(
|
||||
imagePath: croppedPath,
|
||||
targetType: widget.targetType,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Erreur: $e'), backgroundColor: AppTheme.errorColor),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CropRect _calculateCropRect() {
|
||||
if (_imageSize == null) return const CropRect(x: 0, y: 0, width: 1, height: 1);
|
||||
final imageAspect = _imageSize!.width / _imageSize!.height;
|
||||
final viewportAspect = _viewportSize.width / _viewportSize.height;
|
||||
|
||||
double displayWidth, displayHeight;
|
||||
if (imageAspect > viewportAspect) {
|
||||
displayWidth = _viewportSize.width;
|
||||
displayHeight = _viewportSize.width / imageAspect;
|
||||
} else {
|
||||
displayHeight = _viewportSize.height;
|
||||
displayWidth = _viewportSize.height * imageAspect;
|
||||
}
|
||||
|
||||
final scaledWidth = displayWidth * _scale;
|
||||
final scaledHeight = displayHeight * _scale;
|
||||
final imageCenterX = _viewportSize.width / 2 + _offset.dx;
|
||||
final imageCenterY = _viewportSize.height / 2 + _offset.dy;
|
||||
final imageLeft = imageCenterX - scaledWidth / 2;
|
||||
final imageTop = imageCenterY - scaledHeight / 2;
|
||||
final cropLeft = (_viewportSize.width - _cropSize) / 2;
|
||||
final cropTop = (_viewportSize.height - _cropSize) / 2;
|
||||
|
||||
final relCropLeft = (cropLeft - imageLeft) / scaledWidth;
|
||||
final relCropTop = (cropTop - imageTop) / scaledHeight;
|
||||
final relCropSize = _cropSize / scaledWidth;
|
||||
final relCropSizeY = _cropSize / scaledHeight;
|
||||
|
||||
return CropRect(
|
||||
x: relCropLeft.clamp(0.0, 1.0),
|
||||
y: relCropTop.clamp(0.0, 1.0),
|
||||
width: relCropSize.clamp(0.0, 1.0 - relCropLeft.clamp(0.0, 1.0)),
|
||||
height: relCropSizeY.clamp(0.0, 1.0 - relCropTop.clamp(0.0, 1.0)),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../data/models/target_type.dart';
|
||||
import '../../services/image_crop_service.dart';
|
||||
import '../analysis/analysis_screen.dart';
|
||||
import 'widgets/crop_overlay.dart';
|
||||
|
||||
class CropScreen extends StatefulWidget {
|
||||
final String imagePath;
|
||||
final TargetType targetType;
|
||||
final double? initialScale;
|
||||
final Offset? initialOffset;
|
||||
|
||||
const CropScreen({
|
||||
super.key,
|
||||
required this.imagePath,
|
||||
required this.targetType,
|
||||
this.initialScale,
|
||||
this.initialOffset,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CropScreen> createState() => _CropScreenState();
|
||||
}
|
||||
|
||||
class _CropScreenState extends State<CropScreen> {
|
||||
final ImageCropService _cropService = ImageCropService();
|
||||
|
||||
// États de transformation
|
||||
double _scale = 1.0;
|
||||
double _baseScale = 1.0;
|
||||
Offset _offset = Offset.zero;
|
||||
Offset _startOffset = Offset.zero;
|
||||
Offset _startFocalPoint = Offset.zero;
|
||||
|
||||
bool _isLoading = false;
|
||||
bool _imageLoaded = false;
|
||||
Size? _imageSize;
|
||||
late Size _viewportSize;
|
||||
late double _cropSize;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadImageInfo();
|
||||
}
|
||||
|
||||
Future<void> _loadImageInfo() async {
|
||||
final file = File(widget.imagePath);
|
||||
final decodedImage = await decodeImageFromList(await file.readAsBytes());
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_imageSize = Size(
|
||||
decodedImage.width.toDouble(),
|
||||
decodedImage.height.toDouble(),
|
||||
);
|
||||
_imageLoaded = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF101214),
|
||||
appBar: AppBar(
|
||||
backgroundColor: const Color(0xFF101214),
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
title: const Text(
|
||||
'Centrage de la cible',
|
||||
style: TextStyle(color: Colors.white, fontSize: 18),
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator(color: Color(0xFF1A73E8)))
|
||||
: Column(
|
||||
children: [
|
||||
// Zone interactive de crop
|
||||
Expanded(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.white10),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: _imageLoaded ? _buildInteractiveCrop() : const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Bouton d'aide / Modifier
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.center_focus_strong, color: Colors.redAccent, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Alignez le centre de la cible sur la croix',
|
||||
style: TextStyle(color: Colors.white.withOpacity(0.8), fontSize: 14, fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Zoomez pour plus de précision',
|
||||
style: TextStyle(color: Colors.white.withOpacity(0.5), fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Boutons du bas
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 30),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: Colors.white24),
|
||||
padding: const EdgeInsets.symmetric(vertical: 15),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
child: const Text('Retour', style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: _onCropConfirm,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF1A73E8),
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 15),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
child: const Text('Analyser', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInteractiveCrop() {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
_viewportSize = Size(constraints.maxWidth, constraints.maxHeight);
|
||||
|
||||
// Le carré de crop occupe presque tout l'espace disponible
|
||||
_cropSize = math.min(constraints.maxWidth, constraints.maxHeight) * 0.95;
|
||||
|
||||
if (_scale == 1.0 && _offset == Offset.zero) {
|
||||
_initializeImagePosition();
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onScaleStart: _onScaleStart,
|
||||
onScaleUpdate: _onScaleUpdate,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Center(
|
||||
child: Transform(
|
||||
transform: Matrix4.identity()
|
||||
..setTranslationRaw(_offset.dx, _offset.dy, 0)
|
||||
..scale(_scale, _scale),
|
||||
alignment: Alignment.center,
|
||||
child: Image.file(
|
||||
File(widget.imagePath),
|
||||
fit: BoxFit.contain,
|
||||
width: _viewportSize.width,
|
||||
height: _viewportSize.height,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: CropOverlay(cropSize: _cropSize, showGrid: false),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _initializeImagePosition() {
|
||||
if (_imageSize == null) return;
|
||||
|
||||
final imageAspect = _imageSize!.width / _imageSize!.height;
|
||||
final viewportAspect = _viewportSize.width / _viewportSize.height;
|
||||
|
||||
double displayWidth, displayHeight;
|
||||
if (imageAspect > viewportAspect) {
|
||||
displayWidth = _viewportSize.width;
|
||||
displayHeight = _viewportSize.width / imageAspect;
|
||||
} else {
|
||||
displayHeight = _viewportSize.height;
|
||||
displayWidth = _viewportSize.height * imageAspect;
|
||||
}
|
||||
|
||||
final minDisplayDim = math.min(displayWidth, displayHeight);
|
||||
|
||||
_scale = widget.initialScale ?? (_cropSize / minDisplayDim);
|
||||
if (_scale < 1.0 && widget.initialScale == null) _scale = 1.0;
|
||||
|
||||
if (widget.initialOffset != null) {
|
||||
_offset = widget.initialOffset!;
|
||||
}
|
||||
}
|
||||
|
||||
void _onScaleStart(ScaleStartDetails details) {
|
||||
_baseScale = _scale;
|
||||
_startFocalPoint = details.focalPoint;
|
||||
_startOffset = _offset;
|
||||
}
|
||||
|
||||
void _onScaleUpdate(ScaleUpdateDetails details) {
|
||||
setState(() {
|
||||
_scale = (_baseScale * details.scale).clamp(0.8, 8.0);
|
||||
final delta = details.focalPoint - _startFocalPoint;
|
||||
_offset = _startOffset + delta;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onCropConfirm() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final cropRect = _calculateCropRect();
|
||||
// On calcule le centre relatif basé sur le centrage utilisateur
|
||||
final targetCenterX = cropRect.x + cropRect.width / 2;
|
||||
final targetCenterY = cropRect.y + cropRect.height / 2;
|
||||
|
||||
if (!mounted) return;
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => AnalysisScreen(
|
||||
imagePath: widget.imagePath,
|
||||
targetType: widget.targetType,
|
||||
targetCenter: Offset(targetCenterX, targetCenterY),
|
||||
cropScale: _scale,
|
||||
cropOffset: _offset,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Erreur: $e'), backgroundColor: AppTheme.errorColor),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CropRect _calculateCropRect() {
|
||||
if (_imageSize == null) return const CropRect(x: 0, y: 0, width: 1, height: 1);
|
||||
|
||||
final imageAspect = _imageSize!.width / _imageSize!.height;
|
||||
final viewportAspect = _viewportSize.width / _viewportSize.height;
|
||||
|
||||
double displayWidth, displayHeight;
|
||||
if (imageAspect > viewportAspect) {
|
||||
displayWidth = _viewportSize.width;
|
||||
displayHeight = _viewportSize.width / imageAspect;
|
||||
} else {
|
||||
displayHeight = _viewportSize.height;
|
||||
displayWidth = _viewportSize.height * imageAspect;
|
||||
}
|
||||
|
||||
final scaledWidth = displayWidth * _scale;
|
||||
final scaledHeight = displayHeight * _scale;
|
||||
|
||||
final imageCenterX = _viewportSize.width / 2 + _offset.dx;
|
||||
final imageCenterY = _viewportSize.height / 2 + _offset.dy;
|
||||
|
||||
final imageLeft = imageCenterX - scaledWidth / 2;
|
||||
final imageTop = imageCenterY - scaledHeight / 2;
|
||||
|
||||
final cropLeft = (_viewportSize.width - _cropSize) / 2;
|
||||
final cropTop = (_viewportSize.height - _cropSize) / 2;
|
||||
|
||||
final relCropLeft = (cropLeft - imageLeft) / scaledWidth;
|
||||
final relCropTop = (cropTop - imageTop) / scaledHeight;
|
||||
final relCropWidth = _cropSize / scaledWidth;
|
||||
final relCropHeight = _cropSize / scaledHeight;
|
||||
|
||||
return CropRect(
|
||||
x: relCropLeft,
|
||||
y: relCropTop,
|
||||
width: relCropWidth,
|
||||
height: relCropHeight,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user