implement time period validation for bolus/basal profiles

This commit is contained in:
spinel 2021-10-26 01:11:58 +02:00
parent 68927522db
commit db023a94cf
9 changed files with 407 additions and 242 deletions

View File

@ -4,7 +4,6 @@ import 'package:diameter/config.dart';
import 'package:diameter/navigation.dart';
import 'package:diameter/utils/date_time_utils.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:diameter/components/forms.dart';
import 'package:diameter/models/basal.dart';
import 'package:diameter/models/basal_profile.dart';
@ -116,33 +115,40 @@ class _BasalDetailScreenState extends State<BasalDetailScreen> {
Row(
children: [
Expanded(
child: StyledTimeOfDayFormField(
label: 'Start Time',
controller: _startTimeController,
time: _startTime,
onChanged: (newStartTime) {
if (newStartTime != null) {
setState(() {
_startTime = newStartTime;
});
updateStartTime();
}
},
// TODO fix handling of time zones!
child: Padding(
padding: const EdgeInsets.only(right: 5),
child: StyledTimeOfDayFormField(
label: 'Start Time',
controller: _startTimeController,
time: _startTime,
onChanged: (newStartTime) {
if (newStartTime != null) {
setState(() {
_startTime = newStartTime;
});
updateStartTime();
}
},
),
),
),
Expanded(
child: StyledTimeOfDayFormField(
label: 'End Time',
controller: _endTimeController,
time: _endTime,
onChanged: (newEndTime) {
if (newEndTime != null) {
setState(() {
_endTime = newEndTime;
});
updateEndTime();
}
},
child: Padding(
padding: const EdgeInsets.only(left: 5),
child: StyledTimeOfDayFormField(
label: 'End Time',
controller: _endTimeController,
time: _endTime,
onChanged: (newEndTime) {
if (newEndTime != null) {
setState(() {
_endTime = newEndTime;
});
updateEndTime();
}
},
),
),
),
],

View File

@ -1,5 +1,6 @@
import 'package:diameter/components/dialogs.dart';
import 'package:diameter/config.dart';
import 'package:diameter/utils/date_time_utils.dart';
import 'package:flutter/material.dart';
import 'package:diameter/components/progress_indicator.dart';
import 'package:diameter/models/basal.dart';
@ -64,6 +65,44 @@ class _BasalListScreenState extends State<BasalListScreen> {
}
}
String? checkBasalValidity(List<Basal> basalRates, int index) {
Basal basal = basalRates[index];
// check for gaps
if (index == 0 &&
(basal.startTime.toLocal().hour != 0 || basal.startTime.minute != 0)) {
return 'First Basal of the day needs to start at 00:00';
}
if (index > 0) {
var lastEndTime = basalRates[index - 1].endTime;
if (basal.startTime.isAfter(lastEndTime)) {
return 'There\'s a time gap between this and the previous rate';
}
}
if (index == basalRates.length - 1 &&
(basal.endTime.toLocal().hour != 0 || basal.endTime.minute != 0)) {
return 'Last Basal of the day needs to end at 00:00';
}
// check for duplicates
if (basalRates
.where((other) => basal != other && basal.startTime == other.startTime)
.isNotEmpty) {
return 'There are multiple rates with this start time';
}
if (basalRates
.where((other) =>
basal.startTime.isBefore(other.startTime) &&
basal.endTime.isAfter(other.startTime))
.isNotEmpty) {
return 'This rate\'s time period overlaps with another one';
}
}
@override
void initState() {
super.initState();
@ -73,47 +112,60 @@ class _BasalListScreenState extends State<BasalListScreen> {
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.only(top: 10.0),
child: Column(
children: [
FutureBuilder<List<Basal>>(
future: widget.basalProfile!.basalRates,
builder: (context, snapshot) {
return ViewWithProgressIndicator(
// TODO: add warning if time period is missing or has multiple rates
snapshot: snapshot,
child: snapshot.data == null || snapshot.data!.isEmpty
? const Padding(
padding: EdgeInsets.all(10.0),
child: Text('No Basal Rates for this Profile'),
)
: ListBody(
children: [
DataTable(
columnSpacing: 10.0,
showCheckboxColumn: false,
rows: snapshot.data != null
? snapshot.data!.map((basal) {
return DataRow(
cells: basal.asDataTableCells([
IconButton(
icon: const Icon(Icons.edit),
iconSize: 16.0,
onPressed: () =>
handleEditAction(basal)),
IconButton(
icon: const Icon(Icons.delete),
iconSize: 16.0,
onPressed: () =>
handleDeleteAction(basal),
),
]),
);
}).toList()
: [],
columns: Basal.asDataTableColumns(),
),
],
: ListView.builder(
shrinkWrap: true,
itemCount:
snapshot.data != null ? snapshot.data!.length : 0,
itemBuilder: (context, index) {
final basal = snapshot.data![index];
final error =
checkBasalValidity(snapshot.data!, index);
return ListTile(
tileColor:
error != null ? Colors.red.shade100 : null,
onTap: () {
handleEditAction(basal);
},
title: Row(
mainAxisSize: MainAxisSize.max,
children: [
Expanded(
child: Text(
'${DateTimeUtils.displayTime(basal.startTime)} - ${DateTimeUtils.displayTime(basal.endTime)}')),
const Spacer(),
Expanded(child: Text('${basal.units} U')),
],
),
subtitle: error != null
? Text(error,
style: const TextStyle(color: Colors.red))
: Container(),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(
Icons.delete,
color: Colors.blue,
),
onPressed: () => handleDeleteAction(basal),
),
],
),
);
},
),
);
},

View File

@ -227,7 +227,7 @@ class _BasalProfileDetailScreenState extends State<BasalProfileDetailScreen> {
Widget build(BuildContext context) {
bool isNew = widget.basalProfile == null;
return DefaultTabController(
length: 2,
length: isNew ? 1 : 2,
child: Builder(builder: (BuildContext context) {
final TabController tabController = DefaultTabController.of(context)!;
tabController.addListener(() {
@ -235,6 +235,55 @@ class _BasalProfileDetailScreenState extends State<BasalProfileDetailScreen> {
renderTabButtons(tabController.index);
}
});
List<Widget> tabs = [
SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
StyledForm(
formState: _basalProfileForm,
fields: [
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Name',
),
validator: (value) {
if (value!.trim().isEmpty) {
return 'Empty title';
}
return null;
},
),
TextFormField(
keyboardType: TextInputType.multiline,
controller: _notesController,
decoration: const InputDecoration(
labelText: 'Notes',
suffixText: '',
alignLabelWithHint: true,
),
),
StyledBooleanFormField(
value: _active,
onChanged: (value) {
setState(() {
_active = value;
});
},
label: 'active',
),
],
),
],
),
),
];
if (!isNew) {
tabs.add(BasalListScreen(basalProfile: widget.basalProfile));
}
return Scaffold(
appBar: AppBar(
title:
@ -251,53 +300,7 @@ class _BasalProfileDetailScreenState extends State<BasalProfileDetailScreen> {
),
drawer: const Navigation(
currentLocation: BasalProfileDetailScreen.routeName),
body: TabBarView(
children: [
SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
StyledForm(
formState: _basalProfileForm,
fields: [
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Name',
),
validator: (value) {
if (value!.trim().isEmpty) {
return 'Empty title';
}
return null;
},
),
TextFormField(
keyboardType: TextInputType.multiline,
controller: _notesController,
decoration: const InputDecoration(
labelText: 'Notes',
suffixText: '',
alignLabelWithHint: true,
),
),
StyledBooleanFormField(
value: _active,
onChanged: (value) {
setState(() {
_active = value;
});
},
label: 'active',
),
],
),
],
),
),
BasalListScreen(basalProfile: widget.basalProfile),
],
),
body: TabBarView(children: tabs),
bottomNavigationBar: DetailBottomRow(
onCancel: handleCancelAction,
onSave: handleSaveAction,

View File

@ -6,11 +6,9 @@ import 'package:diameter/settings.dart';
import 'package:diameter/utils/date_time_utils.dart';
import 'package:diameter/utils/utils.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:diameter/components/forms.dart';
import 'package:diameter/models/bolus.dart';
import 'package:diameter/models/bolus_profile.dart';
import 'package:flutter/widgets.dart';
class BolusDetailScreen extends StatefulWidget {
static const String routeName = '/bolus';
@ -31,12 +29,12 @@ class _BolusDetailScreenState extends State<BolusDetailScreen> {
TimeOfDay _startTime = const TimeOfDay(hour: 0, minute: 0);
TimeOfDay _endTime = const TimeOfDay(hour: 0, minute: 0);
final _startTimeController = TextEditingController();
final _endTimeController = TextEditingController();
final _unitsController = TextEditingController();
final _carbsController = TextEditingController();
final _mgPerDlController = TextEditingController();
final _mmolPerLController = TextEditingController();
final _startTimeController = TextEditingController(text: '');
final _endTimeController = TextEditingController(text: '');
final _unitsController = TextEditingController(text: '');
final _carbsController = TextEditingController(text: '');
final _mgPerDlController = TextEditingController(text: '');
final _mmolPerLController = TextEditingController(text: '');
@override
void initState() {
@ -136,7 +134,6 @@ class _BolusDetailScreenState extends State<BolusDetailScreen> {
}
void convertBetweenMgPerDlAndMmolPerL({GlucoseMeasurement? calculateFrom}) {
// TODO figure out why this isnt happening automatically
int? mgPerDl;
double? mmolPerL;
@ -182,33 +179,40 @@ class _BolusDetailScreenState extends State<BolusDetailScreen> {
Row(
children: [
Expanded(
child: StyledTimeOfDayFormField(
label: 'Start Time',
controller: _startTimeController,
time: _startTime,
onChanged: (newStartTime) {
if (newStartTime != null) {
setState(() {
_startTime = newStartTime;
});
updateStartTime();
}
},
child: Padding(
padding: const EdgeInsets.only(right: 5),
// TODO fix handling of time zones!
child: StyledTimeOfDayFormField(
label: 'Start Time',
controller: _startTimeController,
time: _startTime,
onChanged: (newStartTime) {
if (newStartTime != null) {
setState(() {
_startTime = newStartTime;
});
updateStartTime();
}
},
),
),
),
Expanded(
child: StyledTimeOfDayFormField(
label: 'End Time',
controller: _endTimeController,
time: _endTime,
onChanged: (newEndTime) {
if (newEndTime != null) {
setState(() {
_endTime = newEndTime;
});
updateEndTime();
}
},
child: Padding(
padding: const EdgeInsets.only(left: 5),
child: StyledTimeOfDayFormField(
label: 'End Time',
controller: _endTimeController,
time: _endTime,
onChanged: (newEndTime) {
if (newEndTime != null) {
setState(() {
_endTime = newEndTime;
});
updateEndTime();
}
},
),
),
),
],
@ -262,7 +266,9 @@ class _BolusDetailScreenState extends State<BolusDetailScreen> {
),
controller: _mgPerDlController,
onChanged: (_) =>
convertBetweenMgPerDlAndMmolPerL,
convertBetweenMgPerDlAndMmolPerL(
calculateFrom:
GlucoseMeasurement.mgPerDl),
keyboardType:
const TextInputType.numberWithOptions(),
validator: (value) {
@ -296,7 +302,9 @@ class _BolusDetailScreenState extends State<BolusDetailScreen> {
),
controller: _mmolPerLController,
onChanged: (_) =>
convertBetweenMgPerDlAndMmolPerL,
convertBetweenMgPerDlAndMmolPerL(
calculateFrom:
GlucoseMeasurement.mmolPerL),
keyboardType:
const TextInputType.numberWithOptions(
decimal: true),

View File

@ -1,5 +1,7 @@
import 'package:diameter/components/dialogs.dart';
import 'package:diameter/config.dart';
import 'package:diameter/settings.dart';
import 'package:diameter/utils/date_time_utils.dart';
import 'package:flutter/material.dart';
import 'package:diameter/components/progress_indicator.dart';
import 'package:diameter/models/bolus.dart';
@ -64,6 +66,44 @@ class _BolusListScreenState extends State<BolusListScreen> {
}
}
String? checkBolusValidity(List<Bolus> bolusRates, int index) {
Bolus bolus = bolusRates[index];
// check for gaps
if (index == 0 &&
(bolus.startTime.toLocal().hour != 0 || bolus.startTime.minute != 0)) {
return 'First Bolus of the day needs to start at 00:00';
}
if (index > 0) {
var lastEndTime = bolusRates[index - 1].endTime;
if (bolus.startTime.isAfter(lastEndTime)) {
return 'There\'s a time gap between this and the previous rate';
}
}
if (index == bolusRates.length - 1 &&
(bolus.endTime.toLocal().hour != 0 || bolus.endTime.minute != 0)) {
return 'Last Bolus of the day needs to end at 00:00';
}
// check for duplicates
if (bolusRates
.where((other) => bolus != other && bolus.startTime == other.startTime)
.isNotEmpty) {
return 'There are multiple rates with this start time';
}
if (bolusRates
.where((other) =>
bolus.startTime.isBefore(other.startTime) &&
bolus.endTime.isAfter(other.startTime))
.isNotEmpty) {
return 'This rate\'s time period overlaps with another one';
}
}
@override
void initState() {
super.initState();
@ -80,41 +120,58 @@ class _BolusListScreenState extends State<BolusListScreen> {
future: widget.bolusProfile!.bolusRates,
builder: (context, snapshot) {
return ViewWithProgressIndicator(
// TODO: add warning if time period is missing or has multiple rates
snapshot: snapshot,
child: snapshot.data == null || snapshot.data!.isEmpty
? const Padding(
padding: EdgeInsets.all(10.0),
child: Text('No Bolus Rates for this Profile'),
child: Text('No Basal Rates for this Profile'),
)
: ListBody(
children: [
DataTable(
columnSpacing: 10.0,
showCheckboxColumn: false,
rows: snapshot.data != null
? snapshot.data!.map((bolus) {
return DataRow(
cells: bolus.asDataTableCells(
[
IconButton(
icon: const Icon(Icons.edit),
iconSize: 16.0,
onPressed: () =>
handleEditAction(bolus)),
IconButton(
icon: const Icon(Icons.delete),
iconSize: 16.0,
onPressed: () =>
handleDeleteAction(bolus)),
],
),
);
}).toList()
: [],
columns: Bolus.asDataTableColumns(),
),
],
: ListView.builder(
shrinkWrap: true,
itemCount:
snapshot.data != null ? snapshot.data!.length : 0,
itemBuilder: (context, index) {
final bolus = snapshot.data![index];
final error =
checkBolusValidity(snapshot.data!, index);
return ListTile(
tileColor:
error != null ? Colors.red.shade100 : null,
onTap: () {
handleEditAction(bolus);
},
title: Row(
mainAxisSize: MainAxisSize.max,
children: [
Expanded(
child: Text(
'${DateTimeUtils.displayTime(bolus.startTime)} - ${DateTimeUtils.displayTime(bolus.endTime)}')),
// TODO: style this
Expanded(
child: Text(
'${bolus.units} U per ${bolus.carbs}${nutritionMeasurement == NutritionMeasurement.grams ? ' g' : ' oz'} carbs/${glucoseMeasurement == GlucoseMeasurement.mgPerDl ? bolus.mgPerDl : bolus.mmolPerL} ${glucoseMeasurement == GlucoseMeasurement.mgPerDl ? 'mg/dl' : 'mmol/l'}',
style: const TextStyle(fontSize: 12.0)),
),
],
),
subtitle: error != null
? Text(error,
style: const TextStyle(color: Colors.red))
: Container(),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(
Icons.delete,
color: Colors.blue,
),
onPressed: () => handleDeleteAction(bolus),
),
],
),
);
},
),
);
},

View File

@ -227,7 +227,7 @@ class _BolusProfileDetailScreenState extends State<BolusProfileDetailScreen> {
Widget build(BuildContext context) {
bool isNew = widget.bolusProfile == null;
return DefaultTabController(
length: 2,
length: isNew ? 1 : 2,
child: Builder(builder: (BuildContext context) {
final TabController tabController = DefaultTabController.of(context)!;
tabController.addListener(() {
@ -235,6 +235,55 @@ class _BolusProfileDetailScreenState extends State<BolusProfileDetailScreen> {
renderTabButtons(tabController.index);
}
});
List<Widget> tabs = [
SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
StyledForm(
formState: _bolusProfileForm,
fields: [
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Name',
),
validator: (value) {
if (value!.trim().isEmpty) {
return 'Empty title';
}
return null;
},
),
TextFormField(
decoration: const InputDecoration(
labelText: 'Notes',
alignLabelWithHint: true,
),
controller: _notesController,
keyboardType: TextInputType.multiline,
),
StyledBooleanFormField(
value: _active,
onChanged: (value) {
setState(() {
_active = value;
});
},
label: 'active',
),
],
),
],
),
),
];
if (!isNew) {
tabs.add(BolusListScreen(bolusProfile: widget.bolusProfile));
}
return Scaffold(
appBar: AppBar(
title:
@ -252,50 +301,7 @@ class _BolusProfileDetailScreenState extends State<BolusProfileDetailScreen> {
drawer: const Navigation(
currentLocation: BolusProfileDetailScreen.routeName),
body: TabBarView(
children: [
SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
StyledForm(
formState: _bolusProfileForm,
fields: [
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Name',
),
validator: (value) {
if (value!.trim().isEmpty) {
return 'Empty title';
}
return null;
},
),
TextFormField(
decoration: const InputDecoration(
labelText: 'Notes',
alignLabelWithHint: true,
),
controller: _notesController,
keyboardType: TextInputType.multiline,
),
StyledBooleanFormField(
value: _active,
onChanged: (value) {
setState(() {
_active = value;
});
},
label: 'active',
),
],
),
],
),
),
BolusListScreen(bolusProfile: widget.bolusProfile),
],
children: tabs,
),
bottomNavigationBar: DetailBottomRow(
onCancel: handleCancelAction,

View File

@ -217,7 +217,7 @@ class _LogEntryScreenState extends State<LogEntryScreen> {
bool isNew = widget.entry == null;
return DefaultTabController(
length: 3,
length: isNew ? 1 : 3,
child: Builder(builder: (BuildContext context) {
final TabController tabController = DefaultTabController.of(context)!;
tabController.addListener(() {
@ -225,6 +225,16 @@ class _LogEntryScreenState extends State<LogEntryScreen> {
renderTabButtons(tabController.index);
}
});
List<Widget> tabs = [
LogEntryForm(
formState: logEntryForm, controllers: formDataControllers),
];
if (!isNew) {
tabs.add(LogMealListScreen(logEntry: widget.entry));
tabs.add(LogEventListScreen(logEntry: widget.entry));
}
return Scaffold(
appBar: AppBar(
title: Text(isNew ? 'New Log Entry' : 'Edit Log Entry'),
@ -241,12 +251,7 @@ class _LogEntryScreenState extends State<LogEntryScreen> {
),
drawer: const Navigation(currentLocation: LogEntryScreen.routeName),
body: TabBarView(
children: [
LogEntryForm(
formState: logEntryForm, controllers: formDataControllers),
LogMealListScreen(logEntry: widget.entry),
LogEventListScreen(logEntry: widget.entry),
],
children: tabs,
),
bottomNavigationBar: DetailBottomRow(
onCancel: handleCancelAction,

View File

@ -17,6 +17,35 @@ class LogEntryForm extends StatefulWidget {
}
class _LogEntryFormState extends State<LogEntryForm> {
void convertBetweenMgPerDlAndMmolPerL({GlucoseMeasurement? calculateFrom}) {
int? mgPerDl;
double? mmolPerL;
final _mgPerDlController = widget.controllers['mgPerDl'];
final _mmolPerLController = widget.controllers['mmolPerL'];
if (calculateFrom != GlucoseMeasurement.mmolPerL &&
_mgPerDlController!.text != '') {
mgPerDl = int.tryParse(_mgPerDlController.text);
}
if (calculateFrom != GlucoseMeasurement.mgPerDl &&
_mmolPerLController!.text != '') {
mmolPerL = double.tryParse(_mmolPerLController.text);
}
if (mgPerDl != null && mmolPerL == null) {
setState(() {
_mmolPerLController!.text =
Utils.convertMgPerDlToMmolPerL(mgPerDl!).toString();
});
}
if (mmolPerL != null && mgPerDl == null) {
setState(() {
_mgPerDlController!.text =
Utils.convertMmolPerLToMgPerDl(mmolPerL!).toString();
});
}
}
@override
Widget build(BuildContext context) {
final _timeController = widget.controllers['time'];
@ -48,8 +77,6 @@ class _LogEntryFormState extends State<LogEntryForm> {
// }
//),
Row(
// TODO: improve conversion of mg/dl and mmol/l
// TODO: display according to settings
children: [
glucoseMeasurement == GlucoseMeasurement.mgPerDl ||
glucoseDisplayMode == GlucoseDisplayMode.both ||
@ -61,6 +88,8 @@ class _LogEntryFormState extends State<LogEntryForm> {
suffixText: 'mg/dl',
),
controller: _mgPerDlController,
onChanged: (_) => convertBetweenMgPerDlAndMmolPerL(
calculateFrom: GlucoseMeasurement.mgPerDl),
keyboardType: const TextInputType.numberWithOptions(),
validator: (value) {
if (value!.trim().isEmpty &&
@ -72,6 +101,14 @@ class _LogEntryFormState extends State<LogEntryForm> {
),
)
: Container(),
glucoseDisplayMode == GlucoseDisplayMode.both ||
glucoseDisplayMode == GlucoseDisplayMode.bothForDetail
? IconButton(
onPressed: () => convertBetweenMgPerDlAndMmolPerL(
calculateFrom: GlucoseMeasurement.mmolPerL),
icon: const Icon(Icons.calculate),
)
: Container(),
glucoseMeasurement == GlucoseMeasurement.mmolPerL ||
glucoseDisplayMode == GlucoseDisplayMode.both ||
glucoseDisplayMode == GlucoseDisplayMode.bothForDetail
@ -80,19 +117,10 @@ class _LogEntryFormState extends State<LogEntryForm> {
decoration: const InputDecoration(
labelText: 'mmol/l',
suffixText: 'mmol/l',
alignLabelWithHint: true,
),
controller: _mmolPerLController,
onChanged: (_) {
setState(() {
_mgPerDlController!.text =
Utils.convertMmolPerLToMgPerDl(
double.tryParse(
_mgPerDlController.text) ??
0)
.toString();
});
},
onChanged: (_) => convertBetweenMgPerDlAndMmolPerL(
calculateFrom: GlucoseMeasurement.mmolPerL),
keyboardType: const TextInputType.numberWithOptions(
decimal: true),
validator: (value) {
@ -105,6 +133,14 @@ class _LogEntryFormState extends State<LogEntryForm> {
),
)
: Container(),
glucoseDisplayMode == GlucoseDisplayMode.both ||
glucoseDisplayMode == GlucoseDisplayMode.bothForDetail
? IconButton(
onPressed: () => convertBetweenMgPerDlAndMmolPerL(
calculateFrom: GlucoseMeasurement.mgPerDl),
icon: const Icon(Icons.calculate),
)
: Container(),
],
),
TextFormField(
@ -142,16 +178,6 @@ class _LogEntryFormState extends State<LogEntryForm> {
keyboardType: TextInputType.multiline,
),
],
// buttons: [
// ElevatedButton(
// onPressed: handleCancelAction,
// child: const Text('CANCEL'),
// ),
// ElevatedButton(
// onPressed: handleSaveAction,
// child: const Text('SAVE'),
// ),
// ],
),
]),
);

View File

@ -3,6 +3,8 @@ import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
class DateTimeUtils {
// TODO fix handling of time zones!
static String displayDateTime(DateTime? date, {String fallback = ''}) {
if (date == null) {
return fallback;