import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_datetime_picker/flutter_datetime_picker.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'datamodel.dart'; import 'databaseconnector.dart'; import 'dart:async'; import 'package:geolocator/geolocator.dart'; class AddEvent extends StatefulWidget { const AddEvent({Key? key}) : super(key: key); @override State<AddEvent> createState() => _AddEventPageState(); } class _AddEventPageState extends State<AddEvent> { bool syncGNSSData = true; bool _addButtonEnabled = true; late String long = ""; late String lat = ""; late String alt = ""; late double accuracy = 0.0; late StreamSubscription<Position> streamHandler; //For canceling GNSS stream on dispose final prefs = SharedPreferences.getInstance(); // Is async var database = DatabaseInstance(); late OverlayEntry _overlayEntry; //For event creation success notifications late Timer _overlayCloseTimer; Future startGNSS() async { final EventStoreInstance eventStore = EventStoreInstance(); debugPrint("Check Location Permission"); bool serviceStatus = false; bool hasPermission = false; serviceStatus = await Geolocator.isLocationServiceEnabled(); if(serviceStatus){ LocationPermission permission = await Geolocator.checkPermission(); if (permission == LocationPermission.denied) { permission = await Geolocator.requestPermission(); if (permission == LocationPermission.denied) { debugPrint('Location permissions are denied'); }else if(permission == LocationPermission.deniedForever){ debugPrint('Location permissions are permanently denied'); }else{ hasPermission = true; } }else{ hasPermission = true; } if(hasPermission){ debugPrint('Location permissions granted'); if(mounted){ setState(() { //refresh the UI });} debugPrint('Starting location stream'); streamHandler = Geolocator.getPositionStream( locationSettings: const LocationSettings( accuracy: LocationAccuracy.high, distanceFilter: 0, )).listen((Position position) { debugPrint('Get Location: Lat:' + position.latitude.toString() + ' Long:' + position.longitude.toString() + ' Alt:' + position.altitude.toString()); long = position.longitude.toString(); lat = position.latitude.toString(); alt = position.altitude.toString(); accuracy = position.accuracy; if (eventStore.gnssSync == true) { if(mounted){ setState(() { //refresh UI on update });} } }); } }else{ debugPrint("GPS Service is not enabled, turn on GPS location"); } if(mounted){ setState(() { //refresh the UI });} } @override void initState() { startGNSS(); super.initState(); } @override void dispose() async { try { streamHandler.cancel(); debugPrint('Cancel location stream'); }catch(e){ debugPrint('Canceling location stream failed'); } try { _overlayEntry.remove(); }catch(e){ debugPrint('Dispose error on overlay remove: ' + e.toString()); } /*Async update current event configuration to shared preferences*/ final EventStoreInstance event = EventStoreInstance(); event.storeToSharedPrefs(); super.dispose(); } bool _validateLatitude(value){ if (value == ""){ return true; //Empty string is valid } var number = num.tryParse(value); if(number != null){ if (number >= -90.0 && number <= 90.0){ return true; // Latitude valid } } return false; } bool _validateLongitude(value){ if (value == ""){ return true; //Empty string is valid } var number = num.tryParse(value); if(number != null){ if (number >= -180.0 && number <= 180.0){ return true; // Longitude valid } } return false; } bool _validateElevation(value){ if (value == ""){ return true; //Empty string is valid } var number = num.tryParse(value); if(number != null){ return true; // Any numerical value is valid for elevation } return false; } bool _validateInput(){ final EventStoreInstance event = EventStoreInstance(); if (RegExp(r'^[a-z A-Z . \- 0-9 , ( ) + - _ :]+$').hasMatch( event.currentEvent.label)) { if (RegExp(r'^[a-z A-Z . \- 0-9 , ( ) + - _ :]+$').hasMatch( event.currentEvent.description) || event.currentEvent.description == '') { if(_validateLatitude(event.currentEvent.latitude)){ if(_validateLongitude(event.currentEvent.longitude)){ if(_validateElevation(event.currentEvent.elevation)){ return true; } } } } } return false; } bool _addButtonStatus(){ if((_validateInput() == true) && (_addButtonEnabled == true)){ return true; } return false; } Future<void> _showResultPopup(BuildContext context, String text, bool error) async { OverlayState? overlayState = Overlay.of(context); try { _overlayEntry.remove(); // Allow only one Overlay Popup at a time }catch(e){ debugPrint('Overlay already removed, during dispose: ' + e.toString()); } _overlayEntry = OverlayEntry(builder: (context) { Color backGroundColor; Color textColor; if (error == true){ backGroundColor = Colors.redAccent; //Style for error message textColor = Colors.black; } else { backGroundColor = Colors.greenAccent; //Style for notification textColor = Colors.black; } return Stack( alignment: Alignment.center, children: [ Positioned( // Position at 10% of height from bottom bottom: MediaQuery.of(context).size.height * 0.1, child: Material( borderRadius: BorderRadius.circular(8.0), color: backGroundColor, //Some transparency remains child: Container( padding: const EdgeInsets.all(5.0), // Space between Text and Bubble width: MediaQuery.of(context).size.width * 0.95, child: TextFormField( minLines: 1, maxLines: 5, readOnly: true, autofocus: false, enabled: false, style: TextStyle(color: textColor), controller: TextEditingController( text: text, ), ), ), ), ), ], ); }); overlayState?.insert(_overlayEntry); try { _overlayCloseTimer.cancel(); // Kill old timers }catch(e){ debugPrint('Timer cancel error: ' + e.toString()); } _overlayCloseTimer = Timer( const Duration(seconds: 3), () { try { _overlayEntry.remove(); // Allow only one Overlay Popup. NOTE: Is this a quick an dirty or a proper solution? }catch(e){ debugPrint('Overlay already removed, during dispose: ' + e.toString()); } }, ); } Future<void> _storeCurrentEvent(BuildContext context) async { final EventStoreInstance event = EventStoreInstance(); final ConfigurationStoreInstance configuration = ConfigurationStoreInstance(); event.currentEvent.typeId = configuration.getEventIdFromName(event.currentEvent.type); event.currentEvent.status = "PENDING"; await database.addEvent(event.currentEvent); HapticFeedback.vibrate(); //Feedback that adding event succeeded _addButtonEnabled = true; //Activate button for add more events setState(() {}); //_showAddSuccessOverlay(context); //Show pop up to indicated adding event succeeded. _showResultPopup(context, "Successfully created Event !", false ); //Update timestamp in UI var date = DateTime.now().toUtc(); var isoDate = date.toIso8601String(); event.currentEvent.startDate = isoDate; event.currentEvent.endDate = isoDate; } @override Widget build(BuildContext context) { /* Get singletons to access relevant data here.*/ final EventStoreInstance eventStore = EventStoreInstance(); final ConfigurationStoreInstance configuration = ConfigurationStoreInstance(); String gnssStatusText = ""; String gnssStatusTextLine2 = ""; if (true == eventStore.gnssSync){ // Update current event coordinates from GNSS stream eventStore.currentEvent.latitude = lat; eventStore.currentEvent.longitude = long; eventStore.currentEvent.elevation = alt; var date = DateTime.now().toUtc(); var isoDate = date.toIso8601String(); eventStore.currentEvent.startDate = isoDate; eventStore.currentEvent.endDate = isoDate; if(accuracy == 0.0){ gnssStatusText = "GNSS On"; gnssStatusTextLine2 = "No-Fix"; } else{ gnssStatusText = "GNSS On"; gnssStatusTextLine2 = "Precision "+ accuracy.toStringAsFixed(1) +"m"; } } else{ gnssStatusText = "GNSS Off"; // Just display existing event coordinates gnssStatusTextLine2 = ""; } if (configuration.initialized == true) { return Scaffold( appBar: AppBar( title: const Text("Add Event"), actions: <Widget>[ Column( children: [ Text(gnssStatusText, style: const TextStyle(fontStyle: FontStyle.italic)), Text(gnssStatusTextLine2, style: const TextStyle(fontStyle: FontStyle.italic)), ], ), Switch( //Enable showing all or only pending events. Default is to show only pending events value: eventStore.gnssSync, onChanged: (value) { eventStore.gnssSync = value; debugPrint('Switched to:' + eventStore.gnssSync.toString()); setState(() { //refresh the UI }); }), ], ) , body: SingleChildScrollView( child: Container( margin: const EdgeInsets.symmetric(horizontal: 5.0), child: Column( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: <Widget>[ const SizedBox(height: 10.0), TextFormField( initialValue: eventStore.currentEvent.label, autovalidateMode: AutovalidateMode.always, decoration: const InputDecoration( border: OutlineInputBorder(), labelText: 'Label', ), onChanged: (value){ eventStore.currentEvent.label = value; setState(() {}); }, validator: (value) { if (!RegExp(r'^[a-z A-Z . \- 0-9 , ( ) + - _ :]+$').hasMatch( value!)) { return "Only: a-z , A-Z , _ , 0-9 , ,(Comma) , ( , ) , + , - , . , :"; } else { return null; // Entered Text is valid } }, ), const SizedBox(height: 15.0), DropdownButtonFormField( value: eventStore.currentEvent.type, isExpanded: true, decoration: const InputDecoration( border: OutlineInputBorder(), labelText: 'Event Type', ), items: configuration.eventTypes.map((EventType event) { return DropdownMenuItem( value: event.name, child: Text(event.name), ); }).toList(), onChanged: (value) { eventStore.currentEvent.type = value.toString(); } ), const SizedBox(height: 15.0), DropdownButtonFormField( value: eventStore.currentEvent.urn, isDense: false, isExpanded: true, decoration: const InputDecoration( border: OutlineInputBorder(), labelText: 'URN', ), items: configuration.devices.map((Device device) { return DropdownMenuItem( value: device.urn, child: Text(device.urn), ); }).toList(), onChanged: (value) { eventStore.currentEvent.urn = value.toString(); eventStore.currentEvent.urnId = configuration.getDeviceIdFromUrn(value.toString()); } ), const SizedBox(height: 15.0), TextFormField( initialValue: eventStore.currentEvent.description, autovalidateMode: AutovalidateMode.always, decoration: const InputDecoration( border: OutlineInputBorder(), labelText: 'Description' ), onChanged: (value){ eventStore.currentEvent.description = value; setState(() {}); }, validator: (value) { if (!RegExp(r'^[a-z A-Z . \- 0-9 , ( ) + - _ :]+$').hasMatch( value!)) { if(value == ''){ return null; //An empty description is also allowed. } return "Only: a-z , A-Z , _ , 0-9 , ,(Comma) , ( , ) , + , - , . , :"; } else { return null; // Entered Text is valid } }, ), const SizedBox(height: 15.0), Row( //mainAxisSize: MainAxisSize.max, //crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[ Flexible( child: TextFormField( enabled: !eventStore.gnssSync, controller: TextEditingController( text: eventStore.currentEvent.startDate.substring(0, 19) + 'Z' //Do not show microseconds ), readOnly: true, decoration: const InputDecoration( labelText: 'Timestamp', //helperText: '', //Adds some space below field border: OutlineInputBorder(), ), onTap: () { DatePicker.showDateTimePicker(context, showTitleActions: true, onConfirm: (date) { //Only one field for start and end date. var isoDate = date.toIso8601String(); eventStore.currentEvent.startDate = isoDate; eventStore.currentEvent.endDate = isoDate; debugPrint('Date set to : $isoDate'); setState(() {}); }, currentTime: DateTime.now().toUtc(), locale: LocaleType.en); }, ), ), const SizedBox(width: 5), ElevatedButton( style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 20.0), //TODO: find a more dynamic solution without a fixed value primary: eventStore.gnssSync == false ? Colors.blue : Colors.grey, ), onPressed: () { if(!eventStore.gnssSync) { var date = DateTime.now().toUtc(); var isoDate = date.toIso8601String(); eventStore.currentEvent.startDate = isoDate; eventStore.currentEvent.endDate = isoDate; debugPrint('Date set to : ' + eventStore.currentEvent.endDate.toString()); setState(() {}); } }, child: const Text('Now'), ), ] ), const SizedBox(height: 15.0), TextFormField( readOnly: false, enabled: !eventStore.gnssSync, keyboardType: TextInputType.number, autovalidateMode: AutovalidateMode.onUserInteraction, controller: TextEditingController( text: eventStore.currentEvent.latitude.toString()), decoration: const InputDecoration( labelText: 'Latitude', border: OutlineInputBorder(), ), onChanged: (value) { eventStore.currentEvent.latitude = value; }, onFieldSubmitted: (value){ if (!eventStore.gnssSync) { setState(() {}); } }, validator: (value) { if (value == "") { return null; // Empty value is allowed } final number = num.tryParse(value!); if (number != null){ if (number >= -90.0 && number <= 90.0){ return null; // Latitude valid } } return "-90 => Latitude <= +90"; }, ), const SizedBox(height: 15.0), TextFormField( readOnly: false, enabled: !eventStore.gnssSync, keyboardType: TextInputType.number, autovalidateMode: AutovalidateMode.onUserInteraction, controller: TextEditingController( text: eventStore.currentEvent.longitude.toString()), decoration: const InputDecoration( labelText: 'Longitude', border: OutlineInputBorder(), ), onChanged: (value) { eventStore.currentEvent.longitude = value; }, onFieldSubmitted: (value){ if (!eventStore.gnssSync) { setState(() {}); } }, validator: (value) { if (value == "") { return null; // Empty value is allowed } final number = num.tryParse(value!); if (number != null){ if (number >= -180.0 && number <= 180.0){ return null; // Longitude valid } } return "-180 => Longitude <= +180"; }, ), const SizedBox(height: 15.0), TextFormField( readOnly: false, enabled: !eventStore.gnssSync, keyboardType: TextInputType.number, autovalidateMode: AutovalidateMode.onUserInteraction, controller: TextEditingController( text: eventStore.currentEvent.elevation.toString()), decoration: const InputDecoration( labelText: 'Elevation', border: OutlineInputBorder(), ), onChanged: (value) { eventStore.currentEvent.elevation = value; }, onFieldSubmitted: (value){ if (!eventStore.gnssSync) { setState(() {}); } }, validator: (value) { if (value == "") { return null; // Empty value is allowed } final number = num.tryParse(value!); if (number != null){ return null; // Elevation valid } return "Only numerical values for elevation in [m]"; }, ), ] ), ), ), bottomNavigationBar: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ Container( margin: const EdgeInsets.symmetric(vertical: 10.0), child: _addButtonStatus() ? FloatingActionButton.extended( heroTag: null, tooltip: 'Create new event in local database', icon: null, label: const Text('Create Event'), onPressed: () { if (_validateInput()) { _addButtonEnabled = false; //Disable button until event is stored _storeCurrentEvent(context); } setState(() {}); }, ): FloatingActionButton.extended( heroTag: null, tooltip: 'Input invalid', icon: null, backgroundColor: Colors.grey, label: const Text('Create Event'), onPressed: () { }, ), ), const SizedBox(width: 5.0), ], ), ); }else { return Scaffold( appBar: AppBar(title: const Text("Add Event")), body: Column( mainAxisAlignment: MainAxisAlignment.spaceEvenly, crossAxisAlignment: CrossAxisAlignment.end, children: <Widget>[ Container( margin: const EdgeInsets.all(10.0), child:const Text( 'Check Configuration Page for initial setup!', style: TextStyle(fontSize: 20) ), ), ], ), ); } } } //TODO: The app shall prefill the label with a configurable prefix and optionally a running number. E.g. prefix: (PS122.3-4_) running number: 1 label = PS122.3-4_1