Files
EM2_ERP/em2rp/lib/views/equipment_form_page.dart
ElPoyo b30ae0f10a feat: Sécurisation Firestore, gestion des prix HT/TTC et refactorisation majeure
Cette mise à jour verrouille l'accès direct à Firestore depuis le client pour renforcer la sécurité et introduit une gestion complète des prix HT/TTC dans toute l'application. Elle apporte également des améliorations significatives des permissions, des optimisations de performance et de nouvelles fonctionnalités.

### Sécurité et Backend
- **Firestore Rules :** Ajout de `firestore.rules` qui bloque par défaut tous les accès en lecture/écriture depuis le client. Toutes les opérations de données doivent maintenant passer par les Cloud Functions, renforçant considérablement la sécurité.
- **Index Firestore :** Création d'un fichier `firestore.indexes.json` pour optimiser les requêtes sur la collection `events`.
- **Cloud Functions :** Les fonctions de création/mise à jour d'événements ont été adaptées pour accepter des ID de documents (utilisateurs, type d'événement) et les convertir en `DocumentReference` côté serveur, simplifiant les appels depuis le client.

### Gestion des Prix HT/TTC
- **Calcul Automatisé :** Introduction d'un helper `PriceHelpers` et d'un widget `PriceHtTtcFields` pour calculer et synchroniser automatiquement les prix HT et TTC dans le formulaire d'événement.
- **Affichage Détaillé :**
    - Les détails des événements et des options affichent désormais les prix HT, la TVA et le TTC séparément pour plus de clarté.
    - Le prix de base (`basePrice`) est maintenant traité comme un prix TTC dans toute l'application.

### Permissions et Rôles
- **Centralisation (`AppPermission`) :** Création d'une énumération `AppPermission` pour centraliser toutes les permissions de l'application, avec descriptions et catégories.
- **Rôles Prédéfinis :** Définition de rôles standards (Admin, Manager, Technicien, User) avec des jeux de permissions prédéfinis.
- **Filtre par Utilisateur :** Ajout d'un filtre par utilisateur sur la page Calendrier, visible uniquement pour les utilisateurs ayant la permission `view_all_user_events`.

### Améliorations et Optimisations (Frontend)
- **`DebugLog` :** Ajout d'un utilitaire `DebugLog` pour gérer les logs, qui sont automatiquement désactivés en mode production.
- **Optimisation du Sélecteur d'Équipement :**
    - La boîte de dialogue de sélection d'équipement a été lourdement optimisée pour éviter les reconstructions complètes de la liste lors de la sélection/désélection d'items.
    - Utilisation de `ValueNotifier` et de caches locaux (`_cachedContainers`, `_cachedEquipment`) pour des mises à jour d'UI plus ciblées et fluides.
    - La position du scroll est désormais préservée.
- **Catégorie d'Équipement :** Ajout de la catégorie `Vehicle` (Véhicule) pour les équipements.
- **Formulaires :** Les formulaires de création/modification d'événements et d'équipements ont été nettoyés de leurs logs de débogage excessifs.
2026-01-14 17:32:58 +01:00

