feat: export ICS
This commit is contained in:
70
em2rp/docs/EXPORT_CALENDAR.md
Normal file
70
em2rp/docs/EXPORT_CALENDAR.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Export vers Google Calendar
|
||||
|
||||
## Fonctionnalité
|
||||
|
||||
L'application permet d'exporter un événement au format ICS (iCalendar), compatible avec Google Calendar, Apple Calendar, Outlook et la plupart des applications de calendrier.
|
||||
|
||||
## Utilisation
|
||||
|
||||
1. Ouvrir les détails d'un événement
|
||||
2. Cliquer sur l'icône de calendrier 📅 dans l'en-tête
|
||||
3. Le fichier `.ics` sera automatiquement téléchargé
|
||||
4. Ouvrir le fichier pour l'importer dans votre application de calendrier
|
||||
|
||||
## Informations exportées
|
||||
|
||||
Le fichier ICS contient :
|
||||
|
||||
### Informations principales
|
||||
- **Titre** : Nom de l'événement
|
||||
- **Date de début** : Date et heure de début
|
||||
- **Date de fin** : Date et heure de fin
|
||||
- **Lieu** : Adresse de l'événement
|
||||
- **Statut** : Confirmé / Annulé / En attente
|
||||
|
||||
### Description détaillée
|
||||
- Type d'événement
|
||||
- Description complète
|
||||
- Jauge (nombre de personnes)
|
||||
- Email de contact
|
||||
- Téléphone de contact
|
||||
- Temps d'installation et démontage
|
||||
- Liste de la main d'œuvre
|
||||
- Options sélectionnées (avec quantités)
|
||||
- Prix de base
|
||||
|
||||
## Format du fichier
|
||||
|
||||
Le fichier généré suit le standard **RFC 5545** (iCalendar) et est nommé selon le format :
|
||||
```
|
||||
event_[nom_evenement]_[date].ics
|
||||
```
|
||||
|
||||
Exemple : `event_Concert_Mairie_20251225.ics`
|
||||
|
||||
## Compatibilité
|
||||
|
||||
✅ Google Calendar
|
||||
✅ Apple Calendar (macOS, iOS)
|
||||
✅ Microsoft Outlook
|
||||
✅ Thunderbird
|
||||
✅ Autres applications supportant le format ICS
|
||||
|
||||
## Import dans Google Calendar
|
||||
|
||||
1. Télécharger le fichier `.ics`
|
||||
2. Ouvrir Google Calendar
|
||||
3. Cliquer sur l'icône ⚙️ (Paramètres)
|
||||
4. Sélectionner "Importation et exportation"
|
||||
5. Cliquer sur "Sélectionner un fichier sur votre ordinateur"
|
||||
6. Choisir le fichier `.ics` téléchargé
|
||||
7. Sélectionner le calendrier de destination
|
||||
8. Cliquer sur "Importer"
|
||||
|
||||
## Notes techniques
|
||||
|
||||
- Les dates sont converties en UTC pour assurer la compatibilité internationale
|
||||
- Les caractères spéciaux sont correctement échappés selon le standard ICS
|
||||
- Un UID unique est généré pour chaque événement (`em2rp-[eventId]@em2rp.app`)
|
||||
- Le fichier est encodé en UTF-8
|
||||
|
||||
18
em2rp/docs/example_event.ics
Normal file
18
em2rp/docs/example_event.ics
Normal file
@@ -0,0 +1,18 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//EM2RP//Event Manager//FR
|
||||
CALSCALE:GREGORIAN
|
||||
METHOD:PUBLISH
|
||||
BEGIN:VEVENT
|
||||
UID:em2rp-example123@em2rp.app
|
||||
DTSTAMP:20251220T120000Z
|
||||
DTSTART:20251225T190000Z
|
||||
DTEND:20251225T230000Z
|
||||
SUMMARY:Concert de Noël
|
||||
DESCRIPTION:TYPE: Concert\n\nDESCRIPTION:\nConcert de Noël avec orchestre symphonique et chorale.\n\nJAUGE: 500 personnes\nEMAIL DE CONTACT: contact@example.com\nTÉLÉPHONE DE CONTACT: 06 12 34 56 78\n\nADRESSE: Salle des fêtes\, Place de la Mairie\, 75001 Paris\n\nINSTALLATION: 4h\nDÉMONTAGE: 2h\n\nMAIN D'ŒUVRE:\n - Jean Dupont\n - Marie Martin\n - Pierre Durand\n\nOPTIONS:\n - Système son professionnel\n - Éclairage scénique (x2)\n\nPRIX DE BASE: 2500.00€\n\n---\nGéré par EM2RP Event Manager
|
||||
LOCATION:Salle des fêtes\, Place de la Mairie\, 75001 Paris
|
||||
STATUS:CONFIRMED
|
||||
CATEGORIES:Concert
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
|
||||
@@ -82,7 +82,7 @@ class EventFormController extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> initialize([EventModel? existingEvent]) async {
|
||||
Future<void> initialize({EventModel? existingEvent, DateTime? selectedDate}) async {
|
||||
await Future.wait([
|
||||
_fetchUsers(),
|
||||
_fetchEventTypes(),
|
||||
@@ -92,6 +92,20 @@ class EventFormController extends ChangeNotifier {
|
||||
_populateFromEvent(existingEvent);
|
||||
} else {
|
||||
_selectedStatus = EventStatus.waitingForApproval;
|
||||
|
||||
// Préremplir les dates si une date est sélectionnée dans le calendrier
|
||||
if (selectedDate != null) {
|
||||
// Date de début : selectedDate à 20h00
|
||||
_startDateTime = DateTime(
|
||||
selectedDate.year,
|
||||
selectedDate.month,
|
||||
selectedDate.day,
|
||||
20,
|
||||
0,
|
||||
);
|
||||
// Date de fin : selectedDate + 4 heures
|
||||
_endDateTime = _startDateTime!.add(const Duration(hours: 4));
|
||||
}
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
267
em2rp/lib/services/ics_export_service.dart
Normal file
267
em2rp/lib/services/ics_export_service.dart
Normal file
@@ -0,0 +1,267 @@
|
||||
import 'package:em2rp/models/event_model.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
|
||||
class IcsExportService {
|
||||
/// Génère un fichier ICS à partir d'un événement
|
||||
static Future<String> generateIcsContent(EventModel event) async {
|
||||
final now = DateTime.now().toUtc();
|
||||
final timestamp = DateFormat('yyyyMMddTHHmmss').format(now) + 'Z';
|
||||
|
||||
// Récupérer les informations supplémentaires
|
||||
final eventTypeName = await _getEventTypeName(event.eventTypeId);
|
||||
final workforce = await _getWorkforceDetails(event.workforce);
|
||||
final optionsWithNames = await _getOptionsDetails(event.options);
|
||||
|
||||
// Formater les dates au format ICS (UTC)
|
||||
final startDate = _formatDateForIcs(event.startDateTime);
|
||||
final endDate = _formatDateForIcs(event.endDateTime);
|
||||
|
||||
// Construire la description détaillée
|
||||
final description = _buildDescription(event, eventTypeName, workforce, optionsWithNames);
|
||||
|
||||
// Générer un UID unique basé sur l'ID de l'événement
|
||||
final uid = 'em2rp-${event.id}@em2rp.app';
|
||||
|
||||
// Construire le contenu ICS
|
||||
final icsContent = '''BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//EM2RP//Event Manager//FR
|
||||
CALSCALE:GREGORIAN
|
||||
METHOD:PUBLISH
|
||||
BEGIN:VEVENT
|
||||
UID:$uid
|
||||
DTSTAMP:$timestamp
|
||||
DTSTART:$startDate
|
||||
DTEND:$endDate
|
||||
SUMMARY:${_escapeIcsText(event.name)}
|
||||
DESCRIPTION:${_escapeIcsText(description)}
|
||||
LOCATION:${_escapeIcsText(event.address)}
|
||||
STATUS:${_getEventStatus(event.status)}
|
||||
CATEGORIES:${_escapeIcsText(eventTypeName)}
|
||||
END:VEVENT
|
||||
END:VCALENDAR''';
|
||||
|
||||
return icsContent;
|
||||
}
|
||||
|
||||
/// Récupère le nom du type d'événement
|
||||
static Future<String> _getEventTypeName(String eventTypeId) async {
|
||||
if (eventTypeId.isEmpty) return 'Non spécifié';
|
||||
|
||||
try {
|
||||
final doc = await FirebaseFirestore.instance
|
||||
.collection('eventTypes')
|
||||
.doc(eventTypeId)
|
||||
.get();
|
||||
|
||||
if (doc.exists) {
|
||||
return doc.data()?['name'] as String? ?? eventTypeId;
|
||||
}
|
||||
} catch (e) {
|
||||
print('Erreur lors de la récupération du type d\'événement: $e');
|
||||
}
|
||||
|
||||
return eventTypeId;
|
||||
}
|
||||
|
||||
/// Récupère les détails de la main d'œuvre
|
||||
static Future<List<String>> _getWorkforceDetails(List<DocumentReference> workforce) async {
|
||||
final List<String> workforceNames = [];
|
||||
|
||||
for (final ref in workforce) {
|
||||
try {
|
||||
final doc = await ref.get();
|
||||
if (doc.exists) {
|
||||
final data = doc.data() as Map<String, dynamic>?;
|
||||
if (data != null) {
|
||||
final firstName = data['firstName'] ?? '';
|
||||
final lastName = data['lastName'] ?? '';
|
||||
if (firstName.isNotEmpty || lastName.isNotEmpty) {
|
||||
workforceNames.add('$firstName $lastName'.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('Erreur lors de la récupération des détails utilisateur: $e');
|
||||
}
|
||||
}
|
||||
|
||||
return workforceNames;
|
||||
}
|
||||
|
||||
/// Récupère les détails des options
|
||||
static Future<List<Map<String, dynamic>>> _getOptionsDetails(List<Map<String, dynamic>> options) async {
|
||||
final List<Map<String, dynamic>> optionsWithNames = [];
|
||||
|
||||
for (final option in options) {
|
||||
try {
|
||||
final optionId = option['id'] ?? option['optionId'];
|
||||
if (optionId == null || optionId.toString().isEmpty) {
|
||||
// Si pas d'ID, garder le nom tel quel
|
||||
optionsWithNames.add({
|
||||
'name': option['name'] ?? 'Option inconnue',
|
||||
'quantity': option['quantity'],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Récupérer le nom depuis Firestore
|
||||
final doc = await FirebaseFirestore.instance
|
||||
.collection('options')
|
||||
.doc(optionId.toString())
|
||||
.get();
|
||||
|
||||
if (doc.exists) {
|
||||
final data = doc.data();
|
||||
optionsWithNames.add({
|
||||
'name': data?['name'] ?? option['name'] ?? 'Option inconnue',
|
||||
'quantity': option['quantity'],
|
||||
});
|
||||
} else {
|
||||
// Document n'existe pas, garder le nom de l'option
|
||||
optionsWithNames.add({
|
||||
'name': option['name'] ?? 'Option inconnue',
|
||||
'quantity': option['quantity'],
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
print('Erreur lors de la récupération des détails option: $e');
|
||||
optionsWithNames.add({
|
||||
'name': option['name'] ?? 'Option inconnue',
|
||||
'quantity': option['quantity'],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return optionsWithNames;
|
||||
}
|
||||
|
||||
/// Construit la description détaillée de l'événement
|
||||
static String _buildDescription(
|
||||
EventModel event,
|
||||
String eventTypeName,
|
||||
List<String> workforce,
|
||||
List<Map<String, dynamic>> optionsWithNames,
|
||||
) {
|
||||
final buffer = StringBuffer();
|
||||
|
||||
// Type d'événement
|
||||
buffer.writeln('TYPE: $eventTypeName');
|
||||
buffer.writeln('');
|
||||
|
||||
// Description
|
||||
if (event.description.isNotEmpty) {
|
||||
buffer.writeln('DESCRIPTION:');
|
||||
buffer.writeln(event.description);
|
||||
buffer.writeln('');
|
||||
}
|
||||
|
||||
// Jauge
|
||||
if (event.jauge != null) {
|
||||
buffer.writeln('JAUGE: ${event.jauge} personnes');
|
||||
}
|
||||
|
||||
// Contact email
|
||||
if (event.contactEmail != null && event.contactEmail!.isNotEmpty) {
|
||||
buffer.writeln('EMAIL DE CONTACT: ${event.contactEmail}');
|
||||
}
|
||||
|
||||
// Contact téléphone
|
||||
if (event.contactPhone != null && event.contactPhone!.isNotEmpty) {
|
||||
buffer.writeln('TÉLÉPHONE DE CONTACT: ${event.contactPhone}');
|
||||
}
|
||||
|
||||
// Adresse
|
||||
if (event.address.isNotEmpty) {
|
||||
buffer.writeln('');
|
||||
buffer.writeln('ADRESSE: ${event.address}');
|
||||
}
|
||||
|
||||
// Temps d'installation et démontage
|
||||
if (event.installationTime > 0 || event.disassemblyTime > 0) {
|
||||
buffer.writeln('');
|
||||
if (event.installationTime > 0) {
|
||||
buffer.writeln('INSTALLATION: ${event.installationTime}h');
|
||||
}
|
||||
if (event.disassemblyTime > 0) {
|
||||
buffer.writeln('DÉMONTAGE: ${event.disassemblyTime}h');
|
||||
}
|
||||
}
|
||||
|
||||
// Main d'œuvre
|
||||
if (workforce.isNotEmpty) {
|
||||
buffer.writeln('');
|
||||
buffer.writeln('MAIN D\'ŒUVRE:');
|
||||
for (final name in workforce) {
|
||||
buffer.writeln(' - $name');
|
||||
}
|
||||
}
|
||||
|
||||
// Options
|
||||
if (optionsWithNames.isNotEmpty) {
|
||||
buffer.writeln('');
|
||||
buffer.writeln('OPTIONS:');
|
||||
for (final option in optionsWithNames) {
|
||||
final optionName = option['name'] ?? 'Option inconnue';
|
||||
final quantity = option['quantity'];
|
||||
if (quantity != null && quantity > 1) {
|
||||
buffer.writeln(' - $optionName (x$quantity)');
|
||||
} else {
|
||||
buffer.writeln(' - $optionName');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prix
|
||||
if (event.basePrice > 0) {
|
||||
buffer.writeln('');
|
||||
buffer.writeln('PRIX DE BASE: ${event.basePrice.toStringAsFixed(2)}€');
|
||||
}
|
||||
|
||||
// Lien vers l'application
|
||||
buffer.writeln('');
|
||||
buffer.writeln('---');
|
||||
buffer.writeln('Géré par EM2RP Event Manager');
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
/// Formate une date au format ICS (yyyyMMddTHHmmssZ)
|
||||
static String _formatDateForIcs(DateTime dateTime) {
|
||||
final utcDate = dateTime.toUtc();
|
||||
return DateFormat('yyyyMMddTHHmmss').format(utcDate) + 'Z';
|
||||
}
|
||||
|
||||
/// Échappe les caractères spéciaux pour le format ICS
|
||||
static String _escapeIcsText(String text) {
|
||||
return text
|
||||
.replaceAll('\\', '\\\\')
|
||||
.replaceAll(',', '\\,')
|
||||
.replaceAll(';', '\\;')
|
||||
.replaceAll('\n', '\\n')
|
||||
.replaceAll('\r', '');
|
||||
}
|
||||
|
||||
/// Convertit le statut de l'événement en statut ICS
|
||||
static String _getEventStatus(EventStatus status) {
|
||||
switch (status) {
|
||||
case EventStatus.confirmed:
|
||||
return 'CONFIRMED';
|
||||
case EventStatus.canceled:
|
||||
return 'CANCELLED';
|
||||
case EventStatus.waitingForApproval:
|
||||
return 'TENTATIVE';
|
||||
}
|
||||
}
|
||||
|
||||
/// Génère le nom du fichier ICS
|
||||
static String generateFileName(EventModel event) {
|
||||
final safeName = event.name
|
||||
.replaceAll(RegExp(r'[^\w\s-]'), '')
|
||||
.replaceAll(RegExp(r'\s+'), '_');
|
||||
final dateStr = DateFormat('yyyyMMdd').format(event.startDateTime);
|
||||
return 'event_${safeName}_$dateStr.ics';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +130,9 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const EventAddEditPage(),
|
||||
builder: (context) => EventAddEditPage(
|
||||
selectedDate: _selectedDay ?? DateTime.now(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -11,7 +11,9 @@ import 'package:em2rp/views/widgets/inputs/option_selector_widget.dart';
|
||||
|
||||
class EventAddEditPage extends StatefulWidget {
|
||||
final EventModel? event;
|
||||
const EventAddEditPage({super.key, this.event});
|
||||
final DateTime? selectedDate;
|
||||
|
||||
const EventAddEditPage({super.key, this.event, this.selectedDate});
|
||||
|
||||
@override
|
||||
State<EventAddEditPage> createState() => _EventAddEditPageState();
|
||||
@@ -27,7 +29,10 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = EventFormController();
|
||||
_controller.initialize(widget.event);
|
||||
_controller.initialize(
|
||||
existingEvent: widget.event,
|
||||
selectedDate: widget.selectedDate,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -5,6 +5,9 @@ import 'package:em2rp/utils/colors.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:em2rp/providers/local_user_provider.dart';
|
||||
import 'package:em2rp/views/event_add_page.dart';
|
||||
import 'package:em2rp/services/ics_export_service.dart';
|
||||
import 'dart:html' as html;
|
||||
import 'dart:convert';
|
||||
|
||||
class EventDetailsHeader extends StatefulWidget {
|
||||
final EventModel event;
|
||||
@@ -93,6 +96,12 @@ class _EventDetailsHeaderState extends State<EventDetailsHeader> {
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_buildStatusIcon(widget.event.status),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.calendar_today, color: AppColors.rouge),
|
||||
tooltip: 'Exporter vers Google Calendar',
|
||||
onPressed: _exportToCalendar,
|
||||
),
|
||||
if (Provider.of<LocalUserProvider>(context, listen: false)
|
||||
.hasPermission('edit_event')) ...[
|
||||
const SizedBox(width: 8),
|
||||
@@ -112,6 +121,63 @@ class _EventDetailsHeaderState extends State<EventDetailsHeader> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _exportToCalendar() async {
|
||||
try {
|
||||
// Afficher un indicateur de chargement
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||
),
|
||||
SizedBox(width: 16),
|
||||
Text('Génération du fichier ICS...'),
|
||||
],
|
||||
),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
|
||||
// Générer le contenu ICS
|
||||
final icsContent = await IcsExportService.generateIcsContent(widget.event);
|
||||
final fileName = IcsExportService.generateFileName(widget.event);
|
||||
|
||||
// Créer un blob et télécharger le fichier
|
||||
final bytes = utf8.encode(icsContent);
|
||||
final blob = html.Blob([bytes], 'text/calendar');
|
||||
final url = html.Url.createObjectUrlFromBlob(blob);
|
||||
html.AnchorElement(href: url)
|
||||
..setAttribute('download', fileName)
|
||||
..click();
|
||||
html.Url.revokeObjectUrl(url);
|
||||
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Événement exporté : $fileName'),
|
||||
backgroundColor: Colors.green,
|
||||
action: SnackBarAction(
|
||||
label: 'OK',
|
||||
textColor: Colors.white,
|
||||
onPressed: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur lors de l\'export : $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildStatusIcon(EventStatus status) {
|
||||
Color color;
|
||||
IconData icon;
|
||||
|
||||
@@ -181,16 +181,24 @@ class EventBasicInfoSection extends StatelessWidget {
|
||||
}
|
||||
|
||||
Future<void> _selectStartDateTime(BuildContext context) async {
|
||||
// Utiliser la date actuelle de l'événement ou aujourd'hui
|
||||
final initialDate = startDateTime ?? DateTime.now();
|
||||
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: DateTime.now(),
|
||||
initialDate: initialDate,
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2099),
|
||||
);
|
||||
if (picked != null) {
|
||||
// Utiliser l'heure actuelle de l'événement ou l'heure actuelle
|
||||
final initialTime = startDateTime != null
|
||||
? TimeOfDay(hour: startDateTime!.hour, minute: startDateTime!.minute)
|
||||
: TimeOfDay.now();
|
||||
|
||||
final time = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay.now(),
|
||||
initialTime: initialTime,
|
||||
);
|
||||
if (time != null) {
|
||||
final newDateTime = DateTime(
|
||||
@@ -206,16 +214,24 @@ class EventBasicInfoSection extends StatelessWidget {
|
||||
}
|
||||
|
||||
Future<void> _selectEndDateTime(BuildContext context) async {
|
||||
// Utiliser la date actuelle de fin ou date de début + 1h
|
||||
final initialDate = endDateTime ?? startDateTime!.add(const Duration(hours: 1));
|
||||
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: startDateTime!.add(const Duration(hours: 1)),
|
||||
initialDate: initialDate,
|
||||
firstDate: startDateTime!,
|
||||
lastDate: DateTime(2099),
|
||||
);
|
||||
if (picked != null) {
|
||||
// Utiliser l'heure actuelle de l'événement ou l'heure actuelle
|
||||
final initialTime = endDateTime != null
|
||||
? TimeOfDay(hour: endDateTime!.hour, minute: endDateTime!.minute)
|
||||
: TimeOfDay.now();
|
||||
|
||||
final time = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay.now(),
|
||||
initialTime: initialTime,
|
||||
);
|
||||
if (time != null) {
|
||||
final newDateTime = DateTime(
|
||||
|
||||
Reference in New Issue
Block a user