Files

4647 lines
156 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:camera/camera.dart';
import 'package:google_mlkit_face_detection/google_mlkit_face_detection.dart';
import 'package:image_picker/image_picker.dart';
import 'package:image/image.dart' as img;
import 'package:path_provider/path_provider.dart';
import 'api_service.dart';
bool get _mlKitFaceDetectionSupported => Platform.isAndroid || Platform.isIOS;
void main() {
runApp(const AestheticPhotoPlannerApp());
}
class AestheticPhotoPlannerApp extends StatelessWidget {
const AestheticPhotoPlannerApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Aesthetic Photo Planner',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
colorSchemeSeed: const Color(0xFF6D4C7D),
inputDecorationTheme: const InputDecorationTheme(border: OutlineInputBorder()),
),
home: const PatientsScreen(),
);
}
}
class PatientsScreen extends StatefulWidget {
const PatientsScreen({super.key});
@override
State<PatientsScreen> createState() => _PatientsScreenState();
}
class _PatientsScreenState extends State<PatientsScreen> {
final ApiService api = ApiService();
final TextEditingController searchController = TextEditingController();
late Future<List<Patient>> futurePatients;
@override
void initState() {
super.initState();
futurePatients = api.getPatients();
searchController.addListener(reload);
}
@override
void dispose() {
searchController.dispose();
super.dispose();
}
void reload() {
setState(() {
futurePatients = api.getPatients(query: searchController.text);
});
}
Future<void> createPatientDialog() async {
final result = await showDialog<PatientFormResult>(
context: context,
builder: (context) => const PatientFormDialog(title: 'Nouveau patient'),
);
if (result == null) return;
await api.createPatient(
firstName: result.firstName,
lastName: result.lastName,
birthDate: result.birthDate,
phone: result.phone,
email: result.email,
procedure: result.procedure,
status: result.status,
consentSigned: result.consentSigned,
notes: result.notes,
);
reload();
}
Widget _buildPatientCard(Patient patient) {
final subtitleItems = <String>[];
if ((patient.procedure ?? '').isNotEmpty) subtitleItems.add(patient.procedure!);
if ((patient.phone ?? '').isNotEmpty) subtitleItems.add(patient.phone!);
if ((patient.notes ?? '').isNotEmpty) subtitleItems.add(patient.notes!);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: ListTile(
leading: CircleAvatar(child: Text(patient.displayName.isEmpty ? '?' : _initial(patient.displayName))),
title: Text(patient.displayName, style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Text(subtitleItems.isEmpty ? 'Aucune information clinique' : subtitleItems.join(' • ')),
trailing: Wrap(
spacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_StatusChip(label: patient.status ?? 'actif'),
const Icon(Icons.chevron_right),
],
),
onTap: () async {
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => PatientDetailScreen(initialPatient: patient)),
);
reload();
},
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Patients'),
actions: [
IconButton(onPressed: reload, icon: const Icon(Icons.refresh), tooltip: 'Actualiser'),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: createPatientDialog,
icon: const Icon(Icons.person_add),
label: const Text('Patient'),
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
child: TextField(
controller: searchController,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search),
labelText: 'Recherche patient, acte, téléphone, note',
suffixIcon: searchController.text.isEmpty
? null
: IconButton(
onPressed: () => searchController.clear(),
icon: const Icon(Icons.close),
),
),
),
),
Expanded(
child: FutureBuilder<List<Patient>>(
future: futurePatients,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) return const Center(child: CircularProgressIndicator());
if (snapshot.hasError) return Center(child: Text('Erreur: ${snapshot.error}'));
final patients = snapshot.data ?? [];
if (patients.isEmpty) return const Center(child: Text('Aucun patient'));
return ListView.builder(
itemCount: patients.length,
itemBuilder: (context, index) => _buildPatientCard(patients[index]),
);
},
),
),
],
),
);
}
}
class PatientDetailScreen extends StatefulWidget {
final Patient initialPatient;
const PatientDetailScreen({super.key, required this.initialPatient});
@override
State<PatientDetailScreen> createState() => _PatientDetailScreenState();
}
class _PatientDetailScreenState extends State<PatientDetailScreen> {
final ApiService api = ApiService();
final ImagePicker picker = ImagePicker();
late Patient patient;
late Future<List<PatientPhoto>> futurePhotos;
bool uploading = false;
@override
void initState() {
super.initState();
patient = widget.initialPatient;
futurePhotos = api.getPatientPhotos(patient.id);
}
void reloadPhotos() {
setState(() {
futurePhotos = api.getPatientPhotos(patient.id);
});
}
Future<void> editPatient() async {
final result = await showDialog<PatientFormResult>(
context: context,
builder: (context) => PatientFormDialog(title: 'Modifier patient', patient: patient),
);
if (result == null) return;
final updated = await api.updatePatient(
patientId: patient.id,
firstName: result.firstName,
lastName: result.lastName,
birthDate: result.birthDate,
phone: result.phone,
email: result.email,
procedure: result.procedure,
status: result.status,
consentSigned: result.consentSigned,
notes: result.notes,
);
setState(() => patient = updated);
}
Future<void> pickPhoto(ImageSource source) async {
final picked = await picker.pickImage(source: source, imageQuality: 95);
if (picked == null) return;
final imageFile = File(picked.path);
final annotated = await Navigator.push<File?>(
context,
MaterialPageRoute(builder: (_) => AnnotationScreen(imageFile: imageFile)),
);
setState(() => uploading = true);
try {
await api.uploadPatientPhoto(
patientId: patient.id,
file: imageFile,
kind: 'original',
notes: 'Photo originale',
);
if (annotated != null) {
await api.uploadPatientPhoto(
patientId: patient.id,
file: annotated,
kind: 'annotated',
notes: 'Planification annotée',
);
}
reloadPhotos();
} finally {
if (mounted) setState(() => uploading = false);
}
}
Future<void> openLiveSimulation() async {
final result = await Navigator.push<LiveSimulationResult>(
context,
MaterialPageRoute(builder: (_) => const LiveFaceSimulationScreen()),
);
if (result == null) return;
setState(() => uploading = true);
try {
await api.uploadPatientPhoto(
patientId: patient.id,
file: result.originalFile,
kind: 'original',
notes: 'Capture live originale',
);
await api.uploadPatientPhoto(
patientId: patient.id,
file: result.planningFile,
kind: 'planification',
notes: 'Simulation live avec repères visage - non contractuelle',
);
reloadPhotos();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Capture live ajoutée au dossier patient.')),
);
}
} finally {
if (mounted) setState(() => uploading = false);
}
}
Future<void> openConsultations() async {
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => ConsultationsScreen(patient: patient)),
);
}
Future<void> openDocuments() async {
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => DocumentsScreen(patient: patient)),
);
}
Future<void> importImmichFace() async {
final asset = await showDialog<ImmichAsset>(
context: context,
builder: (context) => const ImmichPeopleDialog(),
);
if (asset == null) return;
setState(() => uploading = true);
try {
await api.importImmichAsset(patientId: patient.id, assetId: asset.id);
reloadPhotos();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Photo Immich ajoutée: ${asset.displayName}')),
);
}
} finally {
if (mounted) setState(() => uploading = false);
}
}
Future<void> annotateExistingPhoto(PatientPhoto photo) async {
setState(() => uploading = true);
try {
final localFile = await api.downloadPhotoFile(photo);
if (!mounted) return;
final annotated = await Navigator.push<File?>(
context,
MaterialPageRoute(builder: (_) => AnnotationScreen(imageFile: localFile)),
);
if (annotated != null) {
await api.uploadPatientPhoto(
patientId: patient.id,
file: annotated,
kind: 'annotated',
notes: 'Annotation depuis ${photo.filename}',
);
reloadPhotos();
}
} finally {
if (mounted) setState(() => uploading = false);
}
}
Future<void> openComparison(List<PatientPhoto> photos) async {
final originals = photos.where((photo) => photo.kind == 'original').toList();
final annotations = photos.where((photo) => const {'annotated', 'planning', 'planification', 'simulation'}.contains(photo.kind)).toList();
if (originals.isEmpty || annotations.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Il faut au moins une photo originale et une annotation/planification/simulation.')),
);
return;
}
final selection = await showDialog<CompareSelection>(
context: context,
builder: (context) => CompareSelectionDialog(
originals: originals,
annotations: annotations,
),
);
if (selection == null || !mounted) return;
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ComparePhotosScreen(
original: selection.original,
annotated: selection.annotated,
),
),
);
}
Widget _headerCard() {
return Card(
margin: const EdgeInsets.all(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(radius: 24, child: Text(patient.displayName.isEmpty ? '?' : _initial(patient.displayName))),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(patient.displayName, style: Theme.of(context).textTheme.titleLarge),
Text(patient.procedure ?? 'Aucun acte renseigné'),
],
),
),
_StatusChip(label: patient.status ?? 'actif'),
const SizedBox(width: 8),
IconButton(onPressed: editPatient, icon: const Icon(Icons.edit), tooltip: 'Modifier'),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_InfoChip(icon: Icons.cake, text: patient.birthDate ?? 'Date naissance inconnue'),
_InfoChip(icon: Icons.phone, text: patient.phone ?? 'Téléphone non renseigné'),
_InfoChip(icon: Icons.email, text: patient.email ?? 'Email non renseigné'),
_InfoChip(icon: Icons.verified_user, text: 'Consentement: ${patient.consentSigned ?? 'non'}'),
],
),
if ((patient.notes ?? '').isNotEmpty) ...[
const Divider(height: 24),
Text(patient.notes!),
],
],
),
),
);
}
Widget _actionsRow(List<PatientPhoto> photos) {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton.icon(
onPressed: uploading ? null : () => pickPhoto(ImageSource.camera),
icon: const Icon(Icons.camera_alt),
label: const Text('Prendre photo'),
),
FilledButton.tonalIcon(
onPressed: uploading ? null : openLiveSimulation,
icon: const Icon(Icons.center_focus_strong),
label: const Text('Simulation live'),
),
OutlinedButton.icon(
onPressed: uploading ? null : () => pickPhoto(ImageSource.gallery),
icon: const Icon(Icons.photo_library),
label: const Text('Importer'),
),
OutlinedButton.icon(
onPressed: uploading ? null : importImmichFace,
icon: const Icon(Icons.face_retouching_natural),
label: const Text('Photos Immich'),
),
OutlinedButton.icon(
onPressed: uploading ? null : () => openComparison(photos),
icon: const Icon(Icons.compare),
label: const Text('Comparer'),
),
OutlinedButton.icon(
onPressed: uploading ? null : openConsultations,
icon: const Icon(Icons.event_note),
label: const Text('Consultations'),
),
OutlinedButton.icon(
onPressed: uploading ? null : openDocuments,
icon: const Icon(Icons.description),
label: const Text('Documents'),
),
],
),
);
}
Widget _photoGrid(List<PatientPhoto> photos) {
if (photos.isEmpty) {
return const Expanded(child: Center(child: Text('Aucune photo')));
}
return Expanded(
child: GridView.builder(
padding: const EdgeInsets.all(12),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 260,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 0.85,
),
itemCount: photos.length,
itemBuilder: (context, index) {
final photo = photos[index];
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => PhotoViewerScreen(
photo: photo,
onAnnotate: () => annotateExistingPhoto(photo),
onChanged: reloadPhotos,
onDeleted: reloadPhotos,
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Image.network(
api.photoThumbnailUrl(photo.id),
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Center(child: Icon(photo.kind == 'annotated' ? Icons.draw : Icons.image, size: 42)),
),
),
Padding(
padding: const EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
_KindChip(kind: photo.kind),
const Spacer(),
const Icon(Icons.open_in_full, size: 18),
],
),
const SizedBox(height: 6),
Text(photo.filename, maxLines: 1, overflow: TextOverflow.ellipsis),
if ((photo.notes ?? '').trim().isNotEmpty)
Text(
photo.notes!.trim(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall,
),
if (photo.createdAt != null) Text(_formatDate(photo.createdAt!), style: Theme.of(context).textTheme.labelSmall),
],
),
),
],
),
),
);
},
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(patient.displayName)),
body: FutureBuilder<List<PatientPhoto>>(
future: futurePhotos,
builder: (context, snapshot) {
final photos = snapshot.data ?? [];
return Column(
children: [
_headerCard(),
_actionsRow(photos),
if (uploading || snapshot.connectionState == ConnectionState.waiting) const LinearProgressIndicator(),
if (snapshot.hasError) Expanded(child: Center(child: Text('Erreur: ${snapshot.error}'))) else _photoGrid(photos),
],
);
},
),
);
}
}
class LiveSimulationResult {
final File originalFile;
final File planningFile;
const LiveSimulationResult({required this.originalFile, required this.planningFile});
}
class LiveFaceSimulationScreen extends StatefulWidget {
const LiveFaceSimulationScreen({super.key});
@override
State<LiveFaceSimulationScreen> createState() => _LiveFaceSimulationScreenState();
}
class _LiveFaceSimulationScreenState extends State<LiveFaceSimulationScreen> {
CameraController? controller;
List<CameraDescription> cameras = [];
int selectedCameraIndex = 0;
bool initializing = true;
bool capturing = false;
String? error;
final FaceDetector? faceDetector = _mlKitFaceDetectionSupported
? FaceDetector(
options: FaceDetectorOptions(
enableContours: true,
enableLandmarks: true,
performanceMode: FaceDetectorMode.fast,
),
)
: null;
@override
void initState() {
super.initState();
_initCamera();
}
@override
void dispose() {
controller?.dispose();
faceDetector?.close();
super.dispose();
}
Future<void> _initCamera() async {
setState(() {
initializing = true;
error = null;
});
try {
cameras = await availableCameras();
if (cameras.isEmpty) {
throw Exception('Aucune caméra détectée sur cet appareil.');
}
selectedCameraIndex = cameras.indexWhere((camera) => camera.lensDirection == CameraLensDirection.front);
if (selectedCameraIndex < 0) selectedCameraIndex = 0;
await _openCamera(selectedCameraIndex);
} catch (e) {
if (!mounted) return;
setState(() {
error = 'Impossible d’ouvrir la caméra: $e';
initializing = false;
});
}
}
Future<void> _openCamera(int index) async {
await controller?.dispose();
final nextController = CameraController(
cameras[index],
ResolutionPreset.high,
enableAudio: false,
);
controller = nextController;
await nextController.initialize();
if (!mounted) return;
setState(() {
selectedCameraIndex = index;
initializing = false;
error = null;
});
}
Future<void> _switchCamera() async {
if (cameras.length < 2 || capturing) return;
final next = (selectedCameraIndex + 1) % cameras.length;
setState(() => initializing = true);
try {
await _openCamera(next);
} catch (e) {
if (!mounted) return;
setState(() {
error = 'Impossible de changer de caméra: $e';
initializing = false;
});
}
}
Future<void> _capture() async {
final current = controller;
if (current == null || !current.value.isInitialized || capturing) return;
setState(() => capturing = true);
try {
final xfile = await current.takePicture();
final original = File(xfile.path);
final planning = await _createPlanningImage(original);
if (!mounted) return;
Navigator.pop(
context,
LiveSimulationResult(originalFile: original, planningFile: planning),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Capture impossible: $e')));
setState(() => capturing = false);
}
}
Future<File> _createPlanningImage(File originalFile) async {
final bytes = await originalFile.readAsBytes();
final originalImage = await _decodeImage(bytes);
List<Face> faces = [];
final detector = faceDetector;
if (detector != null) {
try {
final inputImage = InputImage.fromFilePath(originalFile.path);
faces = await detector.processImage(inputImage);
} catch (_) {
faces = [];
}
}
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
final imageSize = Size(originalImage.width.toDouble(), originalImage.height.toDouble());
canvas.drawImage(originalImage, Offset.zero, Paint());
_drawPlanningOverlay(canvas, imageSize, faces);
final picture = recorder.endRecording();
final rendered = await picture.toImage(originalImage.width, originalImage.height);
final pngBytes = await rendered.toByteData(format: ui.ImageByteFormat.png);
if (pngBytes == null) throw Exception('Image de planification vide.');
final dir = await getTemporaryDirectory();
final file = File('${dir.path}/live_simulation_${DateTime.now().millisecondsSinceEpoch}.png');
await file.writeAsBytes(pngBytes.buffer.asUint8List());
return file;
}
Future<ui.Image> _decodeImage(Uint8List bytes) async {
final codec = await ui.instantiateImageCodec(bytes);
final frame = await codec.getNextFrame();
return frame.image;
}
void _drawPlanningOverlay(Canvas canvas, Size size, List<Face> faces) {
final scale = math.max(size.width, size.height) / 1000.0;
final guidePaint = Paint()
..color = const Color(0xFFFF1744).withOpacity(0.82)
..style = PaintingStyle.stroke
..strokeWidth = 2.2 * scale;
final softPaint = Paint()
..color = const Color(0xFF00B0FF).withOpacity(0.72)
..style = PaintingStyle.stroke
..strokeWidth = 1.6 * scale;
final pointPaint = Paint()
..color = const Color(0xFFFFEA00).withOpacity(0.92)
..style = PaintingStyle.fill;
void drawLine(Offset a, Offset b, Paint paint) => canvas.drawLine(a, b, paint);
// Repères généraux, utiles même si la détection échoue.
drawLine(Offset(size.width / 2, 0), Offset(size.width / 2, size.height), softPaint);
drawLine(Offset(0, size.height / 3), Offset(size.width, size.height / 3), softPaint);
drawLine(Offset(0, size.height * 2 / 3), Offset(size.width, size.height * 2 / 3), softPaint);
if (faces.isEmpty) {
final oval = Rect.fromCenter(
center: Offset(size.width / 2, size.height / 2),
width: size.width * 0.48,
height: size.height * 0.62,
);
canvas.drawOval(oval, guidePaint);
_drawText(canvas, 'Aucun visage détecté automatiquement', Offset(24 * scale, 24 * scale), 24 * scale, Colors.white);
_drawText(canvas, 'Simulation visuelle non contractuelle', Offset(24 * scale, 58 * scale), 20 * scale, Colors.white70);
return;
}
for (final face in faces) {
final box = face.boundingBox;
canvas.drawOval(box, guidePaint);
drawLine(Offset(box.center.dx, box.top), Offset(box.center.dx, box.bottom), guidePaint);
drawLine(Offset(box.left, box.center.dy), Offset(box.right, box.center.dy), softPaint);
final faceContour = face.contours[FaceContourType.face];
if (faceContour != null && faceContour.points.length > 1) {
final path = Path()..moveTo(faceContour.points.first.x.toDouble(), faceContour.points.first.y.toDouble());
for (final point in faceContour.points.skip(1)) {
path.lineTo(point.x.toDouble(), point.y.toDouble());
}
canvas.drawPath(path, softPaint);
}
final leftEye = face.landmarks[FaceLandmarkType.leftEye]?.position;
final rightEye = face.landmarks[FaceLandmarkType.rightEye]?.position;
final nose = face.landmarks[FaceLandmarkType.noseBase]?.position;
final mouthLeft = face.landmarks[FaceLandmarkType.leftMouth]?.position;
final mouthRight = face.landmarks[FaceLandmarkType.rightMouth]?.position;
final mouthBottom = face.landmarks[FaceLandmarkType.bottomMouth]?.position;
final landmarks = [leftEye, rightEye, nose, mouthLeft, mouthRight, mouthBottom].whereType<dynamic>();
for (final point in landmarks) {
canvas.drawCircle(Offset(point.x.toDouble(), point.y.toDouble()), 5.0 * scale, pointPaint);
}
if (leftEye != null && rightEye != null) {
drawLine(
Offset(leftEye.x.toDouble(), leftEye.y.toDouble()),
Offset(rightEye.x.toDouble(), rightEye.y.toDouble()),
guidePaint,
);
}
if (nose != null && mouthBottom != null) {
drawLine(
Offset(nose.x.toDouble(), nose.y.toDouble()),
Offset(mouthBottom.x.toDouble(), mouthBottom.y.toDouble()),
guidePaint,
);
}
if (mouthLeft != null && mouthRight != null) {
drawLine(
Offset(mouthLeft.x.toDouble(), mouthLeft.y.toDouble()),
Offset(mouthRight.x.toDouble(), mouthRight.y.toDouble()),
softPaint,
);
}
}
_drawText(canvas, 'Repères visage détectés', Offset(24 * scale, 24 * scale), 24 * scale, Colors.white);
_drawText(canvas, 'Simulation visuelle non contractuelle', Offset(24 * scale, 58 * scale), 20 * scale, Colors.white70);
}
void _drawText(Canvas canvas, String text, Offset offset, double fontSize, Color color) {
final painter = TextPainter(
text: TextSpan(
text: text,
style: TextStyle(
color: color,
fontSize: fontSize,
fontWeight: FontWeight.w600,
backgroundColor: Colors.black.withOpacity(0.45),
),
),
textDirection: TextDirection.ltr,
);
painter.layout();
painter.paint(canvas, offset);
}
@override
Widget build(BuildContext context) {
final current = controller;
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
title: const Text('Simulation live'),
actions: [
if (cameras.length > 1)
IconButton(
onPressed: initializing || capturing ? null : _switchCamera,
icon: const Icon(Icons.cameraswitch),
tooltip: 'Changer de caméra',
),
],
),
body: Builder(
builder: (context) {
if (initializing) return const Center(child: CircularProgressIndicator());
if (error != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(error!, style: const TextStyle(color: Colors.white), textAlign: TextAlign.center),
),
);
}
if (current == null || !current.value.isInitialized) {
return const Center(child: Text('Caméra non initialisée.', style: TextStyle(color: Colors.white)));
}
return Column(
children: [
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final previewSize = current.value.previewSize;
final cameraWidth = previewSize?.height ?? constraints.maxWidth;
final cameraHeight = previewSize?.width ?? constraints.maxHeight;
return Stack(
fit: StackFit.expand,
children: [
ClipRect(
child: FittedBox(
fit: BoxFit.cover,
alignment: Alignment.center,
child: SizedBox(
width: cameraWidth,
height: cameraHeight,
child: CameraPreview(current),
),
),
),
CustomPaint(painter: _LiveFaceGuidePainter()),
Positioned(
left: 12,
right: 12,
bottom: 12,
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.48),
borderRadius: BorderRadius.circular(14),
),
child: const Padding(
padding: EdgeInsets.all(12),
child: Text(
'Cadrez le visage dans l’ovale. Rapprochez le téléphone jusqu’à remplir le guide.',
style: TextStyle(color: Colors.white),
textAlign: TextAlign.center,
),
),
),
),
],
);
},
),
),
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
child: Row(
children: [
Expanded(
child: OutlinedButton.icon(
style: OutlinedButton.styleFrom(foregroundColor: Colors.white),
onPressed: capturing ? null : () => Navigator.pop(context),
icon: const Icon(Icons.close),
label: const Text('Annuler'),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton.icon(
onPressed: capturing ? null : _capture,
icon: capturing
? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.camera),
label: Text(capturing ? 'Analyse...' : 'Capturer'),
),
),
],
),
),
),
],
);
},
),
);
}
}
class _LiveFaceGuidePainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final ovalPaint = Paint()
..color = Colors.white.withOpacity(0.72)
..style = PaintingStyle.stroke
..strokeWidth = 2;
final redPaint = Paint()
..color = const Color(0xFFFF1744).withOpacity(0.85)
..style = PaintingStyle.stroke
..strokeWidth = 2;
final bluePaint = Paint()
..color = const Color(0xFF00B0FF).withOpacity(0.72)
..style = PaintingStyle.stroke
..strokeWidth = 1.5;
final center = Offset(size.width / 2, size.height * 0.45);
final guideWidth = math.min(size.width * 0.74, size.height * 0.48);
final guideHeight = math.min(size.height * 0.74, guideWidth * 1.42);
final faceOval = Rect.fromCenter(
center: center,
width: guideWidth,
height: guideHeight,
);
canvas.drawOval(faceOval, ovalPaint);
canvas.drawLine(Offset(center.dx, faceOval.top), Offset(center.dx, faceOval.bottom), redPaint);
canvas.drawLine(Offset(faceOval.left, center.dy), Offset(faceOval.right, center.dy), bluePaint);
canvas.drawLine(Offset(0, size.height / 3), Offset(size.width, size.height / 3), bluePaint);
canvas.drawLine(Offset(0, size.height * 2 / 3), Offset(size.width, size.height * 2 / 3), bluePaint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
class ConsultationsScreen extends StatefulWidget {
final Patient patient;
const ConsultationsScreen({super.key, required this.patient});
@override
State<ConsultationsScreen> createState() => _ConsultationsScreenState();
}
class _ConsultationsScreenState extends State<ConsultationsScreen> {
final ApiService api = ApiService();
late Future<List<ConsultationRecord>> futureConsultations;
@override
void initState() {
super.initState();
reload();
}
void reload() {
setState(() {
futureConsultations = api.getPatientConsultations(widget.patient.id);
});
}
Future<void> addConsultation() async {
final result = await showDialog<ConsultationFormResult>(
context: context,
builder: (context) => ConsultationFormDialog(
title: 'Nouvelle consultation',
defaultProcedure: widget.patient.procedure,
),
);
if (result == null) return;
await api.createConsultation(
patientId: widget.patient.id,
consultationDate: result.consultationDate,
procedure: result.procedure,
reason: result.reason,
plan: result.plan,
followUp: result.followUp,
status: result.status,
notes: result.notes,
);
reload();
}
Future<void> editConsultation(ConsultationRecord consultation) async {
final result = await showDialog<ConsultationFormResult>(
context: context,
builder: (context) => ConsultationFormDialog(
title: 'Modifier consultation',
consultation: consultation,
defaultProcedure: widget.patient.procedure,
),
);
if (result == null) return;
await api.updateConsultation(
consultationId: consultation.id,
consultationDate: result.consultationDate,
procedure: result.procedure,
reason: result.reason,
plan: result.plan,
followUp: result.followUp,
status: result.status,
notes: result.notes,
);
reload();
}
Future<void> deleteConsultation(ConsultationRecord consultation) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Supprimer la consultation ?'),
content: const Text('Cette action supprime seulement la note de consultation dans l’application.'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Supprimer')),
],
),
);
if (confirmed != true) return;
await api.deleteConsultation(consultation.id);
reload();
}
Widget _consultationCard(ConsultationRecord consultation) {
final title = (consultation.procedure ?? '').trim().isEmpty ? 'Consultation' : consultation.procedure!.trim();
final date = (consultation.consultationDate ?? '').trim().isEmpty ? 'Date non renseignée' : consultation.consultationDate!.trim();
final details = <Widget>[];
void addSection(String label, String? value) {
final cleaned = (value ?? '').trim();
if (cleaned.isEmpty) return;
details.add(const SizedBox(height: 8));
details.add(Text(label, style: const TextStyle(fontWeight: FontWeight.w700)));
details.add(Text(cleaned));
}
addSection('Motif', consultation.reason);
addSection('Plan / modifications prévues', consultation.plan);
addSection('Suivi', consultation.followUp);
addSection('Notes', consultation.notes);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: InkWell(
onTap: () => editConsultation(consultation),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.event_note),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleMedium),
Text(date, style: Theme.of(context).textTheme.bodySmall),
],
),
),
_StatusChip(label: consultation.status ?? 'prévu'),
IconButton(
tooltip: 'Supprimer',
onPressed: () => deleteConsultation(consultation),
icon: const Icon(Icons.delete_outline),
),
],
),
...details,
],
),
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Consultations - ${widget.patient.displayName}')),
floatingActionButton: FloatingActionButton.extended(
onPressed: addConsultation,
icon: const Icon(Icons.add),
label: const Text('Consultation'),
),
body: FutureBuilder<List<ConsultationRecord>>(
future: futureConsultations,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) return const Center(child: CircularProgressIndicator());
if (snapshot.hasError) return Center(child: Text('Erreur: ${snapshot.error}'));
final consultations = snapshot.data ?? [];
if (consultations.isEmpty) {
return const Center(child: Text('Aucune consultation enregistrée.'));
}
return ListView.builder(
itemCount: consultations.length,
itemBuilder: (context, index) => _consultationCard(consultations[index]),
);
},
),
);
}
}
class ConsultationFormResult {
final String? consultationDate;
final String? procedure;
final String? reason;
final String? plan;
final String? followUp;
final String status;
final String? notes;
ConsultationFormResult({
this.consultationDate,
this.procedure,
this.reason,
this.plan,
this.followUp,
required this.status,
this.notes,
});
}
class ConsultationFormDialog extends StatefulWidget {
final String title;
final ConsultationRecord? consultation;
final String? defaultProcedure;
const ConsultationFormDialog({
super.key,
required this.title,
this.consultation,
this.defaultProcedure,
});
@override
State<ConsultationFormDialog> createState() => _ConsultationFormDialogState();
}
class _ConsultationFormDialogState extends State<ConsultationFormDialog> {
late final TextEditingController dateController;
late final TextEditingController procedureController;
late final TextEditingController reasonController;
late final TextEditingController planController;
late final TextEditingController followUpController;
late final TextEditingController notesController;
String status = 'prévu';
@override
void initState() {
super.initState();
final c = widget.consultation;
dateController = TextEditingController(text: c?.consultationDate ?? _todayForInput());
procedureController = TextEditingController(text: c?.procedure ?? widget.defaultProcedure ?? '');
reasonController = TextEditingController(text: c?.reason ?? '');
planController = TextEditingController(text: c?.plan ?? '');
followUpController = TextEditingController(text: c?.followUp ?? '');
notesController = TextEditingController(text: c?.notes ?? '');
status = c?.status ?? 'prévu';
}
@override
void dispose() {
dateController.dispose();
procedureController.dispose();
reasonController.dispose();
planController.dispose();
followUpController.dispose();
notesController.dispose();
super.dispose();
}
void submit() {
Navigator.pop(
context,
ConsultationFormResult(
consultationDate: dateController.text.trim(),
procedure: procedureController.text.trim(),
reason: reasonController.text.trim(),
plan: planController.text.trim(),
followUp: followUpController.text.trim(),
status: status,
notes: notesController.text.trim(),
),
);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.title),
content: SizedBox(
width: 760,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(child: TextField(controller: dateController, decoration: const InputDecoration(labelText: 'Date / heure'))),
const SizedBox(width: 12),
Expanded(child: TextField(controller: procedureController, decoration: const InputDecoration(labelText: 'Acte'))),
],
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
value: status,
decoration: const InputDecoration(labelText: 'Statut consultation'),
items: const [
DropdownMenuItem(value: 'prévu', child: Text('Prévu')),
DropdownMenuItem(value: 'fait', child: Text('Fait')),
DropdownMenuItem(value: 'suivi', child: Text('Suivi')),
DropdownMenuItem(value: 'annulé', child: Text('Annulé')),
],
onChanged: (value) => setState(() => status = value ?? 'prévu'),
),
const SizedBox(height: 12),
TextField(
controller: reasonController,
minLines: 2,
maxLines: 4,
decoration: const InputDecoration(labelText: 'Motif / demande patient'),
),
const SizedBox(height: 12),
TextField(
controller: planController,
minLines: 3,
maxLines: 6,
decoration: const InputDecoration(labelText: 'Plan / modifications prévues'),
),
const SizedBox(height: 12),
TextField(
controller: followUpController,
minLines: 2,
maxLines: 4,
decoration: const InputDecoration(labelText: 'Suivi prévu'),
),
const SizedBox(height: 12),
TextField(
controller: notesController,
minLines: 2,
maxLines: 5,
decoration: const InputDecoration(labelText: 'Notes internes'),
),
],
),
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Annuler')),
FilledButton(onPressed: submit, child: const Text('Enregistrer')),
],
);
}
}
String _todayForInput() {
final now = DateTime.now();
String two(int n) => n.toString().padLeft(2, '0');
return '${now.year}-${two(now.month)}-${two(now.day)} ${two(now.hour)}:${two(now.minute)}';
}
class DocumentsScreen extends StatefulWidget {
final Patient patient;
const DocumentsScreen({super.key, required this.patient});
@override
State<DocumentsScreen> createState() => _DocumentsScreenState();
}
class _DocumentsScreenState extends State<DocumentsScreen> {
final ApiService api = ApiService();
late Future<List<DocumentRecord>> futureDocuments;
@override
void initState() {
super.initState();
reload();
}
void reload() {
setState(() {
futureDocuments = api.getPatientDocuments(widget.patient.id);
});
}
Future<void> addDocument() async {
final result = await showDialog<DocumentFormResult>(
context: context,
builder: (context) => DocumentFormDialog(
title: 'Nouveau document',
patient: widget.patient,
),
);
if (result == null) return;
await api.createDocument(
patientId: widget.patient.id,
documentType: result.documentType,
title: result.title,
status: result.status,
content: result.content,
signedBy: result.signedBy,
signedAt: result.signedAt,
);
reload();
}
Future<void> editDocument(DocumentRecord document) async {
final result = await showDialog<DocumentFormResult>(
context: context,
builder: (context) => DocumentFormDialog(
title: 'Modifier document',
patient: widget.patient,
document: document,
),
);
if (result == null) return;
await api.updateDocument(
documentId: document.id,
documentType: result.documentType,
title: result.title,
status: result.status,
content: result.content,
signedBy: result.signedBy,
signedAt: result.signedAt,
);
reload();
}
Future<void> deleteDocument(DocumentRecord document) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Supprimer le document ?'),
content: Text('Le document "${document.title}" sera supprimé du dossier patient local.'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Supprimer')),
],
),
);
if (confirmed != true) return;
await api.deleteDocument(document.id);
reload();
}
Future<void> copyPdfLink(DocumentRecord document) async {
final url = api.documentPdfUrl(document.id);
await Clipboard.setData(ClipboardData(text: url));
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Lien PDF copié: $url')),
);
}
Widget _documentCard(DocumentRecord document) {
final content = (document.content ?? '').trim();
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: InkWell(
onTap: () => editDocument(document),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.description_outlined),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(document.title, style: Theme.of(context).textTheme.titleMedium),
Text(_documentTypeLabel(document.documentType), style: Theme.of(context).textTheme.bodySmall),
],
),
),
_StatusChip(label: document.status),
IconButton(
tooltip: 'Copier lien PDF',
onPressed: () => copyPdfLink(document),
icon: const Icon(Icons.picture_as_pdf),
),
IconButton(
tooltip: 'Supprimer',
onPressed: () => deleteDocument(document),
icon: const Icon(Icons.delete_outline),
),
],
),
if ((document.signedBy ?? '').trim().isNotEmpty || (document.signedAt ?? '').trim().isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'Signature: ${document.signedBy ?? 'Non renseigné'} • ${document.signedAt ?? 'Date non renseignée'}',
style: Theme.of(context).textTheme.bodySmall,
),
],
if (content.isNotEmpty) ...[
const SizedBox(height: 8),
Text(content, maxLines: 4, overflow: TextOverflow.ellipsis),
],
if (document.createdAt != null) ...[
const SizedBox(height: 8),
Text('Créé le ${_formatDate(document.createdAt!)}', style: Theme.of(context).textTheme.labelSmall),
],
],
),
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Documents - ${widget.patient.displayName}')),
floatingActionButton: FloatingActionButton.extended(
onPressed: addDocument,
icon: const Icon(Icons.add),
label: const Text('Document'),
),
body: FutureBuilder<List<DocumentRecord>>(
future: futureDocuments,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) return const Center(child: CircularProgressIndicator());
if (snapshot.hasError) return Center(child: Text('Erreur: ${snapshot.error}'));
final documents = snapshot.data ?? [];
if (documents.isEmpty) {
return const Center(child: Text('Aucun document enregistré.'));
}
return ListView.builder(
itemCount: documents.length,
itemBuilder: (context, index) => _documentCard(documents[index]),
);
},
),
);
}
}
class DocumentFormResult {
final String documentType;
final String title;
final String status;
final String? content;
final String? signedBy;
final String? signedAt;
DocumentFormResult({
required this.documentType,
required this.title,
required this.status,
this.content,
this.signedBy,
this.signedAt,
});
}
class DocumentFormDialog extends StatefulWidget {
final String title;
final Patient patient;
final DocumentRecord? document;
const DocumentFormDialog({
super.key,
required this.title,
required this.patient,
this.document,
});
@override
State<DocumentFormDialog> createState() => _DocumentFormDialogState();
}
class _DocumentFormDialogState extends State<DocumentFormDialog> {
late final TextEditingController titleController;
late final TextEditingController contentController;
late final TextEditingController signedByController;
late final TextEditingController signedAtController;
String documentType = 'consentement_photo';
String status = 'à signer';
@override
void initState() {
super.initState();
final d = widget.document;
documentType = d?.documentType ?? 'consentement_photo';
status = d?.status ?? 'à signer';
titleController = TextEditingController(text: d?.title ?? _defaultDocumentTitle(documentType));
contentController = TextEditingController(text: d?.content ?? _defaultDocumentContent(documentType, widget.patient));
signedByController = TextEditingController(text: d?.signedBy ?? widget.patient.displayName);
signedAtController = TextEditingController(text: d?.signedAt ?? '');
}
@override
void dispose() {
titleController.dispose();
contentController.dispose();
signedByController.dispose();
signedAtController.dispose();
super.dispose();
}
void loadTemplate() {
setState(() {
titleController.text = _defaultDocumentTitle(documentType);
contentController.text = _defaultDocumentContent(documentType, widget.patient);
if (status == 'brouillon') status = 'à signer';
});
}
void submit() {
final title = titleController.text.trim();
if (title.isEmpty) return;
Navigator.pop(
context,
DocumentFormResult(
documentType: documentType,
title: title,
status: status,
content: contentController.text.trim(),
signedBy: signedByController.text.trim(),
signedAt: signedAtController.text.trim(),
),
);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.title),
content: SizedBox(
width: 860,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
child: DropdownButtonFormField<String>(
value: documentType,
decoration: const InputDecoration(labelText: 'Type de document'),
items: _documentTypeItems(),
onChanged: (value) => setState(() => documentType = value ?? 'consentement_photo'),
),
),
const SizedBox(width: 12),
Expanded(
child: DropdownButtonFormField<String>(
value: status,
decoration: const InputDecoration(labelText: 'Statut'),
items: _documentStatusItems(),
onChanged: (value) => setState(() => status = value ?? 'brouillon'),
),
),
],
),
const SizedBox(height: 12),
TextField(controller: titleController, decoration: const InputDecoration(labelText: 'Titre')),
const SizedBox(height: 12),
Row(
children: [
Expanded(child: TextField(controller: signedByController, decoration: const InputDecoration(labelText: 'Signataire'))),
const SizedBox(width: 12),
Expanded(child: TextField(controller: signedAtController, decoration: const InputDecoration(labelText: 'Date signature'))),
],
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerLeft,
child: OutlinedButton.icon(
onPressed: loadTemplate,
icon: const Icon(Icons.auto_fix_high),
label: const Text('Charger modèle'),
),
),
const SizedBox(height: 12),
TextField(
controller: contentController,
minLines: 12,
maxLines: 22,
decoration: const InputDecoration(labelText: 'Contenu du document'),
),
],
),
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Annuler')),
FilledButton(onPressed: submit, child: const Text('Enregistrer')),
],
);
}
}
List<DropdownMenuItem<String>> _documentTypeItems() {
const items = {
'consentement_photo': 'Consentement photo',
'consentement_intervention': 'Consentement intervention',
'consentement_simulation': 'Consentement simulation',
'compte_rendu_consultation': 'Compte rendu consultation',
'note_interne': 'Note interne',
'autre': 'Autre',
};
return items.entries.map((entry) => DropdownMenuItem(value: entry.key, child: Text(entry.value))).toList();
}
List<DropdownMenuItem<String>> _documentStatusItems() {
const items = ['brouillon', 'à signer', 'signé', 'archivé'];
return items.map((value) => DropdownMenuItem(value: value, child: Text(value))).toList();
}
String _documentTypeLabel(String type) {
switch (type) {
case 'consentement_photo':
return 'Consentement photo';
case 'consentement_intervention':
return 'Consentement intervention';
case 'consentement_simulation':
return 'Consentement simulation';
case 'compte_rendu_consultation':
return 'Compte rendu consultation';
case 'note_interne':
return 'Note interne';
default:
return 'Autre document';
}
}
String _defaultDocumentTitle(String type) {
switch (type) {
case 'consentement_photo':
return 'Consentement photo';
case 'consentement_intervention':
return 'Consentement intervention';
case 'consentement_simulation':
return 'Consentement simulation non contractuelle';
case 'compte_rendu_consultation':
return 'Compte rendu de consultation';
case 'note_interne':
return 'Note interne';
default:
return 'Document patient';
}
}
String _defaultDocumentContent(String type, Patient patient) {
final patientName = patient.displayName.isEmpty ? 'le/la patient(e)' : patient.displayName;
final procedure = (patient.procedure ?? '').trim().isEmpty ? 'l’acte envisagé' : patient.procedure!.trim();
switch (type) {
case 'consentement_photo':
return '''Je soussigné(e) $patientName autorise le praticien à réaliser, conserver et utiliser des photographies médicales dans le cadre de mon dossier de consultation esthétique.
Ces photographies servent au suivi clinique, à la comparaison avant/après, à la planification des modifications et à la documentation du dossier patient.
Aucune diffusion publique ou commerciale ne sera réalisée sans accord spécifique séparé.
Je reconnais avoir été informé(e) de la finalité de ces photographies et de leur conservation dans un espace sécurisé.''';
case 'consentement_simulation':
return '''Je soussigné(e) $patientName reconnais que les dessins, annotations, comparaisons ou simulations présentés pendant la consultation sont des supports d’explication et de planification.
Ils ne constituent pas une promesse de résultat ni une garantie contractuelle.
Le résultat réel dépend de nombreux facteurs médicaux, anatomiques, cicatriciels et postopératoires.
J’ai compris que les images annotées servent à expliquer le projet pour $procedure et à faciliter l’échange avec le praticien.''';
case 'consentement_intervention':
return '''Je soussigné(e) $patientName confirme avoir reçu des explications concernant $procedure.
Les bénéfices attendus, les limites, les alternatives, les suites possibles et les risques principaux m’ont été présentés pendant la consultation.
Je reconnais avoir pu poser mes questions et avoir reçu des réponses compréhensibles.
Ce document ne remplace pas les documents médicaux réglementaires propres à chaque intervention, mais complète le dossier de consultation.''';
case 'compte_rendu_consultation':
return '''Patient: $patientName
Acte envisagé: $procedure
Motif de consultation:
Analyse / observations:
Plan proposé:
Photos réalisées:
Suites et prochain rendez-vous:
''';
case 'note_interne':
return '''Note interne dossier patient: $patientName
Acte: $procedure
Points importants:
À vérifier:
Décisions prises:
''';
default:
return '''Document patient: $patientName
Acte: $procedure
Contenu:
''';
}
}
class ImmichPeopleDialog extends StatefulWidget {
const ImmichPeopleDialog({super.key});
@override
State<ImmichPeopleDialog> createState() => _ImmichPeopleDialogState();
}
class _ImmichPeopleDialogState extends State<ImmichPeopleDialog> {
final ApiService api = ApiService();
final TextEditingController searchController = TextEditingController();
late Future<List<ImmichPerson>> futurePeople;
ImmichPerson? selectedPerson;
Future<List<ImmichAsset>>? futureAssets;
@override
void initState() {
super.initState();
futurePeople = api.getImmichPeople();
}
@override
void dispose() {
searchController.dispose();
super.dispose();
}
void reloadPeople() {
setState(() {
selectedPerson = null;
futureAssets = null;
futurePeople = api.getImmichPeople(query: searchController.text);
});
}
void openPerson(ImmichPerson person) {
setState(() {
selectedPerson = person;
futureAssets = api.getImmichPersonAssets(person.id);
});
}
void backToPeople() {
setState(() {
selectedPerson = null;
futureAssets = null;
});
}
Widget _peopleGrid() {
return FutureBuilder<List<ImmichPerson>>(
future: futurePeople,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('Erreur Immich: ${snapshot.error}'));
}
final people = snapshot.data ?? [];
if (people.isEmpty) {
return const Center(child: Text('Aucune personne reconnue trouvée dans Immich.'));
}
return GridView.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 170,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: 0.82,
),
itemCount: people.length,
itemBuilder: (context, index) {
final person = people[index];
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => openPerson(person),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Image.network(
person.thumbnailUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const Center(child: Icon(Icons.face, size: 44)),
),
),
Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(person.displayName, maxLines: 1, overflow: TextOverflow.ellipsis),
Text(
person.isHidden == true ? 'Masqué dans Immich' : 'Voir les photos',
style: Theme.of(context).textTheme.labelSmall,
),
],
),
),
],
),
),
);
},
);
},
);
}
Widget _assetsGrid() {
final person = selectedPerson!;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
TextButton.icon(
onPressed: backToPeople,
icon: const Icon(Icons.arrow_back),
label: const Text('Retour aux visages'),
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Photos de ${person.displayName}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium,
),
),
],
),
const SizedBox(height: 8),
Expanded(
child: FutureBuilder<List<ImmichAsset>>(
future: futureAssets,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('Erreur photos Immich: ${snapshot.error}'));
}
final assets = snapshot.data ?? [];
if (assets.isEmpty) {
return const Center(child: Text('Aucune photo trouvée pour cette personne.'));
}
return GridView.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 190,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: 0.86,
),
itemCount: assets.length,
itemBuilder: (context, index) {
final asset = assets[index];
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => Navigator.pop(context, asset),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Image.network(
asset.thumbnailUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const Center(child: Icon(Icons.image, size: 44)),
),
),
Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(asset.displayName, maxLines: 1, overflow: TextOverflow.ellipsis),
Text(
asset.takenAt == null ? 'Photo complète' : asset.takenAt!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall,
),
],
),
),
],
),
),
);
},
);
},
),
),
],
);
}
@override
Widget build(BuildContext context) {
final inAssets = selectedPerson != null;
return AlertDialog(
title: Text(inAssets ? 'Choisir une photo Immich' : 'Choisir une personne Immich'),
content: SizedBox(
width: 820,
height: 600,
child: Column(
children: [
if (!inAssets) ...[
TextField(
controller: searchController,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search),
labelText: 'Chercher une personne Immich par nom',
suffixIcon: IconButton(onPressed: reloadPeople, icon: const Icon(Icons.search)),
),
onSubmitted: (_) => reloadPeople(),
),
const SizedBox(height: 12),
],
Expanded(child: inAssets ? _assetsGrid() : _peopleGrid()),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Annuler')),
],
);
}
}
class PhotoViewerScreen extends StatefulWidget {
final PatientPhoto photo;
final VoidCallback? onAnnotate;
final VoidCallback? onChanged;
final VoidCallback? onDeleted;
const PhotoViewerScreen({
super.key,
required this.photo,
this.onAnnotate,
this.onChanged,
this.onDeleted,
});
@override
State<PhotoViewerScreen> createState() => _PhotoViewerScreenState();
}
class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
final ApiService api = ApiService();
late PatientPhoto photo;
bool busy = false;
@override
void initState() {
super.initState();
photo = widget.photo;
}
Future<void> editMetadata() async {
final result = await showDialog<PhotoMetadataResult>(
context: context,
builder: (context) => PhotoMetadataDialog(photo: photo),
);
if (result == null) return;
setState(() => busy = true);
try {
final updated = await api.updatePatientPhoto(
photoId: photo.id,
kind: result.kind,
notes: result.notes,
);
if (!mounted) return;
setState(() => photo = updated);
widget.onChanged?.call();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Photo mise à jour')),
);
} finally {
if (mounted) setState(() => busy = false);
}
}
Future<void> deletePhoto() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Retirer la photo du dossier ?'),
content: const Text(
'La photo sera retirée du dossier patient dans cette application. '
'L\'asset Immich ne sera pas supprimé, pour éviter d\'effacer une photo source utilisée ailleurs.',
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Retirer')),
],
),
);
if (confirmed != true) return;
setState(() => busy = true);
try {
await api.deletePatientPhoto(photo.id);
widget.onDeleted?.call();
if (mounted) Navigator.pop(context);
} finally {
if (mounted) setState(() => busy = false);
}
}
void annotate() {
Navigator.pop(context);
widget.onAnnotate?.call();
}
Future<void> transformPhoto() async {
setState(() => busy = true);
try {
final localFile = await api.downloadPhotoFile(photo);
if (!mounted) return;
setState(() => busy = false);
final created = await Navigator.push<PatientPhoto?>(
context,
MaterialPageRoute(builder: (_) => FaceTransformScreen(photo: photo, imageFile: localFile)),
);
if (created == null) return;
widget.onChanged?.call();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Simulation visage backend ajoutée au dossier patient.')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Transformation impossible: $e')));
}
} finally {
if (mounted) setState(() => busy = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(photo.filename, maxLines: 1, overflow: TextOverflow.ellipsis),
Text(_kindLabel(photo.kind), style: Theme.of(context).textTheme.labelSmall),
],
),
actions: [
IconButton(
tooltip: 'Modifier type / note',
onPressed: busy ? null : editMetadata,
icon: const Icon(Icons.tune),
),
IconButton(
tooltip: 'Retirer du dossier patient',
onPressed: busy ? null : deletePhoto,
icon: const Icon(Icons.delete_outline),
),
TextButton.icon(
onPressed: busy ? null : transformPhoto,
icon: const Icon(Icons.auto_fix_high),
label: const Text('Transformer'),
),
TextButton.icon(
onPressed: busy ? null : annotate,
icon: const Icon(Icons.draw),
label: const Text('Annoter'),
),
],
),
body: Stack(
children: [
Center(
child: InteractiveViewer(
minScale: 0.5,
maxScale: 6,
child: Image.network(
api.photoFileUrl(photo.id),
fit: BoxFit.contain,
errorBuilder: (_, error, __) => Padding(
padding: const EdgeInsets.all(16),
child: Text('Impossible de charger la photo: $error'),
),
),
),
),
if ((photo.notes ?? '').trim().isNotEmpty)
Positioned(
left: 12,
right: 12,
bottom: 12,
child: Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(photo.notes!.trim()),
),
),
),
if (busy)
const Positioned.fill(
child: ColoredBox(
color: Color(0x33000000),
child: Center(child: CircularProgressIndicator()),
),
),
],
),
);
}
}
class _TransformPreset {
final String id;
final String name;
final String description;
final double globalStrength;
final double lipVolume;
final double upperLip;
final double lowerLip;
final double noseWidth;
final double noseTip;
final double chinProjection;
final double chinWidth;
final double jawline;
final double cheekbones;
const _TransformPreset({
required this.id,
required this.name,
required this.description,
required this.globalStrength,
required this.lipVolume,
required this.upperLip,
required this.lowerLip,
required this.noseWidth,
required this.noseTip,
required this.chinProjection,
required this.chinWidth,
required this.jawline,
required this.cheekbones,
});
}
class FaceTransformScreen extends StatefulWidget {
final PatientPhoto photo;
final File imageFile;
const FaceTransformScreen({super.key, required this.photo, required this.imageFile});
@override
State<FaceTransformScreen> createState() => _FaceTransformScreenState();
}
class _FaceTransformScreenState extends State<FaceTransformScreen> {
final ApiService api = ApiService();
Uint8List? previewBytes;
bool loading = true;
bool rendering = false;
String? error;
Timer? debounce;
bool detected = false;
String method = 'non vérifié';
int landmarkCount = 0;
int previewImageWidth = 0;
int previewImageHeight = 0;
bool showGuides = true;
String selectedPresetId = 'custom';
bool manualMode = false;
bool savedLandmarksLoaded = false;
bool hasSavedLandmarks = false;
String? savedLandmarksAt;
String selectedManualPoint = 'nose';
final Map<String, Offset> manualLandmarks = {
'left_eye': Offset(0.40, 0.42),
'right_eye': Offset(0.60, 0.42),
'nose': Offset(0.50, 0.54),
'mouth': Offset(0.50, 0.66),
'chin': Offset(0.50, 0.80),
'face_top': Offset(0.50, 0.25),
'face_left': Offset(0.31, 0.55),
'face_right': Offset(0.69, 0.55),
};
double globalStrength = 0.82;
double lipVolume = 0.0;
double upperLip = 0.0;
double lowerLip = 0.0;
double noseWidth = 0.0;
double noseTip = 0.0;
double chinProjection = 0.0;
double chinWidth = 0.0;
double jawline = 0.0;
double cheekbones = 0.0;
String eyeColor = '#5C8FAF';
double eyeColorStrength = 0.0;
double eyeIrisSize = 0.72;
bool irisPaintMode = false;
double eyePaintRadius = 0.018;
final List<Offset> irisPaintPoints = <Offset>[];
String lipTint = '#B85C6A';
double lipTintStrength = 0.0;
double cheekBlush = 0.0;
double browDarkness = 0.0;
double browLift = 0.0;
double skinSmooth = 0.0;
@override
void initState() {
super.initState();
_loadInitial();
}
@override
void dispose() {
debounce?.cancel();
super.dispose();
}
List<_TransformPreset> get _transformPresets => const [
_TransformPreset(
id: 'soft_harmony',
name: 'Harmonisation douce',
description: 'Point de départ prudent : petites corrections globales, utile pour discuter sans produire un effet filtre trop voyant.',
globalStrength: 0.62,
lipVolume: 0.08,
upperLip: 0.04,
lowerLip: 0.05,
noseWidth: 0.08,
noseTip: 0.03,
chinProjection: 0.04,
chinWidth: 0.04,
jawline: 0.06,
cheekbones: 0.04,
),
_TransformPreset(
id: 'rhinoplasty_light',
name: 'Rhinoplastie légère',
description: 'Affinage discret de la largeur du nez et correction légère de la pointe. À utiliser avec des repères du nez bien placés, ce détail que les algorithmes adorent ignorer.',
globalStrength: 0.72,
lipVolume: 0.00,
upperLip: 0.00,
lowerLip: 0.00,
noseWidth: 0.22,
noseTip: 0.12,
chinProjection: 0.00,
chinWidth: 0.00,
jawline: 0.00,
cheekbones: 0.00,
),
_TransformPreset(
id: 'lip_augmentation_soft',
name: 'Lèvres discrètes',
description: 'Augmentation visuelle modérée des lèvres, avec priorité à un rendu conservateur.',
globalStrength: 0.68,
lipVolume: 0.18,
upperLip: 0.12,
lowerLip: 0.10,
noseWidth: 0.00,
noseTip: 0.00,
chinProjection: 0.00,
chinWidth: 0.00,
jawline: 0.00,
cheekbones: 0.00,
),
_TransformPreset(
id: 'chin_projection_soft',
name: 'Menton léger',
description: 'Projection légère du menton et affinement modéré de sa largeur.',
globalStrength: 0.70,
lipVolume: 0.00,
upperLip: 0.00,
lowerLip: 0.00,
noseWidth: 0.00,
noseTip: 0.00,
chinProjection: 0.18,
chinWidth: 0.10,
jawline: 0.05,
cheekbones: 0.00,
),
_TransformPreset(
id: 'oval_refine',
name: 'Ovale affiné',
description: 'Affinage doux du bas du visage avec légère mise en valeur des pommettes.',
globalStrength: 0.72,
lipVolume: 0.00,
upperLip: 0.00,
lowerLip: 0.00,
noseWidth: 0.00,
noseTip: 0.00,
chinProjection: 0.04,
chinWidth: 0.06,
jawline: 0.20,
cheekbones: 0.12,
),
_TransformPreset(
id: 'profile_balance',
name: 'Profil harmonisation',
description: 'Combinaison douce nez + menton + ovale. À garder basse, sinon le patient devient un personnage de jeu mobile, ce qui est rarement le cahier des charges.',
globalStrength: 0.64,
lipVolume: 0.04,
upperLip: 0.03,
lowerLip: 0.03,
noseWidth: 0.12,
noseTip: 0.08,
chinProjection: 0.12,
chinWidth: 0.06,
jawline: 0.10,
cheekbones: 0.05,
),
];
_TransformPreset? _presetById(String id) {
for (final preset in _transformPresets) {
if (preset.id == id) return preset;
}
return null;
}
void _applyPreset(_TransformPreset preset) {
setState(() {
selectedPresetId = preset.id;
globalStrength = preset.globalStrength;
lipVolume = preset.lipVolume;
upperLip = preset.upperLip;
lowerLip = preset.lowerLip;
noseWidth = preset.noseWidth;
noseTip = preset.noseTip;
chinProjection = preset.chinProjection;
chinWidth = preset.chinWidth;
jawline = preset.jawline;
cheekbones = preset.cheekbones;
showGuides = false;
});
_schedulePreview();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Preset appliqué : ${preset.name}')));
}
Map<String, String> get _eyeColorOptions => const {
'#5C8FAF': 'Bleu doux',
'#6FAF7B': 'Vert naturel',
'#8B6F47': 'Noisette',
'#8C9298': 'Gris',
'#A06B42': 'Marron clair',
};
Map<String, String> get _lipTintOptions => const {
'#B85C6A': 'Rose naturel',
'#A94A5D': 'Framboise doux',
'#C27564': 'Pêche rosé',
'#8F3F4E': 'Bois de rose',
'#B76E79': 'Nude rosé',
};
Color _colorFromHex(String value) {
final raw = value.replaceFirst('#', '');
return Color(int.parse('FF$raw', radix: 16));
}
Map<String, String> get _manualLabels => const {
'left_eye': 'Œil gauche',
'right_eye': 'Œil droit',
'nose': 'Nez',
'mouth': 'Bouche',
'chin': 'Menton',
'face_top': 'Haut du visage',
'face_left': 'Contour gauche',
'face_right': 'Contour droit',
};
Map<String, List<double>>? _manualPayload() {
if (!manualMode) return null;
return manualLandmarks.map((key, value) {
return MapEntry(key, [value.dx.clamp(0.0, 1.0).toDouble(), value.dy.clamp(0.0, 1.0).toDouble()]);
});
}
List<List<double>>? _eyePaintPayload() {
if (irisPaintPoints.isEmpty) return null;
return irisPaintPoints
.map((p) => [p.dx.clamp(0.0, 1.0).toDouble(), p.dy.clamp(0.0, 1.0).toDouble()])
.toList(growable: false);
}
void _applyManualLandmarksPayload(Map<String, List<double>> payload) {
final next = <String, Offset>{};
for (final entry in payload.entries) {
final value = entry.value;
if (value.length < 2) continue;
next[entry.key] = Offset(value[0].clamp(0.0, 1.0).toDouble(), value[1].clamp(0.0, 1.0).toDouble());
}
if (next.isEmpty) return;
manualLandmarks
..clear()
..addAll(next);
}
Future<void> _loadSavedLandmarks({bool showMessage = false}) async {
try {
final saved = await api.getSavedFaceLandmarks(widget.photo.id);
if (!mounted) return;
final landmarks = saved.manualLandmarks;
if (landmarks != null && landmarks.isNotEmpty) {
setState(() {
_applyManualLandmarksPayload(landmarks);
manualMode = true;
showGuides = true;
hasSavedLandmarks = true;
savedLandmarksAt = saved.updatedAt;
savedLandmarksLoaded = true;
});
if (showMessage) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Repères sauvegardés rechargés')));
}
} else {
setState(() {
hasSavedLandmarks = false;
savedLandmarksAt = null;
savedLandmarksLoaded = true;
});
if (showMessage) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Aucun repère sauvegardé pour cette photo')));
}
}
} catch (e) {
if (!mounted) return;
setState(() => savedLandmarksLoaded = true);
if (showMessage) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Chargement repères impossible: $e')));
}
}
}
Future<void> _saveManualLandmarks({bool showMessage = true}) async {
if (!manualMode) return;
try {
await api.saveFaceLandmarks(widget.photo.id, _manualPayload() ?? const {});
if (!mounted) return;
setState(() {
hasSavedLandmarks = true;
savedLandmarksAt = 'maintenant';
});
if (showMessage) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Positions des repères sauvegardées pour cette photo')));
}
} catch (e) {
if (mounted && showMessage) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Sauvegarde repères impossible: $e')));
}
}
}
Future<void> _deleteSavedLandmarks() async {
try {
await api.deleteSavedFaceLandmarks(widget.photo.id);
if (!mounted) return;
setState(() {
hasSavedLandmarks = false;
savedLandmarksAt = null;
});
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Repères sauvegardés supprimés')));
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Suppression repères impossible: $e')));
}
}
}
void _rememberPreviewSize(Uint8List bytes) {
final decoded = img.decodeImage(bytes);
if (decoded == null) return;
previewImageWidth = decoded.width;
previewImageHeight = decoded.height;
}
Rect _containedImageRect(Size container) {
final imageW = previewImageWidth <= 0 ? 1.0 : previewImageWidth.toDouble();
final imageH = previewImageHeight <= 0 ? 1.0 : previewImageHeight.toDouble();
final containerW = math.max(1.0, container.width);
final containerH = math.max(1.0, container.height);
final imageRatio = imageW / imageH;
final containerRatio = containerW / containerH;
double displayW;
double displayH;
if (containerRatio > imageRatio) {
displayH = containerH;
displayW = displayH * imageRatio;
} else {
displayW = containerW;
displayH = displayW / imageRatio;
}
final left = (containerW - displayW) / 2;
final top = (containerH - displayH) / 2;
return Rect.fromLTWH(left, top, displayW, displayH);
}
Offset? _normalizedFromPreviewPosition(Offset localPosition, Size containerSize) {
final rect = _containedImageRect(containerSize);
if (rect.width <= 1 || rect.height <= 1) return null;
final x = ((localPosition.dx - rect.left) / rect.width).clamp(0.0, 1.0).toDouble();
final y = ((localPosition.dy - rect.top) / rect.height).clamp(0.0, 1.0).toDouble();
return Offset(x, y);
}
String _nearestManualPoint(Offset normalized) {
var nearestKey = selectedManualPoint;
var bestDistance = double.infinity;
for (final entry in manualLandmarks.entries) {
final dx = entry.value.dx - normalized.dx;
final dy = entry.value.dy - normalized.dy;
final distance = dx * dx + dy * dy;
if (distance < bestDistance) {
bestDistance = distance;
nearestKey = entry.key;
}
}
return nearestKey;
}
void _moveManualPointFromPreview(Offset localPosition, Size containerSize, {bool chooseNearest = false}) {
if (!manualMode) return;
final normalized = _normalizedFromPreviewPosition(localPosition, containerSize);
if (normalized == null) return;
setState(() {
if (chooseNearest) selectedManualPoint = _nearestManualPoint(normalized);
manualLandmarks[selectedManualPoint] = normalized;
showGuides = true;
});
_schedulePreview();
}
void _paintIrisFromPreview(Offset localPosition, Size containerSize) {
if (!irisPaintMode) return;
final normalized = _normalizedFromPreviewPosition(localPosition, containerSize);
if (normalized == null) return;
setState(() {
irisPaintPoints.add(normalized);
if (irisPaintPoints.length > 1200) {
irisPaintPoints.removeRange(0, irisPaintPoints.length - 1200);
}
showGuides = false;
});
_schedulePreview();
}
void _clearIrisPainting() {
setState(() => irisPaintPoints.clear());
_schedulePreview();
}
void _resetManualLandmarks() {
setState(() {
manualLandmarks
..clear()
..addAll({
'left_eye': const Offset(0.40, 0.42),
'right_eye': const Offset(0.60, 0.42),
'nose': const Offset(0.50, 0.54),
'mouth': const Offset(0.50, 0.66),
'chin': const Offset(0.50, 0.80),
'face_top': const Offset(0.50, 0.25),
'face_left': const Offset(0.31, 0.55),
'face_right': const Offset(0.69, 0.55),
});
showGuides = true;
});
_showGuidesPreview();
}
void _updateManualPoint({double? x, double? y}) {
final current = manualLandmarks[selectedManualPoint] ?? const Offset(0.5, 0.5);
setState(() {
manualLandmarks[selectedManualPoint] = Offset((x ?? current.dx).clamp(0.0, 1.0).toDouble(), (y ?? current.dy).clamp(0.0, 1.0).toDouble());
showGuides = true;
});
_schedulePreview();
}
Future<void> _loadInitial() async {
setState(() {
loading = true;
error = null;
});
try {
final bytes = await widget.imageFile.readAsBytes();
_rememberPreviewSize(bytes);
if (!mounted) return;
setState(() {
previewBytes = bytes;
loading = false;
});
await _loadSavedLandmarks();
await _showGuidesPreview();
} catch (e) {
if (!mounted) return;
setState(() {
error = '$e';
loading = false;
});
}
}
Future<void> _showGuidesPreview() async {
if (rendering) return;
setState(() => rendering = true);
try {
final result = await api.getFaceGuidesPreview(widget.photo.id, manualLandmarks: _manualPayload());
_rememberPreviewSize(result.bytes);
_rememberPreviewSize(result.bytes);
if (!mounted) return;
setState(() {
previewBytes = result.bytes;
detected = result.detected;
method = result.method;
landmarkCount = result.landmarkCount;
});
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Repères impossibles: $e')));
}
} finally {
if (mounted) setState(() => rendering = false);
}
}
void _schedulePreview() {
debounce?.cancel();
debounce = Timer(const Duration(milliseconds: 260), _refreshPreview);
}
Future<void> _refreshPreview() async {
if (rendering) return;
setState(() => rendering = true);
try {
final result = await api.previewFaceTransform(
photoId: widget.photo.id,
globalStrength: globalStrength,
lipVolume: lipVolume,
upperLip: upperLip,
lowerLip: lowerLip,
noseWidth: noseWidth,
noseTip: noseTip,
chinProjection: chinProjection,
chinWidth: chinWidth,
jawline: jawline,
cheekbones: cheekbones,
eyeColor: eyeColor,
eyeColorStrength: eyeColorStrength,
eyeIrisSize: eyeIrisSize,
eyePaintPoints: _eyePaintPayload(),
eyePaintRadius: eyePaintRadius,
lipTint: lipTint,
lipTintStrength: lipTintStrength,
cheekBlush: cheekBlush,
browDarkness: browDarkness,
browLift: browLift,
skinSmooth: skinSmooth,
showGuides: showGuides,
manualLandmarks: _manualPayload(),
);
if (!mounted) return;
setState(() {
previewBytes = result.bytes;
detected = result.detected;
method = result.method;
landmarkCount = result.landmarkCount;
});
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Aperçu backend impossible: $e')));
}
} finally {
if (mounted) setState(() => rendering = false);
}
}
Future<void> _save() async {
setState(() => rendering = true);
try {
if (manualMode) {
await _saveManualLandmarks(showMessage: false);
}
final created = await api.saveFaceTransform(
photoId: widget.photo.id,
globalStrength: globalStrength,
lipVolume: lipVolume,
upperLip: upperLip,
lowerLip: lowerLip,
noseWidth: noseWidth,
noseTip: noseTip,
chinProjection: chinProjection,
chinWidth: chinWidth,
jawline: jawline,
cheekbones: cheekbones,
eyeColor: eyeColor,
eyeColorStrength: eyeColorStrength,
eyeIrisSize: eyeIrisSize,
eyePaintPoints: _eyePaintPayload(),
eyePaintRadius: eyePaintRadius,
lipTint: lipTint,
lipTintStrength: lipTintStrength,
cheekBlush: cheekBlush,
browDarkness: browDarkness,
browLift: browLift,
skinSmooth: skinSmooth,
showGuides: false,
manualLandmarks: _manualPayload(),
);
if (mounted) Navigator.pop(context, created);
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Sauvegarde backend impossible: $e')));
} finally {
if (mounted) setState(() => rendering = false);
}
}
void _reset() {
setState(() {
selectedPresetId = 'custom';
globalStrength = 0.82;
lipVolume = 0;
upperLip = 0;
lowerLip = 0;
noseWidth = 0;
noseTip = 0;
chinProjection = 0;
chinWidth = 0;
jawline = 0;
cheekbones = 0;
eyeColorStrength = 0;
eyeIrisSize = 0.72;
irisPaintMode = false;
eyePaintRadius = 0.018;
irisPaintPoints.clear();
lipTintStrength = 0;
cheekBlush = 0;
browDarkness = 0;
browLift = 0;
skinSmooth = 0;
showGuides = true;
});
_resetManualLandmarks();
}
Widget _manualPointSlider({required String label, required double value, required ValueChanged<double> onChanged}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [Expanded(child: Text(label)), Text('${(value * 100).round()}%')]),
Slider(
value: value.clamp(0.0, 1.0).toDouble(),
min: 0,
max: 1,
divisions: 100,
onChanged: onChanged,
),
],
);
}
Widget _manualLandmarksCard() {
final current = manualLandmarks[selectedManualPoint] ?? const Offset(0.5, 0.5);
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SwitchListTile(
contentPadding: EdgeInsets.zero,
value: manualMode,
title: const Text('Correction manuelle des repères'),
subtitle: Text(
hasSavedLandmarks
? 'Repères sauvegardés pour cette photo${savedLandmarksAt == null ? '' : ' · $savedLandmarksAt'}.'
: 'À utiliser quand OpenCV/MediaPipe place mal les yeux, le nez, la bouche ou le menton. Tu peux aussi cliquer/glisser directement les repères sur l’aperçu.',
),
onChanged: (value) {
setState(() {
manualMode = value;
showGuides = true;
});
_showGuidesPreview();
},
),
if (manualMode) ...[
const SizedBox(height: 8),
DropdownButtonFormField<String>(
value: selectedManualPoint,
decoration: const InputDecoration(labelText: 'Repère à déplacer'),
items: _manualLabels.entries
.map((entry) => DropdownMenuItem(value: entry.key, child: Text(entry.value)))
.toList(),
onChanged: (value) {
if (value == null) return;
setState(() => selectedManualPoint = value);
},
),
const SizedBox(height: 8),
_manualPointSlider(
label: 'Horizontal : gauche → droite',
value: current.dx,
onChanged: (v) => _updateManualPoint(x: v),
),
_manualPointSlider(
label: 'Vertical : haut → bas',
value: current.dy,
onChanged: (v) => _updateManualPoint(y: v),
),
Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
OutlinedButton.icon(
onPressed: _resetManualLandmarks,
icon: const Icon(Icons.my_location),
label: const Text('Réinitialiser'),
),
FilledButton.tonalIcon(
onPressed: () => _saveManualLandmarks(),
icon: const Icon(Icons.save),
label: const Text('Sauver repères'),
),
OutlinedButton.icon(
onPressed: () => _loadSavedLandmarks(showMessage: true),
icon: const Icon(Icons.download),
label: const Text('Recharger'),
),
if (hasSavedLandmarks)
OutlinedButton.icon(
onPressed: _deleteSavedLandmarks,
icon: const Icon(Icons.delete_outline),
label: const Text('Oublier'),
),
],
),
const SizedBox(height: 8),
Text(
'Repère actif : ${_manualLabels[selectedManualPoint]} · X ${(current.dx * 100).round()}%, Y ${(current.dy * 100).round()}%',
style: Theme.of(context).textTheme.bodySmall,
),
],
],
),
),
);
}
Widget _slider({required String label, required String help, required double value, required ValueChanged<double> onChanged, double min = -1, double max = 1}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(child: Text(label, style: const TextStyle(fontWeight: FontWeight.w700))),
Text('${(value * 100).round()}%'),
],
),
Slider(
value: value,
min: min,
max: max,
divisions: ((max - min) * 20).round(),
label: '${(value * 100).round()}%',
onChanged: (next) {
if (selectedPresetId != 'custom') {
setState(() => selectedPresetId = 'custom');
}
onChanged(next);
_schedulePreview();
},
),
Text(help, style: Theme.of(context).textTheme.bodySmall),
],
);
}
Widget _colorDropdown({
required String label,
required String value,
required Map<String, String> options,
required ValueChanged<String> onChanged,
}) {
return DropdownButtonFormField<String>(
value: value,
decoration: InputDecoration(labelText: label),
items: options.entries
.map(
(entry) => DropdownMenuItem(
value: entry.key,
child: Row(
children: [
Container(
width: 18,
height: 18,
decoration: BoxDecoration(
color: _colorFromHex(entry.key),
shape: BoxShape.circle,
border: Border.all(color: Colors.black26),
),
),
const SizedBox(width: 8),
Text(entry.value),
],
),
),
)
.toList(),
onChanged: rendering
? null
: (next) {
if (next == null) return;
onChanged(next);
_schedulePreview();
},
);
}
Widget _beautyOptionsCard() {
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.palette_outlined, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 8),
const Expanded(child: Text('Options esthétiques couleur & détails', style: TextStyle(fontWeight: FontWeight.w800))),
],
),
const SizedBox(height: 8),
Text('V3.10.1 : yeux, lèvres, joues, sourcils et peau légère. Le pinceau iris est maintenant un vrai masque direct : peins toute la surface de l’iris, puis actualise.', style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: 12),
_sectionTitle('Yeux'),
_colorDropdown(
label: 'Couleur des yeux',
value: eyeColor,
options: _eyeColorOptions,
onChanged: (v) => setState(() => eyeColor = v),
),
_slider(
label: 'Intensité couleur yeux',
help: 'Recolore l’iris en gardant la pupille et les reflets autant que possible. Commence bas : 15 à 35%.',
value: eyeColorStrength,
min: 0,
max: 1,
onChanged: (v) => setState(() => eyeColorStrength = v),
),
_slider(
label: 'Taille masque iris',
help: 'Diminue si le blanc de l’œil ou la paupière se colore. Augmente seulement si la couleur ne couvre pas assez l’iris.',
value: eyeIrisSize,
min: 0.40,
max: 1.25,
onChanged: (v) => setState(() => eyeIrisSize = v),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
value: irisPaintMode,
title: const Text('Peindre l’iris à la souris / au doigt'),
subtitle: Text(
irisPaintPoints.isEmpty
? 'Active, puis peins toute la surface visible de l’iris. Un simple clic au centre ne suffit pas toujours, cette tragédie du pinceau existe.'
: '${irisPaintPoints.length} points de peinture iris. Désactive les repères avant de juger le rendu.',
),
onChanged: (v) {
setState(() {
irisPaintMode = v;
if (v) showGuides = false;
});
},
),
if (irisPaintMode || irisPaintPoints.isNotEmpty) ...[
_slider(
label: 'Taille pinceau iris',
help: 'Baisse si ça déborde. Monte si tu dois couvrir tout l’iris. Le masque est direct, donc il obéit vraiment à ce que tu peins.',
value: eyePaintRadius,
min: 0.006,
max: 0.045,
onChanged: (v) => setState(() => eyePaintRadius = v),
),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton.icon(
onPressed: irisPaintPoints.isEmpty ? null : _clearIrisPainting,
icon: const Icon(Icons.cleaning_services_outlined),
label: const Text('Effacer peinture iris'),
),
FilledButton.tonalIcon(
onPressed: _refreshPreview,
icon: const Icon(Icons.refresh),
label: const Text('Actualiser iris'),
),
],
),
],
const Divider(),
_sectionTitle('Lèvres et joues'),
_colorDropdown(
label: 'Teinte lèvres',
value: lipTint,
options: _lipTintOptions,
onChanged: (v) => setState(() => lipTint = v),
),
_slider(
label: 'Intensité teinte lèvres',
help: 'Ajoute une coloration douce sur les lèvres. Ne remplace pas le volume, ça colore seulement.',
value: lipTintStrength,
min: 0,
max: 1,
onChanged: (v) => setState(() => lipTintStrength = v),
),
_slider(
label: 'Joues / blush',
help: 'Ajoute une chaleur légère sur les joues/pommettes. À garder discret, sinon ça tourne carnaval médical.',
value: cheekBlush,
min: 0,
max: 1,
onChanged: (v) => setState(() => cheekBlush = v),
),
const Divider(),
_sectionTitle('Sourcils et peau'),
_slider(
label: 'Densité sourcils',
help: 'Assombrit légèrement la zone sourcil estimée au-dessus des yeux.',
value: browDarkness,
min: 0,
max: 1,
onChanged: (v) => setState(() => browDarkness = v),
),
_slider(
label: 'Rehaussement sourcils',
help: 'Déplace visuellement la ligne simulée du sourcil. Effet volontairement léger.',
value: browLift,
min: -1,
max: 1,
onChanged: (v) => setState(() => browLift = v),
),
_slider(
label: 'Lissage peau léger',
help: 'Lisse doucement la zone du visage en protégeant autant que possible yeux et lèvres. Pas de cire numérique, merci.',
value: skinSmooth,
min: 0,
max: 1,
onChanged: (v) => setState(() => skinSmooth = v),
),
],
),
),
);
}
Widget _presetsCard() {
final selectedPreset = _presetById(selectedPresetId);
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.tune, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 8),
const Expanded(child: Text('Presets de transformation', style: TextStyle(fontWeight: FontWeight.w800))),
],
),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
value: selectedPresetId,
decoration: const InputDecoration(labelText: 'Preset rapide'),
items: [
const DropdownMenuItem(value: 'custom', child: Text('Réglage libre / manuel')),
..._transformPresets.map((preset) => DropdownMenuItem(value: preset.id, child: Text(preset.name))),
],
onChanged: rendering
? null
: (value) {
if (value == null) return;
if (value == 'custom') {
setState(() => selectedPresetId = 'custom');
return;
}
final preset = _presetById(value);
if (preset != null) _applyPreset(preset);
},
),
if (selectedPreset != null) ...[
const SizedBox(height: 8),
Text(selectedPreset.description, style: Theme.of(context).textTheme.bodySmall),
] else ...[
const SizedBox(height: 8),
Text('Les sliders sont en réglage libre. Les presets servent seulement de point de départ, pas de vérité esthétique tombée du ciel.', style: Theme.of(context).textTheme.bodySmall),
],
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: _transformPresets
.map(
(preset) => ChoiceChip(
label: Text(preset.name),
selected: selectedPresetId == preset.id,
onSelected: rendering ? null : (_) => _applyPreset(preset),
),
)
.toList(),
),
const SizedBox(height: 8),
Text('Conseil : place d’abord les repères manuels, puis applique un preset, puis ajuste doucement. Oui, dans cet ordre. Le chaos a déjà assez d’abonnés.', style: Theme.of(context).textTheme.bodySmall),
],
),
),
);
}
Widget _sectionTitle(String text) {
return Padding(
padding: const EdgeInsets.only(top: 8, bottom: 4),
child: Text(text, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800)),
);
}
Widget _detectionCard() {
final hasMesh = method == 'mediapipe_face_mesh' && landmarkCount > 0;
final isManual = method == 'manual_landmarks';
final color = hasMesh ? Colors.green : (isManual ? Colors.deepPurple : (detected ? Colors.blue : Colors.orange));
final label = hasMesh
? 'Face Mesh détecté'
: (isManual ? 'Repères manuels utilisés' : (detected ? 'Visage détecté sans maillage complet' : 'Repères approximatifs'));
return Card(
color: Color.alphaBlend(color.withOpacity(0.10), Theme.of(context).colorScheme.surface),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(detected ? Icons.check_circle : Icons.warning_amber, color: color),
const SizedBox(width: 10),
Expanded(
child: Text(
'$label\nMéthode: $method${landmarkCount > 0 ? " · Points: $landmarkCount" : ""}\nActive “Afficher repères” pour vérifier si le nez, les lèvres, le menton et l’ovale sont ciblés au bon endroit.',
),
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Transformer le visage'),
actions: [
IconButton(onPressed: rendering ? null : _reset, icon: const Icon(Icons.restart_alt), tooltip: 'Réinitialiser'),
FilledButton.icon(
onPressed: rendering || loading ? null : _save,
icon: rendering ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.save),
label: const Text('Sauver simulation'),
),
const SizedBox(width: 8),
],
),
body: Builder(
builder: (context) {
if (loading) return const Center(child: CircularProgressIndicator());
if (error != null) return Center(child: Text('Erreur: $error'));
final preview = ColoredBox(
color: Colors.black,
child: LayoutBuilder(
builder: (context, previewConstraints) {
final previewSize = Size(previewConstraints.maxWidth, previewConstraints.maxHeight);
final imageRect = _containedImageRect(previewSize);
return Stack(
children: [
Center(
child: InteractiveViewer(
minScale: 0.4,
maxScale: 8,
panEnabled: !(manualMode || irisPaintMode),
scaleEnabled: !(manualMode || irisPaintMode),
child: previewBytes == null
? const CircularProgressIndicator()
: Image.memory(previewBytes!, fit: BoxFit.contain),
),
),
if (manualMode && showGuides)
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
painter: _ManualLandmarksOverlayPainter(
imageRect: imageRect,
landmarks: manualLandmarks,
labels: _manualLabels,
selectedKey: selectedManualPoint,
),
),
),
),
if (irisPaintPoints.isNotEmpty || irisPaintMode)
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
painter: _IrisPaintOverlayPainter(
imageRect: imageRect,
points: irisPaintPoints,
brushRadius: eyePaintRadius,
color: _colorFromHex(eyeColor),
),
),
),
),
if (manualMode || irisPaintMode)
Positioned.fill(
child: MouseRegion(
cursor: SystemMouseCursors.precise,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTapDown: (details) => irisPaintMode
? _paintIrisFromPreview(details.localPosition, previewSize)
: _moveManualPointFromPreview(details.localPosition, previewSize, chooseNearest: true),
onPanDown: (details) => irisPaintMode
? _paintIrisFromPreview(details.localPosition, previewSize)
: _moveManualPointFromPreview(details.localPosition, previewSize, chooseNearest: true),
onPanUpdate: (details) => irisPaintMode
? _paintIrisFromPreview(details.localPosition, previewSize)
: _moveManualPointFromPreview(details.localPosition, previewSize),
),
),
),
if (manualMode || irisPaintMode)
Positioned(
left: 12,
bottom: 12,
right: 12,
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.62),
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Text(
irisPaintMode ? 'Peins toute la surface de l’iris. Évite le blanc de l’œil.' : 'Glisse un repère directement sur l’image. Le repère le plus proche est sélectionné automatiquement.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.white),
),
),
),
),
if (rendering)
const Positioned.fill(
child: ColoredBox(
color: Color(0x22000000),
child: Center(child: CircularProgressIndicator()),
),
),
],
);
},
),
);
final controls = ListView(
padding: const EdgeInsets.all(16),
children: [
_detectionCard(),
_manualLandmarksCard(),
_presetsCard(),
_beautyOptionsCard(),
const Card(
child: Padding(
padding: EdgeInsets.all(12),
child: Text('V3.10.1 : pinceau iris direct. Peins toute la surface de l’iris, puis actualise le backend. La simulation reste non contractuelle.'),
),
),
SwitchListTile(
value: showGuides,
title: const Text('Afficher repères sur l’aperçu'),
subtitle: const Text('À désactiver avant de juger le rendu esthétique. En V3.4, les points Face Mesh permettent de vérifier si les zones sont bien ciblées.'),
onChanged: (value) {
setState(() => showGuides = value);
_schedulePreview();
},
),
const SizedBox(height: 8),
_slider(
label: 'Force globale',
help: 'Limite l’intensité de toutes les transformations. Valeur recommandée : 75 à 105% avec Face Mesh. Reste sous 120% pour éviter l’effet filtre grotesque.',
value: globalStrength,
min: 0.2,
max: 1.35,
onChanged: (v) => setState(() => globalStrength = v),
),
const Divider(),
_sectionTitle('Lèvres'),
_slider(
label: 'Volume global lèvres',
help: 'Augmente ou réduit visuellement le volume global des lèvres.',
value: lipVolume,
onChanged: (v) => setState(() => lipVolume = v),
),
_slider(
label: 'Lèvre supérieure',
help: 'Ajuste surtout la hauteur apparente de la lèvre supérieure.',
value: upperLip,
onChanged: (v) => setState(() => upperLip = v),
),
_slider(
label: 'Lèvre inférieure',
help: 'Ajuste surtout la hauteur apparente de la lèvre inférieure.',
value: lowerLip,
onChanged: (v) => setState(() => lowerLip = v),
),
const Divider(),
_sectionTitle('Nez'),
_slider(
label: 'Largeur du nez',
help: 'Valeur positive : affine visuellement les ailes et l’arête basse du nez.',
value: noseWidth,
onChanged: (v) => setState(() => noseWidth = v),
),
_slider(
label: 'Pointe du nez',
help: 'Valeur positive : remonte légèrement la pointe sur la simulation 2D.',
value: noseTip,
onChanged: (v) => setState(() => noseTip = v),
),
const Divider(),
_sectionTitle('Menton et ovale'),
_slider(
label: 'Projection menton',
help: 'Ajuste légèrement la position visuelle de la zone du menton.',
value: chinProjection,
onChanged: (v) => setState(() => chinProjection = v),
),
_slider(
label: 'Largeur menton',
help: 'Valeur positive : affine visuellement la largeur du menton.',
value: chinWidth,
onChanged: (v) => setState(() => chinWidth = v),
),
_slider(
label: 'Mâchoire / ovale',
help: 'Valeur positive : affine légèrement les contours latéraux du bas du visage.',
value: jawline,
onChanged: (v) => setState(() => jawline = v),
),
_slider(
label: 'Pommettes',
help: 'Valeur positive : accentue légèrement la projection des pommettes.',
value: cheekbones,
onChanged: (v) => setState(() => cheekbones = v),
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: rendering ? null : _refreshPreview,
icon: const Icon(Icons.refresh),
label: const Text('Actualiser aperçu backend'),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: rendering ? null : _showGuidesPreview,
icon: const Icon(Icons.face_retouching_natural),
label: const Text('Voir seulement les repères'),
),
const SizedBox(height: 8),
const Text(
'La simulation est générée par le backend depuis l’original Immich. Elle est enregistrée comme nouvelle photo “simulation”.',
),
],
);
return LayoutBuilder(
builder: (context, constraints) {
final compact = constraints.maxWidth < 820;
if (compact) {
final previewHeight = (constraints.maxHeight * 0.50).clamp(300.0, 520.0).toDouble();
return Column(
children: [
SizedBox(
height: previewHeight,
width: double.infinity,
child: preview,
),
Expanded(child: controls),
],
);
}
return Row(
children: [
Expanded(flex: 4, child: preview),
SizedBox(width: 460, child: controls),
],
);
},
);
},
),
);
}
}
class _IrisPaintOverlayPainter extends CustomPainter {
final Rect imageRect;
final List<Offset> points;
final double brushRadius;
final Color color;
_IrisPaintOverlayPainter({required this.imageRect, required this.points, required this.brushRadius, required this.color});
@override
void paint(Canvas canvas, Size size) {
if (imageRect.width <= 1 || imageRect.height <= 1 || points.isEmpty) return;
final radius = math.max(2.0, brushRadius * math.min(imageRect.width, imageRect.height));
final fill = Paint()
..color = color.withOpacity(0.42)
..style = PaintingStyle.fill;
final stroke = Paint()
..color = Colors.white.withOpacity(0.70)
..style = PaintingStyle.stroke
..strokeWidth = 1.0;
for (final p in points) {
final c = Offset(imageRect.left + p.dx * imageRect.width, imageRect.top + p.dy * imageRect.height);
canvas.drawCircle(c, radius, fill);
canvas.drawCircle(c, radius, stroke);
}
}
@override
bool shouldRepaint(covariant _IrisPaintOverlayPainter oldDelegate) => true;
}
class _ManualLandmarksOverlayPainter extends CustomPainter {
final Rect imageRect;
final Map<String, Offset> landmarks;
final Map<String, String> labels;
final String selectedKey;
_ManualLandmarksOverlayPainter({
required this.imageRect,
required this.landmarks,
required this.labels,
required this.selectedKey,
});
Offset _toCanvas(Offset normalized) {
return Offset(
imageRect.left + normalized.dx * imageRect.width,
imageRect.top + normalized.dy * imageRect.height,
);
}
@override
void paint(Canvas canvas, Size size) {
if (imageRect.width <= 1 || imageRect.height <= 1) return;
final linePaint = Paint()
..color = Colors.white.withOpacity(0.92)
..style = PaintingStyle.stroke
..strokeWidth = 2.0;
final selectedPaint = Paint()
..color = Colors.yellowAccent
..style = PaintingStyle.fill;
final pointPaint = Paint()
..color = Colors.cyanAccent
..style = PaintingStyle.fill;
final outlinePaint = Paint()
..color = Colors.black.withOpacity(0.75)
..style = PaintingStyle.stroke
..strokeWidth = 2.0;
Offset? p(String key) => landmarks[key] == null ? null : _toCanvas(landmarks[key]!);
final faceTop = p('face_top');
final faceLeft = p('face_left');
final faceRight = p('face_right');
final chin = p('chin');
if (faceTop != null && faceLeft != null && faceRight != null && chin != null) {
final path = Path()
..moveTo(faceTop.dx, faceTop.dy)
..lineTo(faceRight.dx, faceRight.dy)
..lineTo(chin.dx, chin.dy)
..lineTo(faceLeft.dx, faceLeft.dy)
..close();
canvas.drawPath(path, outlinePaint);
canvas.drawPath(path, linePaint);
}
final leftEye = p('left_eye');
final rightEye = p('right_eye');
if (leftEye != null && rightEye != null) {
canvas.drawLine(leftEye, rightEye, outlinePaint);
canvas.drawLine(leftEye, rightEye, linePaint);
}
final nose = p('nose');
final mouth = p('mouth');
if (nose != null && mouth != null) {
canvas.drawLine(nose, mouth, outlinePaint);
canvas.drawLine(nose, mouth, linePaint);
}
for (final entry in landmarks.entries) {
final center = _toCanvas(entry.value);
final isSelected = entry.key == selectedKey;
canvas.drawCircle(center, isSelected ? 9 : 7, Paint()..color = Colors.black.withOpacity(0.78));
canvas.drawCircle(center, isSelected ? 7 : 5, isSelected ? selectedPaint : pointPaint);
if (isSelected) {
final label = labels[entry.key] ?? entry.key;
final textPainter = TextPainter(
text: TextSpan(
text: label,
style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w700),
),
textDirection: TextDirection.ltr,
)..layout();
final labelOffset = center + const Offset(10, -26);
final bgRect = Rect.fromLTWH(labelOffset.dx - 6, labelOffset.dy - 4, textPainter.width + 12, textPainter.height + 8);
canvas.drawRRect(
RRect.fromRectAndRadius(bgRect, const Radius.circular(8)),
Paint()..color = Colors.black.withOpacity(0.70),
);
textPainter.paint(canvas, labelOffset);
}
}
}
@override
bool shouldRepaint(covariant _ManualLandmarksOverlayPainter oldDelegate) => true;
}
class _FaceTransformGeometry {
final Offset faceCenter;
final double faceWidth;
final double faceHeight;
final Offset nosePoint;
final Offset mouthCenter;
final double mouthWidth;
final Offset chinPoint;
_FaceTransformGeometry({
required this.faceCenter,
required this.faceWidth,
required this.faceHeight,
required this.nosePoint,
required this.mouthCenter,
required this.mouthWidth,
required this.chinPoint,
});
static _FaceTransformGeometry guessFromImage(img.Image image) {
final w = image.width.toDouble();
final h = image.height.toDouble();
final center = Offset(w / 2, h * 0.46);
final faceWidth = math.max(80.0, w * 0.38);
final faceHeight = math.max(120.0, h * 0.56);
return _FaceTransformGeometry(
faceCenter: center,
faceWidth: faceWidth,
faceHeight: faceHeight,
nosePoint: Offset(center.dx, center.dy + faceHeight * 0.03),
mouthCenter: Offset(center.dx, center.dy + faceHeight * 0.22),
mouthWidth: faceWidth * 0.34,
chinPoint: Offset(center.dx, center.dy + faceHeight * 0.44),
);
}
static _FaceTransformGeometry fromFace(Face face, int targetWidth, int targetHeight, int originalWidth, int originalHeight) {
final sx = targetWidth / math.max(1, originalWidth);
final sy = targetHeight / math.max(1, originalHeight);
Offset scalePoint(dynamic p) => Offset(p.x.toDouble() * sx, p.y.toDouble() * sy);
final box = Rect.fromLTRB(
face.boundingBox.left * sx,
face.boundingBox.top * sy,
face.boundingBox.right * sx,
face.boundingBox.bottom * sy,
);
final noseRaw = face.landmarks[FaceLandmarkType.noseBase]?.position;
final leftMouthRaw = face.landmarks[FaceLandmarkType.leftMouth]?.position;
final rightMouthRaw = face.landmarks[FaceLandmarkType.rightMouth]?.position;
final bottomMouthRaw = face.landmarks[FaceLandmarkType.bottomMouth]?.position;
final nose = noseRaw == null ? Offset(box.center.dx, box.center.dy) : scalePoint(noseRaw);
final leftMouth = leftMouthRaw == null ? Offset(box.left + box.width * 0.36, box.top + box.height * 0.68) : scalePoint(leftMouthRaw);
final rightMouth = rightMouthRaw == null ? Offset(box.left + box.width * 0.64, box.top + box.height * 0.68) : scalePoint(rightMouthRaw);
final bottomMouth = bottomMouthRaw == null ? Offset(box.center.dx, box.top + box.height * 0.73) : scalePoint(bottomMouthRaw);
final mouthCenter = Offset((leftMouth.dx + rightMouth.dx + bottomMouth.dx) / 3, (leftMouth.dy + rightMouth.dy + bottomMouth.dy) / 3);
return _FaceTransformGeometry(
faceCenter: box.center,
faceWidth: math.max(40, box.width),
faceHeight: math.max(60, box.height),
nosePoint: nose,
mouthCenter: mouthCenter,
mouthWidth: math.max(24, (rightMouth.dx - leftMouth.dx).abs()),
chinPoint: Offset(box.center.dx, box.top + box.height * 0.88),
);
}
}
class PhotoMetadataResult {
final String kind;
final String? notes;
PhotoMetadataResult({required this.kind, this.notes});
}
class PhotoMetadataDialog extends StatefulWidget {
final PatientPhoto photo;
const PhotoMetadataDialog({super.key, required this.photo});
@override
State<PhotoMetadataDialog> createState() => _PhotoMetadataDialogState();
}
class _PhotoMetadataDialogState extends State<PhotoMetadataDialog> {
late String kind;
late final TextEditingController notesController;
@override
void initState() {
super.initState();
kind = widget.photo.kind;
notesController = TextEditingController(text: widget.photo.notes ?? '');
}
@override
void dispose() {
notesController.dispose();
super.dispose();
}
void submit() {
Navigator.pop(
context,
PhotoMetadataResult(
kind: kind,
notes: notesController.text.trim(),
),
);
}
@override
Widget build(BuildContext context) {
const kinds = ['original', 'before', 'after', 'annotated', 'planning', 'planification', 'simulation', 'face'];
return AlertDialog(
title: const Text('Modifier la photo'),
content: SizedBox(
width: 520,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
DropdownButtonFormField<String>(
value: kinds.contains(kind) ? kind : 'original',
isExpanded: true,
decoration: const InputDecoration(labelText: 'Type de photo'),
items: kinds
.map((value) => DropdownMenuItem(
value: value,
child: Text(_kindLabel(value)),
))
.toList(),
onChanged: (value) => setState(() => kind = value ?? 'original'),
),
const SizedBox(height: 12),
TextField(
controller: notesController,
minLines: 3,
maxLines: 6,
decoration: const InputDecoration(labelText: 'Note photo'),
),
const SizedBox(height: 8),
Text(
'La modification change seulement le classement dans le dossier patient. Immich garde la photo originale.',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Annuler')),
FilledButton(onPressed: submit, child: const Text('Enregistrer')),
],
);
}
}
class ComparePhotosScreen extends StatelessWidget {
final PatientPhoto original;
final PatientPhoto annotated;
const ComparePhotosScreen({super.key, required this.original, required this.annotated});
@override
Widget build(BuildContext context) {
final api = ApiService();
return Scaffold(
appBar: AppBar(title: const Text('Comparaison')),
body: Row(
children: [
Expanded(child: _ComparePane(title: 'Original', imageUrl: api.photoFileUrl(original.id))),
const VerticalDivider(width: 1),
Expanded(child: _ComparePane(title: 'Annoté', imageUrl: api.photoFileUrl(annotated.id))),
],
),
);
}
}
class CompareSelection {
final PatientPhoto original;
final PatientPhoto annotated;
CompareSelection({required this.original, required this.annotated});
}
class CompareSelectionDialog extends StatefulWidget {
final List<PatientPhoto> originals;
final List<PatientPhoto> annotations;
const CompareSelectionDialog({super.key, required this.originals, required this.annotations});
@override
State<CompareSelectionDialog> createState() => _CompareSelectionDialogState();
}
class _CompareSelectionDialogState extends State<CompareSelectionDialog> {
late PatientPhoto selectedOriginal;
late PatientPhoto selectedAnnotation;
@override
void initState() {
super.initState();
selectedOriginal = widget.originals.first;
selectedAnnotation = widget.annotations.first;
}
String _label(PatientPhoto photo) {
final date = photo.createdAt == null ? '' : ' • ${_formatDate(photo.createdAt!)}';
return '${photo.filename}$date';
}
DropdownMenuItem<PatientPhoto> _item(PatientPhoto photo) {
return DropdownMenuItem(
value: photo,
child: Text(_label(photo), maxLines: 1, overflow: TextOverflow.ellipsis),
);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Choisir la comparaison'),
content: SizedBox(
width: 620,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DropdownButtonFormField<PatientPhoto>(
value: selectedOriginal,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Photo originale',
prefixIcon: Icon(Icons.image),
),
items: widget.originals.map(_item).toList(),
onChanged: (value) {
if (value != null) setState(() => selectedOriginal = value);
},
),
const SizedBox(height: 12),
DropdownButtonFormField<PatientPhoto>(
value: selectedAnnotation,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Annotation à comparer',
prefixIcon: Icon(Icons.draw),
),
items: widget.annotations.map(_item).toList(),
onChanged: (value) {
if (value != null) setState(() => selectedAnnotation = value);
},
),
const SizedBox(height: 12),
Text(
'${widget.originals.length} originale(s), ${widget.annotations.length} annotation(s) disponibles.',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Annuler')),
FilledButton(
onPressed: () => Navigator.pop(
context,
CompareSelection(original: selectedOriginal, annotated: selectedAnnotation),
),
child: const Text('Comparer'),
),
],
);
}
}
class _ComparePane extends StatelessWidget {
final String title;
final String imageUrl;
const _ComparePane({required this.title, required this.imageUrl});
@override
Widget build(BuildContext context) {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(8),
child: Text(title, style: Theme.of(context).textTheme.titleMedium),
),
Expanded(
child: InteractiveViewer(
minScale: 0.5,
maxScale: 6,
child: Image.network(imageUrl, fit: BoxFit.contain),
),
),
],
);
}
}
class PatientFormResult {
final String firstName;
final String lastName;
final String? birthDate;
final String? phone;
final String? email;
final String? procedure;
final String status;
final String consentSigned;
final String? notes;
PatientFormResult({
required this.firstName,
required this.lastName,
this.birthDate,
this.phone,
this.email,
this.procedure,
required this.status,
required this.consentSigned,
this.notes,
});
}
class PatientFormDialog extends StatefulWidget {
final String title;
final Patient? patient;
const PatientFormDialog({super.key, required this.title, this.patient});
@override
State<PatientFormDialog> createState() => _PatientFormDialogState();
}
class _PatientFormDialogState extends State<PatientFormDialog> {
late final TextEditingController firstNameController;
late final TextEditingController lastNameController;
late final TextEditingController birthDateController;
late final TextEditingController phoneController;
late final TextEditingController emailController;
late final TextEditingController procedureController;
late final TextEditingController notesController;
String status = 'actif';
String consent = 'non';
@override
void initState() {
super.initState();
final p = widget.patient;
firstNameController = TextEditingController(text: p?.firstName ?? '');
lastNameController = TextEditingController(text: p?.lastName ?? '');
birthDateController = TextEditingController(text: p?.birthDate ?? '');
phoneController = TextEditingController(text: p?.phone ?? '');
emailController = TextEditingController(text: p?.email ?? '');
procedureController = TextEditingController(text: p?.procedure ?? '');
notesController = TextEditingController(text: p?.notes ?? '');
status = p?.status ?? 'actif';
consent = p?.consentSigned ?? 'non';
}
@override
void dispose() {
firstNameController.dispose();
lastNameController.dispose();
birthDateController.dispose();
phoneController.dispose();
emailController.dispose();
procedureController.dispose();
notesController.dispose();
super.dispose();
}
void submit() {
if (firstNameController.text.trim().isEmpty || lastNameController.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Nom et prénom obligatoires.')));
return;
}
Navigator.pop(
context,
PatientFormResult(
firstName: firstNameController.text.trim(),
lastName: lastNameController.text.trim(),
birthDate: birthDateController.text.trim(),
phone: phoneController.text.trim(),
email: emailController.text.trim(),
procedure: procedureController.text.trim(),
status: status,
consentSigned: consent,
notes: notesController.text.trim(),
),
);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.title),
content: SizedBox(
width: 720,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(child: TextField(controller: lastNameController, decoration: const InputDecoration(labelText: 'Nom'))),
const SizedBox(width: 12),
Expanded(child: TextField(controller: firstNameController, decoration: const InputDecoration(labelText: 'Prénom'))),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(child: TextField(controller: birthDateController, decoration: const InputDecoration(labelText: 'Date naissance'))),
const SizedBox(width: 12),
Expanded(child: TextField(controller: phoneController, decoration: const InputDecoration(labelText: 'Téléphone'))),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(child: TextField(controller: emailController, decoration: const InputDecoration(labelText: 'Email'))),
const SizedBox(width: 12),
Expanded(child: TextField(controller: procedureController, decoration: const InputDecoration(labelText: 'Acte envisagé'))),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: DropdownButtonFormField<String>(
value: status,
decoration: const InputDecoration(labelText: 'Statut'),
items: const [
DropdownMenuItem(value: 'actif', child: Text('Actif')),
DropdownMenuItem(value: 'suivi', child: Text('Suivi')),
DropdownMenuItem(value: 'archive', child: Text('Archivé')),
],
onChanged: (value) => setState(() => status = value ?? 'actif'),
),
),
const SizedBox(width: 12),
Expanded(
child: DropdownButtonFormField<String>(
value: consent,
decoration: const InputDecoration(labelText: 'Consentement photo'),
items: const [
DropdownMenuItem(value: 'non', child: Text('Non signé')),
DropdownMenuItem(value: 'oui', child: Text('Signé')),
],
onChanged: (value) => setState(() => consent = value ?? 'non'),
),
),
],
),
const SizedBox(height: 12),
TextField(
controller: notesController,
minLines: 3,
maxLines: 5,
decoration: const InputDecoration(labelText: 'Notes consultation'),
),
],
),
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Annuler')),
FilledButton(onPressed: submit, child: const Text('Enregistrer')),
],
);
}
}
enum AnnotationTool { pen, line, arrow, circle, text }
class AnnotationScreen extends StatefulWidget {
final File imageFile;
const AnnotationScreen({super.key, required this.imageFile});
@override
State<AnnotationScreen> createState() => _AnnotationScreenState();
}
class _AnnotationScreenState extends State<AnnotationScreen> {
final GlobalKey canvasKey = GlobalKey();
List<AnnotationMark> marks = [];
AnnotationMark? currentMark;
ui.Image? sourceImage;
AnnotationTool selectedTool = AnnotationTool.pen;
Color selectedColor = Colors.red;
double selectedStrokeWidth = 5;
@override
void initState() {
super.initState();
_loadImage();
}
Future<void> _loadImage() async {
final bytes = await widget.imageFile.readAsBytes();
final codec = await ui.instantiateImageCodec(bytes);
final frame = await codec.getNextFrame();
if (!mounted) return;
setState(() => sourceImage = frame.image);
}
Size get _imageSize => Size(sourceImage!.width.toDouble(), sourceImage!.height.toDouble());
Rect _imageRectInside(Size canvasSize, Size imageSize) {
final scale = math.min(canvasSize.width / imageSize.width, canvasSize.height / imageSize.height);
final displayWidth = imageSize.width * scale;
final displayHeight = imageSize.height * scale;
final left = (canvasSize.width - displayWidth) / 2;
final top = (canvasSize.height - displayHeight) / 2;
return Rect.fromLTWH(left, top, displayWidth, displayHeight);
}
Offset? _globalToImagePoint(Offset globalPosition, Rect imageRect) {
final renderObject = canvasKey.currentContext?.findRenderObject();
if (renderObject is! RenderBox) return null;
final local = renderObject.globalToLocal(globalPosition);
if (!imageRect.contains(local)) return null;
return Offset(
((local.dx - imageRect.left) / imageRect.width) * _imageSize.width,
((local.dy - imageRect.top) / imageRect.height) * _imageSize.height,
);
}
void _startMark(Offset globalPosition, Rect imageRect) {
if (selectedTool == AnnotationTool.text) return;
final point = _globalToImagePoint(globalPosition, imageRect);
if (point == null) return;
setState(() {
currentMark = AnnotationMark(
tool: selectedTool,
points: [point],
color: selectedColor,
strokeWidth: selectedStrokeWidth,
);
marks.add(currentMark!);
});
}
void _updateMark(Offset globalPosition, Rect imageRect) {
if (selectedTool == AnnotationTool.text) return;
final point = _globalToImagePoint(globalPosition, imageRect);
if (point == null || currentMark == null) return;
setState(() {
if (currentMark!.tool == AnnotationTool.pen) {
currentMark!.points.add(point);
} else if (currentMark!.points.length == 1) {
currentMark!.points.add(point);
} else {
currentMark!.points[1] = point;
}
});
}
Future<void> _addTextMark(Offset globalPosition, Rect imageRect) async {
final point = _globalToImagePoint(globalPosition, imageRect);
if (point == null) return;
final controller = TextEditingController();
final text = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Ajouter un texte'),
content: TextField(
controller: controller,
autofocus: true,
minLines: 1,
maxLines: 3,
decoration: const InputDecoration(
labelText: 'Texte à afficher sur la photo',
hintText: 'Ex: Rhinoplastie, zone à corriger, +1 ml...',
),
onSubmitted: (_) => Navigator.pop(context, controller.text.trim()),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Annuler')),
FilledButton(onPressed: () => Navigator.pop(context, controller.text.trim()), child: const Text('Ajouter')),
],
),
);
controller.dispose();
if (text == null || text.trim().isEmpty) return;
setState(() {
currentMark = null;
marks.add(
AnnotationMark(
tool: AnnotationTool.text,
points: [point],
color: selectedColor,
strokeWidth: selectedStrokeWidth,
text: text.trim(),
),
);
});
}
void _clearMarks() {
if (marks.isEmpty) return;
setState(() {
currentMark = null;
marks = <AnnotationMark>[];
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Tracés effacés')),
);
}
void _undoLastMark() {
if (marks.isEmpty) return;
setState(() {
currentMark = null;
marks = marks.sublist(0, marks.length - 1);
});
}
Future<void> saveAnnotatedImage() async {
final image = sourceImage;
if (image == null) return;
final width = image.width;
final height = image.height;
final imageSize = Size(width.toDouble(), height.toDouble());
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
final targetRect = Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble());
canvas.drawImage(image, Offset.zero, Paint());
drawMarksOnCanvas(canvas: canvas, marks: marks, imageRect: targetRect, imageSize: imageSize, scaleStroke: true);
final picture = recorder.endRecording();
final annotated = await picture.toImage(width, height);
final ByteData? byteData = await annotated.toByteData(format: ui.ImageByteFormat.png);
if (byteData == null) return;
final directory = await getTemporaryDirectory();
final file = File('${directory.path}/annotated_${DateTime.now().millisecondsSinceEpoch}.png');
await file.writeAsBytes(byteData.buffer.asUint8List());
if (mounted) Navigator.pop(context, file);
}
Widget _toolButton(AnnotationTool tool, IconData icon, String label) {
final selected = selectedTool == tool;
return ChoiceChip(
selected: selected,
avatar: Icon(icon, size: 18),
label: Text(label),
onSelected: (_) => setState(() => selectedTool = tool),
);
}
Widget _colorButton(Color color) {
final selected = selectedColor.value == color.value;
return InkWell(
onTap: () => setState(() => selectedColor = color),
child: Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(width: selected ? 3 : 1, color: selected ? Colors.black : Colors.grey),
),
),
);
}
@override
Widget build(BuildContext context) {
final image = sourceImage;
if (image == null) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return Scaffold(
appBar: AppBar(
title: const Text('Planification / annotation'),
actions: [
IconButton(
tooltip: 'Annuler dernier tracé',
onPressed: marks.isEmpty ? null : _undoLastMark,
icon: const Icon(Icons.undo),
),
IconButton(
tooltip: 'Effacer les tracés en cours',
onPressed: marks.isEmpty ? null : _clearMarks,
icon: const Icon(Icons.delete_outline),
),
FilledButton(onPressed: saveAnnotatedImage, child: const Text('Sauver')),
const SizedBox(width: 8),
],
),
body: Column(
children: [
Material(
elevation: 1,
child: Padding(
padding: const EdgeInsets.all(8),
child: Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_toolButton(AnnotationTool.pen, Icons.edit, 'Stylo'),
_toolButton(AnnotationTool.line, Icons.show_chart, 'Ligne'),
_toolButton(AnnotationTool.arrow, Icons.arrow_forward, 'Flèche'),
_toolButton(AnnotationTool.circle, Icons.radio_button_unchecked, 'Cercle'),
_toolButton(AnnotationTool.text, Icons.text_fields, 'Texte'),
const SizedBox(width: 12),
_colorButton(Colors.red),
_colorButton(Colors.blue),
_colorButton(Colors.green),
_colorButton(Colors.black),
const SizedBox(width: 12),
const Text('Épaisseur / taille texte'),
SizedBox(
width: 160,
child: Slider(
value: selectedStrokeWidth,
min: 2,
max: 14,
divisions: 6,
label: selectedStrokeWidth.round().toString(),
onChanged: (value) => setState(() => selectedStrokeWidth = value),
),
),
],
),
),
),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final canvasSize = Size(constraints.maxWidth, constraints.maxHeight);
final imageRect = _imageRectInside(canvasSize, _imageSize);
return GestureDetector(
key: canvasKey,
behavior: HitTestBehavior.opaque,
onTapDown: (details) {
if (selectedTool == AnnotationTool.text) {
_addTextMark(details.globalPosition, imageRect);
}
},
onPanStart: (details) => _startMark(details.globalPosition, imageRect),
onPanUpdate: (details) => _updateMark(details.globalPosition, imageRect),
onPanEnd: (_) => currentMark = null,
child: CustomPaint(
size: Size.infinite,
painter: AnnotationCanvasPainter(
image: image,
marks: marks,
imageRect: imageRect,
imageSize: _imageSize,
),
child: const SizedBox.expand(),
),
);
},
),
),
],
),
);
}
}
class AnnotationMark {
final AnnotationTool tool;
final List<Offset> points;
final Color color;
final double strokeWidth;
final String? text;
AnnotationMark({required this.tool, required this.points, required this.color, required this.strokeWidth, this.text});
}
class AnnotationCanvasPainter extends CustomPainter {
final ui.Image image;
final List<AnnotationMark> marks;
final Rect imageRect;
final Size imageSize;
AnnotationCanvasPainter({required this.image, required this.marks, required this.imageRect, required this.imageSize});
@override
void paint(Canvas canvas, Size size) {
canvas.drawImageRect(
image,
Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()),
imageRect,
Paint(),
);
drawMarksOnCanvas(canvas: canvas, marks: marks, imageRect: imageRect, imageSize: imageSize, scaleStroke: false);
}
@override
bool shouldRepaint(covariant AnnotationCanvasPainter oldDelegate) => true;
}
void drawMarksOnCanvas({
required Canvas canvas,
required List<AnnotationMark> marks,
required Rect imageRect,
required Size imageSize,
required bool scaleStroke,
}) {
Offset toCanvasPoint(Offset imagePoint) {
return Offset(
imageRect.left + (imagePoint.dx / imageSize.width) * imageRect.width,
imageRect.top + (imagePoint.dy / imageSize.height) * imageRect.height,
);
}
for (final mark in marks) {
if (mark.points.isEmpty) continue;
final strokeWidth = scaleStroke ? math.max(mark.strokeWidth, math.min(imageSize.width, imageSize.height) / 260) : mark.strokeWidth;
final paint = Paint()
..color = mark.color
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..style = PaintingStyle.stroke;
if (mark.tool == AnnotationTool.text) {
final text = mark.text ?? '';
if (text.trim().isEmpty) continue;
final position = toCanvasPoint(mark.points.first);
_drawTextMark(
canvas: canvas,
text: text,
position: position,
color: mark.color,
fontSize: math.max(16.0, strokeWidth * 7),
maxWidth: math.max(120.0, imageRect.right - position.dx - 8),
);
continue;
}
if (mark.tool == AnnotationTool.pen) {
if (mark.points.length < 2) continue;
final first = toCanvasPoint(mark.points.first);
final path = Path()..moveTo(first.dx, first.dy);
for (final point in mark.points.skip(1)) {
final p = toCanvasPoint(point);
path.lineTo(p.dx, p.dy);
}
canvas.drawPath(path, paint);
continue;
}
if (mark.points.length < 2) continue;
final start = toCanvasPoint(mark.points[0]);
final end = toCanvasPoint(mark.points[1]);
switch (mark.tool) {
case AnnotationTool.line:
canvas.drawLine(start, end, paint);
break;
case AnnotationTool.arrow:
canvas.drawLine(start, end, paint);
_drawArrowHead(canvas, start, end, paint);
break;
case AnnotationTool.circle:
canvas.drawOval(Rect.fromPoints(start, end), paint);
break;
case AnnotationTool.pen:
case AnnotationTool.text:
break;
}
}
}
void _drawTextMark({
required Canvas canvas,
required String text,
required Offset position,
required Color color,
required double fontSize,
required double maxWidth,
}) {
final outlinePainter = TextPainter(
text: TextSpan(
text: text,
style: TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.w700,
foreground: Paint()
..style = PaintingStyle.stroke
..strokeWidth = math.max(2, fontSize / 14)
..color = Colors.white,
),
),
textDirection: TextDirection.ltr,
maxLines: 3,
ellipsis: '…',
)..layout(maxWidth: maxWidth);
final fillPainter = TextPainter(
text: TextSpan(
text: text,
style: TextStyle(
color: color,
fontSize: fontSize,
fontWeight: FontWeight.w700,
),
),
textDirection: TextDirection.ltr,
maxLines: 3,
ellipsis: '…',
)..layout(maxWidth: maxWidth);
outlinePainter.paint(canvas, position);
fillPainter.paint(canvas, position);
}
void _drawArrowHead(Canvas canvas, Offset start, Offset end, Paint paint) {
final angle = math.atan2(end.dy - start.dy, end.dx - start.dx);
final size = math.max(12.0, paint.strokeWidth * 4);
final p1 = Offset(end.dx - size * math.cos(angle - math.pi / 6), end.dy - size * math.sin(angle - math.pi / 6));
final p2 = Offset(end.dx - size * math.cos(angle + math.pi / 6), end.dy - size * math.sin(angle + math.pi / 6));
canvas.drawLine(end, p1, paint);
canvas.drawLine(end, p2, paint);
}
class _StatusChip extends StatelessWidget {
final String label;
const _StatusChip({required this.label});
@override
Widget build(BuildContext context) {
return Chip(label: Text(label));
}
}
class _KindChip extends StatelessWidget {
final String kind;
const _KindChip({required this.kind});
@override
Widget build(BuildContext context) {
final icon = kind == 'annotated'
? Icons.draw
: kind == 'face'
? Icons.face_retouching_natural
: kind == 'before'
? Icons.looks_one
: kind == 'after'
? Icons.looks_two
: kind == 'planning' || kind == 'planification'
? Icons.architecture
: kind == 'simulation'
? Icons.auto_fix_high
: Icons.image;
return Chip(avatar: Icon(icon, size: 16), label: Text(_kindLabel(kind)));
}
}
String _kindLabel(String kind) {
switch (kind) {
case 'original':
return 'Originale';
case 'before':
return 'Avant';
case 'after':
return 'Après';
case 'annotated':
return 'Annotée';
case 'planning':
case 'planification':
return 'Planification';
case 'simulation':
return 'Simulation';
case 'face':
return 'Visage Immich';
default:
return kind;
}
}
class _InfoChip extends StatelessWidget {
final IconData icon;
final String text;
const _InfoChip({required this.icon, required this.text});
@override
Widget build(BuildContext context) {
return Chip(avatar: Icon(icon, size: 16), label: Text(text));
}
}
String _formatDate(DateTime date) {
String two(int n) => n.toString().padLeft(2, '0');
return '${two(date.day)}/${two(date.month)}/${date.year} ${two(date.hour)}:${two(date.minute)}';
}
String _initial(String value) {
final cleaned = value.trim();
if (cleaned.isEmpty) return '?';
return cleaned.substring(0, 1).toUpperCase();
}