4647 lines
156 KiB
Dart
4647 lines
156 KiB
Dart
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();
|
||
}
|