Files
aesthetic-immich-starter/flutter_app/lib/api_service.dart
T

840 lines
26 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
import 'config.dart';
class Patient {
final int id;
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;
final String? immichAlbumId;
Patient({
required this.id,
required this.firstName,
required this.lastName,
this.birthDate,
this.phone,
this.email,
this.procedure,
this.status,
this.consentSigned,
this.notes,
this.immichAlbumId,
});
factory Patient.fromJson(Map<String, dynamic> json) {
return Patient(
id: json['id'] as int,
firstName: (json['first_name'] ?? '') as String,
lastName: (json['last_name'] ?? '') as String,
birthDate: json['birth_date'] as String?,
phone: json['phone'] as String?,
email: json['email'] as String?,
procedure: json['procedure'] as String?,
status: json['status'] as String?,
consentSigned: json['consent_signed'] as String?,
notes: json['notes'] as String?,
immichAlbumId: json['immich_album_id'] as String?,
);
}
String get displayName => '$lastName $firstName'.trim();
}
class PatientPhoto {
final int id;
final int patientId;
final String kind;
final String filename;
final String immichAssetId;
final String? notes;
final DateTime? createdAt;
PatientPhoto({
required this.id,
required this.patientId,
required this.kind,
required this.filename,
required this.immichAssetId,
this.notes,
this.createdAt,
});
factory PatientPhoto.fromJson(Map<String, dynamic> json) {
return PatientPhoto(
id: json['id'] as int,
patientId: json['patient_id'] as int,
kind: json['kind'] as String,
filename: json['filename'] as String,
immichAssetId: json['immich_asset_id'] as String,
notes: json['notes'] as String?,
createdAt: DateTime.tryParse((json['created_at'] ?? '').toString()),
);
}
}
class ConsultationRecord {
final int id;
final int patientId;
final String? consultationDate;
final String? procedure;
final String? reason;
final String? plan;
final String? followUp;
final String? status;
final String? notes;
final DateTime? createdAt;
ConsultationRecord({
required this.id,
required this.patientId,
this.consultationDate,
this.procedure,
this.reason,
this.plan,
this.followUp,
this.status,
this.notes,
this.createdAt,
});
factory ConsultationRecord.fromJson(Map<String, dynamic> json) {
return ConsultationRecord(
id: json['id'] as int,
patientId: json['patient_id'] as int,
consultationDate: json['consultation_date'] as String?,
procedure: json['procedure'] as String?,
reason: json['reason'] as String?,
plan: json['plan'] as String?,
followUp: json['follow_up'] as String?,
status: json['status'] as String?,
notes: json['notes'] as String?,
createdAt: DateTime.tryParse((json['created_at'] ?? '').toString()),
);
}
}
class DocumentRecord {
final int id;
final int patientId;
final int? consultationId;
final String documentType;
final String title;
final String status;
final String? content;
final String? signedBy;
final String? signedAt;
final DateTime? createdAt;
DocumentRecord({
required this.id,
required this.patientId,
this.consultationId,
required this.documentType,
required this.title,
required this.status,
this.content,
this.signedBy,
this.signedAt,
this.createdAt,
});
factory DocumentRecord.fromJson(Map<String, dynamic> json) {
return DocumentRecord(
id: json['id'] as int,
patientId: json['patient_id'] as int,
consultationId: json['consultation_id'] as int?,
documentType: (json['document_type'] ?? 'autre') as String,
title: (json['title'] ?? 'Document') as String,
status: (json['status'] ?? 'brouillon') as String,
content: json['content'] as String?,
signedBy: json['signed_by'] as String?,
signedAt: json['signed_at'] as String?,
createdAt: DateTime.tryParse((json['created_at'] ?? '').toString()),
);
}
}
class ImmichPerson {
final String id;
final String? name;
final String thumbnailUrl;
final bool? isHidden;
ImmichPerson({
required this.id,
this.name,
required this.thumbnailUrl,
this.isHidden,
});
factory ImmichPerson.fromJson(Map<String, dynamic> json, String baseUrl) {
final rawThumbnail = (json['thumbnail_url'] ?? '') as String;
final resolvedThumbnail = rawThumbnail.startsWith('http') ? rawThumbnail : '$baseUrl$rawThumbnail';
return ImmichPerson(
id: json['id'] as String,
name: json['name'] as String?,
thumbnailUrl: resolvedThumbnail,
isHidden: json['is_hidden'] as bool?,
);
}
String get displayName {
final cleaned = (name ?? '').trim();
return cleaned.isEmpty ? 'Sans nom' : cleaned;
}
}
class ImmichAsset {
final String id;
final String? filename;
final String thumbnailUrl;
final String fileUrl;
final String? takenAt;
final String? type;
ImmichAsset({
required this.id,
this.filename,
required this.thumbnailUrl,
required this.fileUrl,
this.takenAt,
this.type,
});
factory ImmichAsset.fromJson(Map<String, dynamic> json, String baseUrl) {
String resolve(String raw) => raw.startsWith('http') ? raw : '$baseUrl$raw';
final rawThumbnail = (json['thumbnail_url'] ?? '') as String;
final rawFile = (json['file_url'] ?? '') as String;
return ImmichAsset(
id: json['id'] as String,
filename: json['filename'] as String?,
thumbnailUrl: resolve(rawThumbnail),
fileUrl: resolve(rawFile),
takenAt: json['taken_at'] as String?,
type: json['type'] as String?,
);
}
String get displayName {
final cleaned = (filename ?? '').trim();
return cleaned.isEmpty ? 'Photo Immich' : cleaned;
}
}
class FaceTransformPreview {
final Uint8List bytes;
final bool detected;
final String method;
final int landmarkCount;
FaceTransformPreview({
required this.bytes,
required this.detected,
required this.method,
required this.landmarkCount,
});
}
class SavedFaceLandmarks {
final Map<String, List<double>>? manualLandmarks;
final String? updatedAt;
SavedFaceLandmarks({required this.manualLandmarks, this.updatedAt});
factory SavedFaceLandmarks.fromJson(Map<String, dynamic> json) {
final raw = json['manual_landmarks'];
Map<String, List<double>>? parsed;
if (raw is Map) {
parsed = {};
raw.forEach((key, value) {
if (value is List && value.length >= 2) {
parsed![key.toString()] = [
(value[0] as num).toDouble(),
(value[1] as num).toDouble(),
];
}
});
}
return SavedFaceLandmarks(
manualLandmarks: parsed == null || parsed.isEmpty ? null : parsed,
updatedAt: json['updated_at']?.toString(),
);
}
}
class ApiService {
final String baseUrl;
ApiService({this.baseUrl = AppConfig.apiBaseUrl});
Future<List<Patient>> getPatients({String? query}) async {
final uri = Uri.parse('$baseUrl/patients').replace(
queryParameters: query == null || query.trim().isEmpty ? null : {'q': query.trim()},
);
final response = await http.get(uri);
_check(response);
final data = jsonDecode(response.body) as List<dynamic>;
return data.map((e) => Patient.fromJson(e as Map<String, dynamic>)).toList();
}
Future<Patient> createPatient({
required String firstName,
required String lastName,
String? birthDate,
String? phone,
String? email,
String? procedure,
String? status,
String? consentSigned,
String? notes,
}) async {
final uri = Uri.parse('$baseUrl/patients');
final response = await http.post(
uri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'first_name': firstName,
'last_name': lastName,
'birth_date': _nullIfEmpty(birthDate),
'phone': _nullIfEmpty(phone),
'email': _nullIfEmpty(email),
'procedure': _nullIfEmpty(procedure),
'status': _nullIfEmpty(status) ?? 'actif',
'consent_signed': _nullIfEmpty(consentSigned) ?? 'non',
'notes': _nullIfEmpty(notes),
}),
);
_check(response);
return Patient.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}
Future<Patient> updatePatient({
required int patientId,
String? firstName,
String? lastName,
String? birthDate,
String? phone,
String? email,
String? procedure,
String? status,
String? consentSigned,
String? notes,
}) async {
final uri = Uri.parse('$baseUrl/patients/$patientId');
final response = await http.patch(
uri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
if (firstName != null) 'first_name': firstName,
if (lastName != null) 'last_name': lastName,
if (birthDate != null) 'birth_date': _nullIfEmpty(birthDate),
if (phone != null) 'phone': _nullIfEmpty(phone),
if (email != null) 'email': _nullIfEmpty(email),
if (procedure != null) 'procedure': _nullIfEmpty(procedure),
if (status != null) 'status': _nullIfEmpty(status),
if (consentSigned != null) 'consent_signed': _nullIfEmpty(consentSigned),
if (notes != null) 'notes': _nullIfEmpty(notes),
}),
);
_check(response);
return Patient.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}
Future<List<PatientPhoto>> getPatientPhotos(int patientId) async {
final uri = Uri.parse('$baseUrl/patients/$patientId/photos');
final response = await http.get(uri);
_check(response);
final data = jsonDecode(response.body) as List<dynamic>;
return data.map((e) => PatientPhoto.fromJson(e as Map<String, dynamic>)).toList();
}
Future<PatientPhoto> uploadPatientPhoto({
required int patientId,
required File file,
required String kind,
String? notes,
}) async {
final uri = Uri.parse('$baseUrl/patients/$patientId/photos');
final request = http.MultipartRequest('POST', uri);
request.fields['kind'] = kind;
if (notes != null && notes.trim().isNotEmpty) request.fields['notes'] = notes.trim();
request.files.add(await http.MultipartFile.fromPath('file', file.path));
final streamed = await request.send();
final response = await http.Response.fromStream(streamed);
_check(response);
return PatientPhoto.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}
String photoThumbnailUrl(int photoId) => '$baseUrl/photos/$photoId/thumbnail';
String photoFileUrl(int photoId) => '$baseUrl/photos/$photoId/file';
Map<String, dynamic> _faceTransformPayload({
required double globalStrength,
required double lipVolume,
required double upperLip,
required double lowerLip,
required double noseWidth,
required double noseTip,
required double chinProjection,
required double chinWidth,
required double jawline,
required double cheekbones,
required String? eyeColor,
required double eyeColorStrength,
required double eyeIrisSize,
List<List<double>>? eyePaintPoints,
required double eyePaintRadius,
required String? lipTint,
required double lipTintStrength,
required double cheekBlush,
required double browDarkness,
required double browLift,
required double skinSmooth,
required bool showGuides,
Map<String, List<double>>? manualLandmarks,
}) {
return {
'global_strength': globalStrength,
'lip_volume': lipVolume,
'upper_lip': upperLip,
'lower_lip': lowerLip,
'nose_width': noseWidth,
'nose_tip': noseTip,
'chin_projection': chinProjection,
'chin_width': chinWidth,
'jawline': jawline,
'cheekbones': cheekbones,
if (eyeColor != null && eyeColor.isNotEmpty) 'eye_color': eyeColor,
'eye_color_strength': eyeColorStrength,
'eye_iris_size': eyeIrisSize,
if (eyePaintPoints != null && eyePaintPoints.isNotEmpty) 'eye_paint_points': eyePaintPoints,
if (eyePaintPoints != null && eyePaintPoints.isNotEmpty) 'eye_paint_radius': eyePaintRadius,
if (lipTint != null && lipTint.isNotEmpty) 'lip_tint': lipTint,
'lip_tint_strength': lipTintStrength,
'cheek_blush': cheekBlush,
'brow_darkness': browDarkness,
'brow_lift': browLift,
'skin_smooth': skinSmooth,
'show_guides': showGuides,
if (manualLandmarks != null && manualLandmarks.isNotEmpty) 'manual_landmarks': manualLandmarks,
};
}
FaceTransformPreview _facePreviewFromResponse(http.Response response) {
return FaceTransformPreview(
bytes: response.bodyBytes,
detected: (response.headers['x-face-detected'] ?? '').toLowerCase() == 'true',
method: response.headers['x-face-method'] ?? 'unknown',
landmarkCount: int.tryParse(response.headers['x-face-landmark-count'] ?? '0') ?? 0,
);
}
Future<SavedFaceLandmarks> getSavedFaceLandmarks(int photoId) async {
final uri = Uri.parse('$baseUrl/photos/$photoId/face-landmarks');
final response = await http.get(uri);
_check(response);
return SavedFaceLandmarks.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}
Future<void> saveFaceLandmarks(int photoId, Map<String, List<double>> manualLandmarks) async {
final uri = Uri.parse('$baseUrl/photos/$photoId/face-landmarks');
final response = await http.put(
uri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'manual_landmarks': manualLandmarks}),
);
_check(response);
}
Future<void> deleteSavedFaceLandmarks(int photoId) async {
final uri = Uri.parse('$baseUrl/photos/$photoId/face-landmarks');
final response = await http.delete(uri);
_check(response);
}
Future<FaceTransformPreview> getFaceGuidesPreview(int photoId, {Map<String, List<double>>? manualLandmarks}) async {
final uri = Uri.parse('$baseUrl/photos/$photoId/face-guides');
final response = await http.post(
uri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
if (manualLandmarks != null && manualLandmarks.isNotEmpty) 'manual_landmarks': manualLandmarks,
}),
);
_check(response);
return _facePreviewFromResponse(response);
}
Future<FaceTransformPreview> previewFaceTransform({
required int photoId,
required double globalStrength,
required double lipVolume,
required double upperLip,
required double lowerLip,
required double noseWidth,
required double noseTip,
required double chinProjection,
required double chinWidth,
required double jawline,
required double cheekbones,
required String? eyeColor,
required double eyeColorStrength,
required double eyeIrisSize,
List<List<double>>? eyePaintPoints,
required double eyePaintRadius,
required String? lipTint,
required double lipTintStrength,
required double cheekBlush,
required double browDarkness,
required double browLift,
required double skinSmooth,
required bool showGuides,
Map<String, List<double>>? manualLandmarks,
}) async {
final uri = Uri.parse('$baseUrl/photos/$photoId/face-transform/preview');
final response = await http.post(
uri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode(_faceTransformPayload(
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: eyePaintPoints,
eyePaintRadius: eyePaintRadius,
lipTint: lipTint,
lipTintStrength: lipTintStrength,
cheekBlush: cheekBlush,
browDarkness: browDarkness,
browLift: browLift,
skinSmooth: skinSmooth,
showGuides: showGuides,
manualLandmarks: manualLandmarks,
)),
);
_check(response);
return _facePreviewFromResponse(response);
}
Future<PatientPhoto> saveFaceTransform({
required int photoId,
required double globalStrength,
required double lipVolume,
required double upperLip,
required double lowerLip,
required double noseWidth,
required double noseTip,
required double chinProjection,
required double chinWidth,
required double jawline,
required double cheekbones,
required String? eyeColor,
required double eyeColorStrength,
required double eyeIrisSize,
List<List<double>>? eyePaintPoints,
required double eyePaintRadius,
required String? lipTint,
required double lipTintStrength,
required double cheekBlush,
required double browDarkness,
required double browLift,
required double skinSmooth,
required bool showGuides,
Map<String, List<double>>? manualLandmarks,
}) async {
final uri = Uri.parse('$baseUrl/photos/$photoId/face-transform');
final response = await http.post(
uri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode(_faceTransformPayload(
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: eyePaintPoints,
eyePaintRadius: eyePaintRadius,
lipTint: lipTint,
lipTintStrength: lipTintStrength,
cheekBlush: cheekBlush,
browDarkness: browDarkness,
browLift: browLift,
skinSmooth: skinSmooth,
showGuides: showGuides,
manualLandmarks: manualLandmarks,
)),
);
_check(response);
return PatientPhoto.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}
Future<File> downloadPhotoFile(PatientPhoto photo) async {
final response = await http.get(Uri.parse(photoFileUrl(photo.id)));
_check(response);
final directory = await getTemporaryDirectory();
final extension = photo.filename.contains('.') ? photo.filename.split('.').last : 'jpg';
final file = File('${directory.path}/photo_${photo.id}_${DateTime.now().millisecondsSinceEpoch}.$extension');
await file.writeAsBytes(response.bodyBytes);
return file;
}
Future<PatientPhoto> updatePatientPhoto({
required int photoId,
String? kind,
String? notes,
}) async {
final uri = Uri.parse('$baseUrl/photos/$photoId');
final response = await http.patch(
uri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
if (kind != null) 'kind': _nullIfEmpty(kind),
if (notes != null) 'notes': _nullIfEmpty(notes),
}),
);
_check(response);
return PatientPhoto.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}
Future<void> deletePatientPhoto(int photoId) async {
final uri = Uri.parse('$baseUrl/photos/$photoId');
final response = await http.delete(uri);
_check(response);
}
Future<List<ImmichPerson>> getImmichPeople({String? query}) async {
final uri = Uri.parse('$baseUrl/immich/people').replace(
queryParameters: query == null || query.trim().isEmpty ? null : {'q': query.trim()},
);
final response = await http.get(uri);
_check(response);
final data = jsonDecode(response.body) as List<dynamic>;
return data.map((e) => ImmichPerson.fromJson(e as Map<String, dynamic>, baseUrl)).toList();
}
String immichPersonThumbnailUrl(String personId) => '$baseUrl/immich/people/$personId/thumbnail';
Future<List<ImmichAsset>> getImmichPersonAssets(String personId) async {
final uri = Uri.parse('$baseUrl/immich/people/$personId/assets');
final response = await http.get(uri);
_check(response);
final data = jsonDecode(response.body) as List<dynamic>;
return data.map((e) => ImmichAsset.fromJson(e as Map<String, dynamic>, baseUrl)).toList();
}
Future<PatientPhoto> importImmichAsset({
required int patientId,
required String assetId,
}) async {
final uri = Uri.parse('$baseUrl/patients/$patientId/photos/from-immich-asset/$assetId');
final response = await http.post(uri);
_check(response);
return PatientPhoto.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}
Future<PatientPhoto> importImmichPersonFace({
required int patientId,
required String personId,
}) async {
final uri = Uri.parse('$baseUrl/patients/$patientId/photos/from-immich-person/$personId');
final response = await http.post(uri);
_check(response);
return PatientPhoto.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}
Future<List<ConsultationRecord>> getPatientConsultations(int patientId) async {
final uri = Uri.parse('$baseUrl/patients/$patientId/consultations');
final response = await http.get(uri);
_check(response);
final data = jsonDecode(response.body) as List<dynamic>;
return data.map((e) => ConsultationRecord.fromJson(e as Map<String, dynamic>)).toList();
}
Future<ConsultationRecord> createConsultation({
required int patientId,
String? consultationDate,
String? procedure,
String? reason,
String? plan,
String? followUp,
String? status,
String? notes,
}) async {
final uri = Uri.parse('$baseUrl/patients/$patientId/consultations');
final response = await http.post(
uri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'consultation_date': _nullIfEmpty(consultationDate),
'procedure': _nullIfEmpty(procedure),
'reason': _nullIfEmpty(reason),
'plan': _nullIfEmpty(plan),
'follow_up': _nullIfEmpty(followUp),
'status': _nullIfEmpty(status) ?? 'prévu',
'notes': _nullIfEmpty(notes),
}),
);
_check(response);
return ConsultationRecord.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}
Future<ConsultationRecord> updateConsultation({
required int consultationId,
String? consultationDate,
String? procedure,
String? reason,
String? plan,
String? followUp,
String? status,
String? notes,
}) async {
final uri = Uri.parse('$baseUrl/consultations/$consultationId');
final response = await http.patch(
uri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
if (consultationDate != null) 'consultation_date': _nullIfEmpty(consultationDate),
if (procedure != null) 'procedure': _nullIfEmpty(procedure),
if (reason != null) 'reason': _nullIfEmpty(reason),
if (plan != null) 'plan': _nullIfEmpty(plan),
if (followUp != null) 'follow_up': _nullIfEmpty(followUp),
if (status != null) 'status': _nullIfEmpty(status),
if (notes != null) 'notes': _nullIfEmpty(notes),
}),
);
_check(response);
return ConsultationRecord.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}
Future<void> deleteConsultation(int consultationId) async {
final uri = Uri.parse('$baseUrl/consultations/$consultationId');
final response = await http.delete(uri);
_check(response);
}
Future<List<DocumentRecord>> getPatientDocuments(int patientId) async {
final uri = Uri.parse('$baseUrl/patients/$patientId/documents');
final response = await http.get(uri);
_check(response);
final data = jsonDecode(response.body) as List<dynamic>;
return data.map((e) => DocumentRecord.fromJson(e as Map<String, dynamic>)).toList();
}
Future<DocumentRecord> createDocument({
required int patientId,
int? consultationId,
required String documentType,
required String title,
required String status,
String? content,
String? signedBy,
String? signedAt,
}) async {
final uri = Uri.parse('$baseUrl/patients/$patientId/documents');
final response = await http.post(
uri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'consultation_id': consultationId,
'document_type': documentType,
'title': title,
'status': status,
'content': _nullIfEmpty(content),
'signed_by': _nullIfEmpty(signedBy),
'signed_at': _nullIfEmpty(signedAt),
}),
);
_check(response);
return DocumentRecord.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}
Future<DocumentRecord> updateDocument({
required int documentId,
int? consultationId,
String? documentType,
String? title,
String? status,
String? content,
String? signedBy,
String? signedAt,
}) async {
final uri = Uri.parse('$baseUrl/documents/$documentId');
final response = await http.patch(
uri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
if (consultationId != null) 'consultation_id': consultationId,
if (documentType != null) 'document_type': documentType,
if (title != null) 'title': _nullIfEmpty(title),
if (status != null) 'status': status,
if (content != null) 'content': _nullIfEmpty(content),
if (signedBy != null) 'signed_by': _nullIfEmpty(signedBy),
if (signedAt != null) 'signed_at': _nullIfEmpty(signedAt),
}),
);
_check(response);
return DocumentRecord.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}
Future<void> deleteDocument(int documentId) async {
final uri = Uri.parse('$baseUrl/documents/$documentId');
final response = await http.delete(uri);
_check(response);
}
String documentPdfUrl(int documentId) => '$baseUrl/documents/$documentId/pdf';
void _check(http.Response response) {
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('HTTP ${response.statusCode}: ${response.body}');
}
}
String? _nullIfEmpty(String? value) {
if (value == null) return null;
final cleaned = value.trim();
return cleaned.isEmpty ? null : cleaned;
}
}