Files
impact/lib/services/image_crop_service.dart

157 lines
5.1 KiB
Dart

/// Service de recadrage d'images.
///
/// Permet de recadrer une image en format carré (1:1) et de la sauvegarder
/// dans un fichier temporaire pour utilisation ultérieure.
library;
import 'dart:io';
import 'dart:math' as math;
import 'package:image/image.dart' as img;
import 'package:path_provider/path_provider.dart';
import 'package:uuid/uuid.dart';
/// Représente une zone de recadrage normalisée (0.0 à 1.0)
class CropRect {
/// Position X du coin supérieur gauche (0.0 à 1.0)
final double x;
/// Position Y du coin supérieur gauche (0.0 à 1.0)
final double y;
/// Largeur de la zone (0.0 à 1.0)
final double width;
/// Hauteur de la zone (0.0 à 1.0)
final double height;
const CropRect({
required this.x,
required this.y,
required this.width,
required this.height,
});
@override
String toString() => 'CropRect(x: $x, y: $y, w: $width, h: $height)';
}
/// Service pour recadrer les images
class ImageCropService {
final Uuid _uuid = const Uuid();
/// Taille de sortie maximale pour les images recadrées
static const int maxOutputSize = 1024;
/// Recadre une image en format carré en prenant en compte l'angle de rotation.
///
/// [sourcePath] - Chemin vers l'image source
/// [cropRect] - Zone de recadrage normalisée (0.0 à 1.0)
/// [rotationDegrees] - L'angle appliqué à la jauge haute précision du CropScreen
/// [outputSize] - Taille de sortie en pixels (carré)
///
/// Retourne le chemin vers l'image recadrée dans le dossier temporaire
Future<String> cropToSquare(
String sourcePath,
CropRect cropRect, {
double rotationDegrees = 0.0, // FIX : On intercepte la rotation ici !
int outputSize = maxOutputSize,
}) async {
// Charger l'image source
final file = File(sourcePath);
final bytes = await file.readAsBytes();
img.Image? originalImage = img.decodeImage(bytes);
if (originalImage == null) {
throw Exception('Impossible de décoder l\'image: $sourcePath');
}
// FIX CRITIQUE : Si l'utilisateur a pivoté l'image, on applique d'abord la rotation
// aux pixels bruts pour que le découpage rectiligne qui suit tape au bon endroit.
if (rotationDegrees != 0.0) {
originalImage = img.copyRotate(
originalImage,
angle: rotationDegrees,
interpolation: img.Interpolation.cubic,
);
}
// Calculer les coordonnées en pixels sur la géométrie de l'image redressée
final srcX = (cropRect.x * originalImage.width).round();
final srcY = (cropRect.y * originalImage.height).round();
final srcWidth = (cropRect.width * originalImage.width).round();
final srcHeight = (cropRect.height * originalImage.height).round();
// S'assurer que les dimensions sont valides
final clampedX = srcX.clamp(0, originalImage.width - 1);
final clampedY = srcY.clamp(0, originalImage.height - 1);
final clampedWidth = math.min(srcWidth, originalImage.width - clampedX);
final clampedHeight = math.min(srcHeight, originalImage.height - clampedY);
// Recadrer l'image redressée
img.Image cropped = img.copyCrop(
originalImage,
x: clampedX,
y: clampedY,
width: clampedWidth,
height: clampedHeight,
);
// Redimensionner à la taille de sortie si nécessaire
if (cropped.width != outputSize || cropped.height != outputSize) {
cropped = img.copyResize(
cropped,
width: outputSize,
height: outputSize,
interpolation: img.Interpolation.cubic,
);
}
// Sauvegarder dans le dossier temporaire
final tempDir = await getTemporaryDirectory();
final fileName = 'cropped_${_uuid.v4()}.jpg';
final outputPath = '${tempDir.path}/$fileName';
final outputFile = File(outputPath);
await outputFile.writeAsBytes(img.encodeJpg(cropped, quality: 90));
return outputPath;
}
/// Calcule la zone de recadrage carrée maximale centrée sur l'image
CropRect getDefaultSquareCrop(int imageWidth, int imageHeight) {
final aspectRatio = imageWidth / imageHeight;
if (aspectRatio > 1) {
final squareWidth = imageHeight / imageWidth;
final x = (1 - squareWidth) / 2;
return CropRect(x: x, y: 0, width: squareWidth, height: 1);
} else if (aspectRatio < 1) {
final squareHeight = imageWidth / imageHeight;
final y = (1 - squareHeight) / 2;
return CropRect(x: 0, y: y, width: 1, height: squareHeight);
} else {
return const CropRect(x: 0, y: 0, width: 1, height: 1);
}
}
/// Nettoie les fichiers temporaires de crop anciens
Future<void> cleanupOldCrops({Duration maxAge = const Duration(hours: 24)}) async {
try {
final tempDir = await getTemporaryDirectory();
final dir = Directory(tempDir.path);
final now = DateTime.now();
await for (final entity in dir.list()) {
if (entity is File && entity.path.contains('cropped_')) {
final stat = await entity.stat();
final age = now.difference(stat.modified);
if (age > maxAge) {
await entity.delete();
}
}
}
} catch (e) {
// Ignorer les erreurs
}
}
}