717 lines
30 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/providers/equipment_provider.dart';
import 'package:em2rp/providers/container_provider.dart';
import 'package:em2rp/providers/local_user_provider.dart';
import 'package:em2rp/services/equipment_service.dart';
import 'package:em2rp/services/container_equipment_service.dart';
import 'package:em2rp/utils/colors.dart';
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
import 'package:intl/intl.dart';
import 'package:em2rp/views/equipment_form/brand_model_selector.dart';
import 'package:em2rp/utils/id_generator.dart';
import 'package:em2rp/utils/debug_log.dart';
import 'package:em2rp/views/widgets/equipment/parent_boxes_selector.dart';
class EquipmentFormPage extends StatefulWidget {
final EquipmentModel? equipment;
const EquipmentFormPage({super.key, this.equipment});
@override
State<EquipmentFormPage> createState() => _EquipmentFormPageState();
}
class _EquipmentFormPageState extends State<EquipmentFormPage> {
final _formKey = GlobalKey<FormState>();
final EquipmentService _equipmentService = EquipmentService();
// Controllers
final TextEditingController _identifierController = TextEditingController();
final TextEditingController _brandController = TextEditingController();
final TextEditingController _modelController = TextEditingController();
final TextEditingController _purchasePriceController = TextEditingController();
final TextEditingController _rentalPriceController = TextEditingController();
final TextEditingController _totalQuantityController = TextEditingController();
final TextEditingController _criticalThresholdController = TextEditingController();
final TextEditingController _notesController = TextEditingController();
final TextEditingController _quantityToAddController = TextEditingController(text: '1');
// State variables
EquipmentCategory _selectedCategory = EquipmentCategory.other;
EquipmentStatus _selectedStatus = EquipmentStatus.available;
DateTime? _purchaseDate;
DateTime? _lastMaintenanceDate;
DateTime? _nextMaintenanceDate;
List<String> _selectedParentBoxIds = [];
List<ContainerModel> _availableBoxes = [];
bool _isLoading = false;
bool _isLoadingBoxes = true;
bool _addMultiple = false;
String? _selectedBrand;
List<String> _filteredModels = [];
@override
void initState() {
super.initState();
_loadAvailableBoxes();
WidgetsBinding.instance.addPostFrameCallback((_) {
final provider = Provider.of<EquipmentProvider>(context, listen: false);
provider.loadBrands();
provider.loadModels();
});
if (widget.equipment != null) {
_populateFields();
}
}
void _populateFields() {
final equipment = widget.equipment!;
setState(() {
_identifierController.text = equipment.id;
_brandController.text = equipment.brand ?? '';
_selectedBrand = equipment.brand;
_modelController.text = equipment.model ?? '';
_selectedCategory = equipment.category;
_selectedStatus = equipment.status;
_purchasePriceController.text = equipment.purchasePrice?.toStringAsFixed(2) ?? '';
_rentalPriceController.text = equipment.rentalPrice?.toStringAsFixed(2) ?? '';
_totalQuantityController.text = equipment.totalQuantity?.toString() ?? '';
_criticalThresholdController.text = equipment.criticalThreshold?.toString() ?? '';
_purchaseDate = equipment.purchaseDate;
_lastMaintenanceDate = equipment.lastMaintenanceDate;
_nextMaintenanceDate = equipment.nextMaintenanceDate;
_notesController.text = equipment.notes ?? '';
});
DebugLog.info('[EquipmentForm] Populating fields for equipment: ${equipment.id}');
// Charger les containers contenant cet équipement depuis Firestore
_loadCurrentContainers(equipment.id);
if (_selectedBrand != null && _selectedBrand!.isNotEmpty) {
_loadFilteredModels(_selectedBrand!);
}
}
/// Charge les containers qui contiennent actuellement cet équipement
Future<void> _loadCurrentContainers(String equipmentId) async {
try {
final containers = await containerEquipmentService.getContainersByEquipment(equipmentId);
setState(() {
_selectedParentBoxIds = containers.map((c) => c.id).toList();
});
DebugLog.info('[EquipmentForm] Loaded ${containers.length} containers for equipment $equipmentId');
DebugLog.info('[EquipmentForm] Selected container IDs: $_selectedParentBoxIds');
} catch (e) {
DebugLog.error('[EquipmentForm] Error loading containers for equipment', e);
}
}
Future<void> _loadAvailableBoxes() async {
try {
final boxes = await _equipmentService.getBoxes();
DebugLog.info('[EquipmentForm] Loaded ${boxes.length} boxes from service');
for (var box in boxes) {
DebugLog.info('[EquipmentForm] Box loaded - ID: ${box.id}, Name: ${box.name}');
}
setState(() {
_availableBoxes = boxes;
_isLoadingBoxes = false;
});
} catch (e) {
DebugLog.error('[EquipmentForm] Error loading boxes', e);
setState(() {
_isLoadingBoxes = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur lors du chargement des boîtes : $e')),
);
}
}
}
Future<void> _loadFilteredModels(String brand) async {
try {
final equipmentProvider = Provider.of<EquipmentProvider>(context, listen: false);
final models = await equipmentProvider.loadModelsByBrand(brand);
setState(() {
_filteredModels = models;
});
} catch (e) {
setState(() {
_filteredModels = [];
});
}
}
@override
void dispose() {
_identifierController.dispose();
_brandController.dispose();
_modelController.dispose();
_purchasePriceController.dispose();
_rentalPriceController.dispose();
_totalQuantityController.dispose();
_criticalThresholdController.dispose();
_notesController.dispose();
_quantityToAddController.dispose();
super.dispose();
}
bool get _isConsumable => _selectedCategory == EquipmentCategory.consumable || _selectedCategory == EquipmentCategory.cable;
@override
Widget build(BuildContext context) {
final localUserProvider = Provider.of<LocalUserProvider>(context);
final hasManagePermission = localUserProvider.hasPermission('manage_equipment');
final isEditing = widget.equipment != null;
return Scaffold(
appBar: CustomAppBar(
title: isEditing ? 'Modifier l\'équipement' : 'Nouvel équipement',
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Identifiant (généré ou saisi)
TextFormField(
controller: _identifierController,
decoration: InputDecoration(
labelText: 'Identifiant *',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.tag),
hintText: isEditing ? null : 'Laissez vide pour générer automatiquement',
helperText: isEditing ? 'Non modifiable' : 'Format auto: {Marque4Chars}_{Modèle}',
),
enabled: !isEditing,
validator: (value) {
if (value != null && value.isNotEmpty) {
// Empêcher les ID commençant par BOX_ (réservé aux containers)
if (value.toUpperCase().startsWith('BOX_')) {
return 'Les ID commençant par BOX_ sont réservés aux boites';
}
}
return null;
},
),
const SizedBox(height: 16),
// Case à cocher "Ajouter plusieurs" (uniquement en mode création)
if (!isEditing) ...[
Row(
children: [
Expanded(
flex: 2,
child: CheckboxListTile(
title: const Text('Ajouter plusieurs équipements'),
subtitle: const Text('Créer plusieurs équipements numérotés'),
value: _addMultiple,
contentPadding: EdgeInsets.zero,
onChanged: (bool? value) {
setState(() {
_addMultiple = value ?? false;
});
},
),
),
if (_addMultiple) ...[
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _quantityToAddController,
decoration: const InputDecoration(
labelText: 'Quantité ou range',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.plus_one),
hintText: '5 ou 6-18',
helperText: 'Ex: 5 ou 6-18',
),
keyboardType: TextInputType.text,
validator: (value) {
if (_addMultiple) {
if (value == null || value.isEmpty) return 'Requis';
// Vérifier si c'est un nombre simple ou une range
if (value.contains('-')) {
final parts = value.split('-');
if (parts.length != 2) return 'Format invalide';
final start = int.tryParse(parts[0].trim());
final end = int.tryParse(parts[1].trim());
if (start == null || end == null) return 'Nombres invalides';
if (start >= end) return 'Le début doit être < fin';
if (end - start > 100) return 'Max 100 équipements';
} else {
final num = int.tryParse(value);
if (num == null || num < 1 || num > 100) return '1-100';
}
}
return null;
},
),
),
],
],
),
const SizedBox(height: 16),
],
// Sélecteur Marque/Modèle
BrandModelSelector(
brandController: _brandController,
modelController: _modelController,
selectedBrand: _selectedBrand,
filteredModels: _filteredModels,
onBrandChanged: (brand) {
setState(() {
_selectedBrand = brand;
});
if (brand != null && brand.isNotEmpty) {
_loadFilteredModels(brand);
} else {
setState(() {
_filteredModels = [];
});
}
},
onModelsChanged: (models) {
setState(() {
_filteredModels = models;
});
},
),
const SizedBox(height: 16),
// Catégorie et Statut
Row(
children: [
Expanded(
child: DropdownButtonFormField<EquipmentCategory>(
value: _selectedCategory,
decoration: const InputDecoration(
labelText: 'Catégorie *',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.category),
),
items: EquipmentCategory.values.map((category) {
return DropdownMenuItem(
value: category,
child: Text(category.label),
);
}).toList(),
onChanged: (value) {
if (value != null) {
setState(() {
_selectedCategory = value;
});
}
},
),
),
// Afficher le statut uniquement si ce n'est pas un consommable ou câble
if (!_isConsumable) ...[
const SizedBox(width: 16),
Expanded(
child: DropdownButtonFormField<EquipmentStatus>(
value: _selectedStatus,
decoration: const InputDecoration(
labelText: 'Statut *',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.info),
),
items: EquipmentStatus.values.map((status) {
return DropdownMenuItem(
value: status,
child: Text(status.label),
);
}).toList(),
onChanged: (value) {
if (value != null) {
setState(() {
_selectedStatus = value;
});
}
},
),
),
],
],
),
const SizedBox(height: 16),
// Prix
if (hasManagePermission) ...[
Row(
children: [
Expanded(
child: TextFormField(
controller: _purchasePriceController,
decoration: const InputDecoration(
labelText: 'Prix d\'achat (€)',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.euro),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))],
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _rentalPriceController,
decoration: const InputDecoration(
labelText: 'Prix de location (€)',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.attach_money),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))],
),
),
],
),
const SizedBox(height: 16),
],
// Quantités pour consommables
if (_isConsumable) ...[
const Divider(),
const Text('Gestion des quantités', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: _totalQuantityController,
decoration: const InputDecoration(
labelText: 'Quantité totale',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.inventory),
),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _criticalThresholdController,
decoration: const InputDecoration(
labelText: 'Seuil critique',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.warning),
),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
),
),
],
),
const SizedBox(height: 16),
],
// Boîtes parentes
const SizedBox(height: 8),
_isLoadingBoxes
? const Card(
child: Padding(
padding: EdgeInsets.all(32.0),
child: Center(child: CircularProgressIndicator()),
),
)
: _buildParentBoxesSelector(),
const SizedBox(height: 16),
// Dates
const Divider(),
const Text('Dates', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
_buildDateField(label: 'Date d\'achat', icon: Icons.shopping_cart, value: _purchaseDate, onTap: () => _selectDate(context, 'purchase')),
const SizedBox(height: 16),
_buildDateField(label: 'Dernière maintenance', icon: Icons.build, value: _lastMaintenanceDate, onTap: () => _selectDate(context, 'lastMaintenance')),
const SizedBox(height: 16),
_buildDateField(label: 'Prochaine maintenance', icon: Icons.event, value: _nextMaintenanceDate, onTap: () => _selectDate(context, 'nextMaintenance')),
const SizedBox(height: 16),
// Notes
const Divider(),
TextFormField(
controller: _notesController,
decoration: const InputDecoration(
labelText: 'Notes',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.notes),
),
maxLines: 3,
),
const SizedBox(height: 24),
// Boutons
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
const SizedBox(width: 16),
ElevatedButton(
onPressed: _saveEquipment,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.rouge,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
),
child: Text(isEditing ? 'Enregistrer' : 'Créer', style: const TextStyle(color: Colors.white)),
),
],
),
],
),
),
),
);
}
Widget _buildParentBoxesSelector() {
return ParentBoxesSelector(
availableBoxes: _availableBoxes,
selectedBoxIds: _selectedParentBoxIds,
onSelectionChanged: (newSelection) {
setState(() {
_selectedParentBoxIds = newSelection;
});
},
);
}
Widget _buildDateField({required String label, required IconData icon, required DateTime? value, required VoidCallback onTap}) {
return InkWell(
onTap: onTap,
child: InputDecorator(
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
prefixIcon: Icon(icon),
suffixIcon: value != null
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
setState(() {
if (label.contains('achat')) {
_purchaseDate = null;
} else if (label.contains('Dernière')) {
_lastMaintenanceDate = null;
} else if (label.contains('Prochaine')) {
_nextMaintenanceDate = null;
}
});
},
)
: null,
),
child: Text(
value != null ? DateFormat('dd/MM/yyyy').format(value) : 'Sélectionner une date',
style: TextStyle(color: value != null ? Colors.black : Colors.grey),
),
),
);
}
Future<void> _selectDate(BuildContext context, String field) async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (picked != null) {
setState(() {
switch (field) {
case 'purchase':
_purchaseDate = picked;
break;
case 'lastMaintenance':
_lastMaintenanceDate = picked;
break;
case 'nextMaintenance':
_nextMaintenanceDate = picked;
break;
}
});
}
}
Future<void> _saveEquipment() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
final equipmentProvider = Provider.of<EquipmentProvider>(context, listen: false);
final isEditing = widget.equipment != null;
int? availableQuantity;
if (_isConsumable && _totalQuantityController.text.isNotEmpty) {
final totalQuantity = int.parse(_totalQuantityController.text);
if (isEditing && widget.equipment!.availableQuantity != null) {
availableQuantity = widget.equipment!.availableQuantity;
} else {
availableQuantity = totalQuantity;
}
}
// Validation marque/modèle obligatoires
String brand = _brandController.text.trim();
String model = _modelController.text.trim();
if (brand.isEmpty || model.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('La marque et le modèle sont obligatoires')),
);
return;
}
// Génération d'identifiant si vide
List<String> ids = [];
List<int> numbers = [];
if (!isEditing && _identifierController.text.isEmpty) {
// Gérer la range ou nombre simple
final quantityText = _quantityToAddController.text.trim();
if (_addMultiple && quantityText.contains('-')) {
// Range: ex "6-18"
final parts = quantityText.split('-');
final start = int.parse(parts[0].trim());
final end = int.parse(parts[1].trim());
for (int i = start; i <= end; i++) {
numbers.add(i);
}
} else if (_addMultiple) {
// Nombre simple
final nbToAdd = int.tryParse(quantityText) ?? 1;
for (int i = 1; i <= nbToAdd; i++) {
numbers.add(i);
}
}
// Générer les IDs
if (numbers.isEmpty) {
String baseId = IdGenerator.generateEquipmentId(brand: brand, model: model, number: null);
String uniqueId = await IdGenerator.ensureUniqueEquipmentId(baseId, _equipmentService);
ids.add(uniqueId);
} else {
for (final num in numbers) {
String baseId = IdGenerator.generateEquipmentId(brand: brand, model: model, number: num);
String uniqueId = await IdGenerator.ensureUniqueEquipmentId(baseId, _equipmentService);
ids.add(uniqueId);
}
}
} else {
ids.add(_identifierController.text.trim());
}
// Création des équipements
for (final id in ids) {
final now = DateTime.now();
final equipment = EquipmentModel(
id: id,
name: id, // Utilisation de l'identifiant comme nom
brand: brand,
model: model,
category: _selectedCategory,
status: _selectedStatus,
purchasePrice: _purchasePriceController.text.isNotEmpty ? double.tryParse(_purchasePriceController.text) : null,
rentalPrice: _rentalPriceController.text.isNotEmpty ? double.tryParse(_rentalPriceController.text) : null,
totalQuantity: _isConsumable ? int.tryParse(_totalQuantityController.text) : null,
criticalThreshold: _isConsumable ? int.tryParse(_criticalThresholdController.text) : null,
purchaseDate: _purchaseDate,
lastMaintenanceDate: _lastMaintenanceDate,
nextMaintenanceDate: _nextMaintenanceDate,
parentBoxIds: [], // On ne stocke plus les parentBoxIds dans l'équipement
notes: _notesController.text,
createdAt: isEditing ? (widget.equipment?.createdAt ?? now) : now,
updatedAt: now,
availableQuantity: availableQuantity,
);
if (isEditing) {
await equipmentProvider.updateEquipment(equipment);
// Synchroniser les containers : mettre à jour equipmentIds des containers
// Charger les anciens containers depuis Firestore
final oldContainers = await containerEquipmentService.getContainersByEquipment(equipment.id);
final oldParentBoxIds = oldContainers.map((c) => c.id).toList();
final newParentBoxIds = _selectedParentBoxIds;
// Boîtes ajoutées : ajouter cet équipement à leur equipmentIds
final addedBoxes = newParentBoxIds.where((id) => !oldParentBoxIds.contains(id));
for (final boxId in addedBoxes) {
try {
final containerProvider = Provider.of<ContainerProvider>(context, listen: false);
await containerProvider.addEquipmentToContainer(
containerId: boxId,
equipmentId: equipment.id,
);
DebugLog.info('[EquipmentForm] Added equipment ${equipment.id} to container $boxId');
} catch (e) {
DebugLog.error('[EquipmentForm] Error adding equipment to container $boxId', e);
}
}
// Boîtes retirées : retirer cet équipement de leur equipmentIds
final removedBoxes = oldParentBoxIds.where((id) => !newParentBoxIds.contains(id));
for (final boxId in removedBoxes) {
try {
final containerProvider = Provider.of<ContainerProvider>(context, listen: false);
await containerProvider.removeEquipmentFromContainer(
containerId: boxId,
equipmentId: equipment.id,
);
DebugLog.info('[EquipmentForm] Removed equipment ${equipment.id} from container $boxId');
} catch (e) {
DebugLog.error('[EquipmentForm] Error removing equipment from container $boxId', e);
}
}
} else {
await equipmentProvider.addEquipment(equipment);
// Pour un nouvel équipement, ajouter à tous les containers sélectionnés
for (final boxId in _selectedParentBoxIds) {
try {
final containerProvider = Provider.of<ContainerProvider>(context, listen: false);
await containerProvider.addEquipmentToContainer(
containerId: boxId,
equipmentId: equipment.id,
);
DebugLog.info('[EquipmentForm] Added new equipment ${equipment.id} to container $boxId');
} catch (e) {
DebugLog.error('[EquipmentForm] Error adding new equipment to container $boxId', e);
}
}
}
}
if (mounted) {
Navigator.pop(context, true);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur lors de l\'enregistrement : $e')),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
}