diff --git a/extras/log_file_client/lib/components/chart_series_selector.dart b/extras/log_file_client/lib/components/chart_series_selector.dart new file mode 100644 index 00000000..a0291185 --- /dev/null +++ b/extras/log_file_client/lib/components/chart_series_selector.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; + +class ChartSeriesSelector extends StatefulWidget { + const ChartSeriesSelector({required this.onPressed, super.key}); + final void Function(int) onPressed; + + @override + State createState() => _ChartSeriesSelectorState(); +} + +class _ChartSeriesSelectorState extends State { + final List _buttonState = [true, true]; + + void toggleButton(int index) { + setState(() { + _buttonState[index] = !_buttonState[index]; + widget.onPressed(index); + }); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(8), + ), + child: SizedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + _chartSeriesOption('pH', Colors.green, 0), + SizedBox(width: 4), + _chartSeriesOption('temp', Colors.blue, 1), + ], + ), + ), + ); + } + + Widget _chartSeriesOption(String text, Color color, int index) { + final active = _buttonState[index]; + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => toggleButton(index), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: active ? color : Colors.grey[100], + borderRadius: BorderRadius.circular(8), + boxShadow: active + ? [ + BoxShadow( + color: Colors.grey[300]!, + blurRadius: 4, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: Text( + text, + style: TextStyle(color: active ? Colors.white : color), + ), + ), + ), + ); + } +} diff --git a/extras/log_file_client/lib/components/custom_time_range_picker.dart b/extras/log_file_client/lib/components/custom_time_range_picker.dart new file mode 100644 index 00000000..2dedcc7a --- /dev/null +++ b/extras/log_file_client/lib/components/custom_time_range_picker.dart @@ -0,0 +1,191 @@ +import 'package:date_picker_plus/date_picker_plus.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:log_file_client/components/wheel_picker.dart'; + +enum RangeEndpointType { start, end } + +class CustomTimeRangePicker extends StatefulWidget { + const CustomTimeRangePicker({ + required this.timeRange, + required this.onApplied, + super.key, + }); + + final DateTimeRange timeRange; + final void Function(DateTimeRange) onApplied; + + @override + State createState() => _CustomTimeRangePickerState(); +} + +class _CustomTimeRangePickerState extends State { + late DateTime minTime; + late DateTime maxTime; + + @override + void initState() { + super.initState(); + minTime = widget.timeRange.start; + maxTime = widget.timeRange.end; + } + + void onDatesSelected(DateTimeRange value) { + minTime = DateTime( + value.start.year, + value.start.month, + value.start.day, + minTime.hour, + minTime.minute, + ); + maxTime = DateTime( + value.end.year, + value.end.month, + value.end.day, + maxTime.hour, + maxTime.minute, + ); + } + + void onTimeSelected(TimeOfDay time, RangeEndpointType type) { + if (type == RangeEndpointType.start) { + minTime = DateTime( + minTime.year, + minTime.month, + minTime.day, + time.hour, + time.minute, + ); + } else if (type == RangeEndpointType.end) { + maxTime = DateTime( + maxTime.year, + maxTime.month, + maxTime.day, + time.hour, + time.minute, + ); + } + } + + void onApplied() { + // Ensure the time range is within the available range + if (minTime.isBefore(widget.timeRange.start)) { + minTime = widget.timeRange.start; + } + if (maxTime.isAfter(widget.timeRange.end)) { + maxTime = widget.timeRange.end; + } + + widget.onApplied(DateTimeRange(start: minTime, end: maxTime)); + } + + @override + Widget build(BuildContext context) { + return IntrinsicHeight( + child: Column( + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _calendarPicker(), + const SizedBox(width: 28), + _rangeDisplay(), + const SizedBox(width: 28), + ], + ), + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: TextButton( + onPressed: onApplied, + child: const Text('Apply'), + ), + ), + ], + ), + ); + } + + Widget _rangeDisplay() { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 16), + _rangeDisplayItem(RangeEndpointType.start), + const SizedBox(height: 32), + _rangeDisplayItem(RangeEndpointType.end), + ], + ); + } + + Widget _rangeDisplayItem(RangeEndpointType type) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + type == RangeEndpointType.start ? 'From' : 'To', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.grey, + ), + ), + const SizedBox(height: 4), + _dateDisplay(type), + const SizedBox(height: 4), + _timePicker(type), + ], + ); + } + + Widget _dateDisplay(RangeEndpointType type) { + return Text( + type == RangeEndpointType.start + ? DateFormat.yMMMd('en_US').format(minTime) + : DateFormat.yMMMd('en_US').format(maxTime), + style: const TextStyle(fontSize: 16), + ); + } + + Widget _calendarPicker() { + return SizedBox( + width: 300, + height: 300, + child: RangeDatePicker( + centerLeadingDate: true, + minDate: minTime, + maxDate: maxTime, + onRangeSelected: onDatesSelected, + splashColor: Colors.transparent, + daysOfTheWeekTextStyle: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.grey.shade400, + ), + enabledCellsTextStyle: const CellTextStyle(), + disabledCellsTextStyle: const CellTextStyle(color: Colors.grey), + selectedCellsTextStyle: const CellTextStyle(), + singleSelectedCellTextStyle: const CellTextStyle(color: Colors.white), + currentDateTextStyle: const CellTextStyle(), + ), + ); + } + + Widget _timePicker(RangeEndpointType type) { + final TimeOfDay selectedTime = type == RangeEndpointType.start + ? TimeOfDay(hour: minTime.hour, minute: minTime.minute) + : TimeOfDay(hour: maxTime.hour, minute: maxTime.minute); + + return SizedBox( + height: 100, + child: MyWheelPicker( + selectedTime: selectedTime, + onTimeSelected: (time) => onTimeSelected(time, type), + ), + ); + } +} + +class CellTextStyle extends TextStyle { + const CellTextStyle({super.color, fontSize = 16}); +} diff --git a/extras/log_file_client/lib/components/graph_view.dart b/extras/log_file_client/lib/components/graph_view.dart index ec66ea50..6ceb2fb0 100644 --- a/extras/log_file_client/lib/components/graph_view.dart +++ b/extras/log_file_client/lib/components/graph_view.dart @@ -1,17 +1,18 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; -import 'package:log_file_client/components/toggle_button.dart'; +import 'package:log_file_client/components/chart_series_selector.dart'; +import 'package:log_file_client/components/time_range_selector.dart'; import 'package:log_file_client/utils/http_client.dart'; import 'package:syncfusion_flutter_charts/charts.dart'; class GraphView extends StatefulWidget { const GraphView({ - required this.filePath, - required this.httpClient, + required this.logData, super.key, }); - final String filePath; - final HttpClient httpClient; + final List logData; @override State createState() => _GraphViewState(); @@ -20,10 +21,19 @@ class GraphView extends StatefulWidget { class _GraphViewState extends State { bool _showPH = true; bool _showTemp = true; + late final DateTimeRange avaliableTimeRange; + late List _displayedTimeRange = [ + max(widget.logData.length - 1400, 0), + widget.logData.length, + ]; // 1 day - Future> getLogData() async { - final table = await widget.httpClient.getLogData(widget.filePath); - return table; + @override + void initState() { + super.initState(); + avaliableTimeRange = DateTimeRange( + start: widget.logData.first.time, + end: widget.logData.last.time, + ); } void toggleSeriesView(int index) { @@ -36,54 +46,77 @@ class _GraphViewState extends State { }); } + void toggleTimeRange(int index, DateTimeRange? customRange) { + final ranges = [ + 360, // 6 hours + 720, // 12 hours + 1440, // 1 day + 4320, // 3 days + 10080, // 7 days + 43200, // 30 days + 9999999, // Max + ]; + + setState(() { + if (index < 7) { + _displayedTimeRange = [ + max(widget.logData.length - ranges[index], 0), + widget.logData.length, + ]; + } else if (index == 7) { + final int endOffset = + avaliableTimeRange.end.difference(customRange!.end).inMinutes; + final int endIndex = widget.logData.length - endOffset; + final int startIndex = endIndex - customRange.duration.inMinutes; + _displayedTimeRange = [startIndex, endIndex]; + } + }); + } + @override Widget build(BuildContext context) { - // ignore: discarded_futures - final Future> logData = getLogData(); - return Scaffold( body: Center( child: Column( - mainAxisAlignment: MainAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.start, children: [ - _graphBuilder(logData), - _toggleButton(), + _topRow(widget.logData), + _graph( + widget.logData.sublist( + _displayedTimeRange[0], + _displayedTimeRange[1], + ), + ), ], ), ), ); } - FutureBuilder _graphBuilder( - Future> logData, - ) { - return FutureBuilder>( - future: logData, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return Padding( - padding: const EdgeInsets.only(bottom: 300), - child: CircularProgressIndicator(), - ); - } else if (snapshot.hasError) { - return Center(child: Text('Error: ${snapshot.error}')); - } else if (!snapshot.hasData || snapshot.data!.isEmpty) { - return const Center(child: Text('No data found')); - } else { - final logData = snapshot.data!; - return _graph(logData); - } - }, - ); - } - - Widget _toggleButton() { - return Center( - child: Padding( - padding: const EdgeInsets.only(bottom: 8.0), - child: ToggleButton( - onPressed: toggleSeriesView, - ), + Widget _topRow(List logData) { + return Padding( + padding: const EdgeInsets.only(left: 70, right: 70, top: 15, bottom: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Tank ID: ${logData.first.tankid}', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 20, + ), + ), + Row( + children: [ + ChartSeriesSelector(onPressed: toggleSeriesView), + const SizedBox(width: 10), + TimeRangeSelector( + onSelected: toggleTimeRange, + avaliableTimeRange: avaliableTimeRange, + ), + ], + ), + ], ), ); } @@ -107,7 +140,6 @@ class _GraphViewState extends State { return Expanded( child: SfCartesianChart( - title: ChartTitle(text: 'Tank ID: ${logData.first.tankid}'), backgroundColor: Colors.white, primaryXAxis: DateTimeAxis( title: AxisTitle(text: 'Time'), @@ -117,13 +149,14 @@ class _GraphViewState extends State { primaryYAxis: NumericAxis( name: 'pHAxis', title: AxisTitle(text: 'pH Value'), + isVisible: _showPH, ), axes: [ NumericAxis( name: 'TemperatureAxis', title: AxisTitle(text: 'Temperature Value'), opposedPosition: true, - isVisible: _showTemp && _showPH, + isVisible: _showTemp, ), ], trackballBehavior: trackballBehavior, @@ -135,48 +168,44 @@ class _GraphViewState extends State { List _chartSeries(List logData) { return [ LineSeries( - legendItemText: 'temp', - name: 'temp', + legendItemText: 'pH', + name: 'pH', dataSource: logData, xValueMapper: (LogDataLine log, _) => log.time, - yValueMapper: (LogDataLine log, _) => log.tempMean, - color: Colors.blue, - yAxisName: 'TemperatureAxis', + yValueMapper: (LogDataLine log, _) => log.phCurrent, + color: _showPH ? Colors.green : Colors.transparent, + yAxisName: 'pHAxis', animationDuration: 0, - initialIsVisible: _showTemp, ), LineSeries( - legendItemText: 'temp setpoint', - name: 'temp setpoint', + legendItemText: 'pH setpoint', + name: 'pH setpoint', dataSource: logData, xValueMapper: (LogDataLine log, _) => log.time, - yValueMapper: (LogDataLine log, _) => log.tempTarget, - color: Colors.blue.shade800, - yAxisName: 'TemperatureAxis', + yValueMapper: (LogDataLine log, _) => log.phTarget, + color: _showPH ? Colors.green.shade800 : Colors.transparent, + yAxisName: 'pHAxis', animationDuration: 0, - initialIsVisible: _showTemp, ), LineSeries( - legendItemText: 'pH', - name: 'pH', + legendItemText: 'temp', + name: 'temp', dataSource: logData, xValueMapper: (LogDataLine log, _) => log.time, - yValueMapper: (LogDataLine log, _) => log.phCurrent, - color: Colors.green, - yAxisName: 'pHAxis', + yValueMapper: (LogDataLine log, _) => log.tempMean, + color: _showTemp ? Colors.blue : Colors.transparent, + yAxisName: 'TemperatureAxis', animationDuration: 0, - initialIsVisible: _showPH, ), LineSeries( - legendItemText: 'pH setpoint', - name: 'pH setpoint', + legendItemText: 'temp setpoint', + name: 'temp setpoint', dataSource: logData, xValueMapper: (LogDataLine log, _) => log.time, - yValueMapper: (LogDataLine log, _) => log.phTarget, - color: Colors.green.shade800, - yAxisName: 'pHAxis', + yValueMapper: (LogDataLine log, _) => log.tempTarget, + color: _showTemp ? Colors.blue.shade800 : Colors.transparent, + yAxisName: 'TemperatureAxis', animationDuration: 0, - initialIsVisible: _showPH, ), ]; } diff --git a/extras/log_file_client/lib/components/time_range_selector.dart b/extras/log_file_client/lib/components/time_range_selector.dart new file mode 100644 index 00000000..0d81ceaf --- /dev/null +++ b/extras/log_file_client/lib/components/time_range_selector.dart @@ -0,0 +1,134 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:log_file_client/components/custom_time_range_picker.dart'; +import 'package:popover/popover.dart'; + +class TimeRangeSelector extends StatefulWidget { + const TimeRangeSelector({ + required this.onSelected, + required this.avaliableTimeRange, + super.key, + }); + final void Function(int, DateTimeRange?) onSelected; + final DateTimeRange avaliableTimeRange; + + @override + State createState() => _TimeRangeSelectorState(); +} + +class _TimeRangeSelectorState extends State { + final List _isSelected = [ + false, + false, + true, + false, + false, + false, + false, + false, + ]; + + Future onPressed(int index, BuildContext context) async { + if (index < 7) { + setState(() { + _isSelected.fillRange(0, _isSelected.length, false); + _isSelected[index] = true; + widget.onSelected(index, null); + }); + } + if (index == 7) { + late DateTimeRange timeRange; + await showPopover( + context: context, + bodyBuilder: (context) => CustomTimeRangePicker( + timeRange: widget.avaliableTimeRange, + onApplied: (value) { + timeRange = value; + Navigator.of(context).pop(); + }, + ), + ); + setState(() { + _isSelected.fillRange(0, _isSelected.length, false); + _isSelected[index] = true; + widget.onSelected(index, timeRange); + }); + } + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(8), + ), + child: IntrinsicHeight( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + _timeRangeOption('6H', 0), + _timeRangeOption('12H', 1), + _timeRangeOption('24H', 2), + _timeRangeOption('3D', 3), + _timeRangeOption('1W', 4), + _timeRangeOption('1M', 5), + _timeRangeOption('Max', 6), + _verticalDivider(), + _timeRangeOption('Custom', 7), + ], + ), + ), + ); + } + + Padding _verticalDivider() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: VerticalDivider( + color: Colors.grey[300], + width: 1, + indent: 4, + endIndent: 4, + ), + ); + } + + Widget _timeRangeOption(String text, int index) { + final active = _isSelected[index]; + return MouseRegion( + cursor: SystemMouseCursors.click, + child: Builder( + builder: (context) { + return GestureDetector( + onTap: () => unawaited(onPressed(index, context)), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: active ? Colors.white : Colors.grey[100], + borderRadius: BorderRadius.circular(8), + boxShadow: active + ? [ + BoxShadow( + color: Colors.grey[300]!, + blurRadius: 4, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: Text( + text, + style: + TextStyle(color: active ? Colors.black : Colors.grey[600]), + ), + ), + ); + }, + ), + ); + } +} diff --git a/extras/log_file_client/lib/components/toggle_button.dart b/extras/log_file_client/lib/components/toggle_button.dart deleted file mode 100644 index 491bf395..00000000 --- a/extras/log_file_client/lib/components/toggle_button.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'package:flutter/material.dart'; - -class ToggleButton extends StatefulWidget { - const ToggleButton({required this.onPressed, super.key}); - final void Function(int index) onPressed; - - @override - State createState() => _ToggleButtonState(); -} - -class _ToggleButtonState extends State { - List buttonState = [true, true]; - - void toggleButton(int index) { - setState(() { - buttonState[index] = !buttonState[index]; - widget.onPressed(index); - }); - } - - @override - Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - color: Colors.grey[500], - ), - padding: const EdgeInsets.all(0.5), - child: SizedBox( - width: 148, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _halfButton('pH', Colors.green, 0), - _halfButton('temp', Colors.blue, 1), - ], - ), - ), - ); - } - - TextButton _halfButton(String text, Color color, int index) { - return TextButton( - key: Key(text), - onPressed: () { - toggleButton(index); - }, - style: _buttonStyle(color, index), - child: _buttonText(text, color, index), - ); - } - - ButtonStyle _buttonStyle(Color color, int index) { - return ButtonStyle( - backgroundColor: buttonState[index] - ? WidgetStateProperty.all(color) - : WidgetStateProperty.all(Colors.white), - shape: WidgetStateProperty.all( - RoundedRectangleBorder( - borderRadius: index == 0 - ? BorderRadius.only( - topLeft: Radius.circular(10), - bottomLeft: Radius.circular(10), - ) - : BorderRadius.only( - topRight: Radius.circular(10), - bottomRight: Radius.circular(10), - ), - ), - ), - ); - } - - Widget _buttonText(String text, Color color, int index) { - return SizedBox( - width: 50, - height: 40, - child: Center( - child: Text( - text, - style: TextStyle(color: buttonState[index] ? Colors.white : color), - ), - ), - ); - } -} diff --git a/extras/log_file_client/lib/components/wheel_picker.dart b/extras/log_file_client/lib/components/wheel_picker.dart new file mode 100644 index 00000000..2ab6ae93 --- /dev/null +++ b/extras/log_file_client/lib/components/wheel_picker.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; +import 'package:wheel_picker/wheel_picker.dart'; + +enum WheelType { hours, minutes, amPm } + +class MyWheelPicker extends StatefulWidget { + const MyWheelPicker({ + required this.selectedTime, + required this.onTimeSelected, + super.key, + }); + + final TimeOfDay selectedTime; + final void Function(TimeOfDay) onTimeSelected; + + @override + State createState() => _MyWheelPickerState(); +} + +class _MyWheelPickerState extends State { + late TimeOfDay selectedTime; + late String selectedAmPm = selectedTime.hour < 12 ? 'AM' : 'PM'; + + @override + void initState() { + super.initState(); + selectedTime = widget.selectedTime; + } + + void onIndexChanged(int index, WheelType type) { + switch (type) { + case WheelType.hours: + selectedTime = TimeOfDay( + hour: (selectedAmPm == 'PM') ? index + 12 : index, + minute: selectedTime.minute, + ); + break; + case WheelType.minutes: + selectedTime = TimeOfDay( + hour: selectedTime.hour, + minute: index, + ); + break; + case WheelType.amPm: + selectedAmPm = ['AM', 'PM'][index]; + selectedTime = TimeOfDay( + hour: selectedTime.hour, + minute: selectedTime.minute, + ); + break; + } + widget.onTimeSelected(selectedTime); + } + + late final _hoursWheelController = WheelPickerController( + itemCount: 12, + initialIndex: selectedTime.hour % 12, + ); + late final _minutesWheelController = WheelPickerController( + itemCount: 60, + initialIndex: selectedTime.minute, + mounts: [_hoursWheelController], + ); + + @override + Widget build(BuildContext context) { + const textStyle = TextStyle( + fontSize: 16, + fontWeight: FontWeight.normal, + height: 1.5, + color: Colors.black, + decoration: TextDecoration.none, + ); + final wheelStyle = WheelPickerStyle( + itemExtent: textStyle.fontSize! * textStyle.height!, // Text height + squeeze: 1.25, + diameterRatio: .8, + surroundingOpacity: .25, + ); + + Widget itemBuilder(BuildContext context, int index) { + return Text('$index'.padLeft(2, '0'), style: textStyle); + } + + final hourWheel = Expanded( + child: WheelPicker( + builder: itemBuilder, + controller: _hoursWheelController, + looping: false, + style: wheelStyle, + selectedIndexColor: Colors.black, + onIndexChanged: (index, interactionType) { + onIndexChanged(index, WheelType.hours); + }, + ), + ); + + final minuteWheel = Expanded( + child: WheelPicker( + builder: itemBuilder, + controller: _minutesWheelController, + looping: true, + style: wheelStyle, + selectedIndexColor: Colors.black, + onIndexChanged: (index, interactionType) { + onIndexChanged(index, WheelType.minutes); + }, + ), + ); + + final amPmWheel = Expanded( + child: WheelPicker( + itemCount: 2, + builder: (context, index) { + return Text(['AM', 'PM'][index], style: textStyle); + }, + initialIndex: (selectedTime.period == DayPeriod.am) ? 0 : 1, + onIndexChanged: (index, interactionType) { + onIndexChanged(index, WheelType.amPm); + }, + looping: false, + style: wheelStyle.copyWith( + shiftAnimationStyle: const WheelShiftAnimationStyle( + duration: Duration(seconds: 1), + curve: Curves.bounceOut, + ), + ), + ), + ); + + return Center( + child: SizedBox( + width: 125, + height: 150, + child: Stack( + fit: StackFit.expand, + children: [ + _centerBar(context), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 15.0), + child: Row( + children: [ + hourWheel, + const Text(':', style: textStyle), + minuteWheel, + amPmWheel, + ], + ), + ), + ], + ), + ), + ); + } + + @override + void dispose() { + _hoursWheelController.dispose(); + _minutesWheelController.dispose(); + super.dispose(); + } + + Widget _centerBar(BuildContext context) { + return Center( + child: Container( + height: 30, + decoration: BoxDecoration( + color: const Color.fromARGB(255, 116, 126, 202).withAlpha(26), + borderRadius: BorderRadius.circular(8.0), + ), + ), + ); + } +} diff --git a/extras/log_file_client/lib/pages/graph_page.dart b/extras/log_file_client/lib/pages/graph_page.dart index 116e32f9..849ffb13 100644 --- a/extras/log_file_client/lib/pages/graph_page.dart +++ b/extras/log_file_client/lib/pages/graph_page.dart @@ -8,17 +8,42 @@ class GraphPage extends StatelessWidget { final HttpClient httpClient; final Log log; + Future> getLogData() async { + final table = await httpClient.getLogData('/logs/${log.uri}'); + + return table; + } + @override Widget build(BuildContext context) { + // ignore: discarded_futures + final Future> logData = getLogData(); + return Scaffold( appBar: AppBar( backgroundColor: Theme.of(context).colorScheme.inversePrimary, title: const Text('Tank Monitor'), ), body: Center( - child: GraphView( - filePath: '/logs/${log.uri}', - httpClient: httpClient, + child: FutureBuilder( + future: logData, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return Padding( + padding: const EdgeInsets.only(bottom: 300), + child: CircularProgressIndicator(), + ); + } else if (snapshot.hasError) { + return Center(child: Text('Error: ${snapshot.error}')); + } else if (!snapshot.hasData || snapshot.data!.isEmpty) { + return const Center(child: Text('No data found')); + } else { + final logData = snapshot.data!; + return GraphView( + logData: logData, + ); + } + }, ), ), ); diff --git a/extras/log_file_client/lib/utils/http_client.dart b/extras/log_file_client/lib/utils/http_client.dart index 402c8391..d375e011 100644 --- a/extras/log_file_client/lib/utils/http_client.dart +++ b/extras/log_file_client/lib/utils/http_client.dart @@ -293,7 +293,8 @@ class HttpClientTest extends HttpClient { if (filePath == 'logs' || filePath == 'logs/index.html') { return testHTML; } else if (filePath == 'sample_short.log' || - filePath == 'api/sample_short.log') { + filePath == 'api/sample_short.log' || + filePath == '/logs/sample_short.log') { return ''' Version Tank ID Severity Date Time Message Temperature Target Temperature Mean Temperature Std Dev pH Target pH Uptime MAC Address pH Slope Ignoring Bad pH Calibration Temperature Correction Ignoring Bad Temperature Calibration Heat (1) or Chill (0) KD KI KP pH Flat (0) Ramp (1) Sine (2) pH Target pH Ramp Start Time pH Ramp End Time pH Ramp Start Value pH Sine Start Time pH Sine Period pH Sine Amplitude Temperature Flat (0) Ramp (1) Sine (2) Temperature Target Temperature Ramp Start Time Temperature Ramp End Time Temperature Ramp Start Value Temperature Sine Start Time Temperature Sine Period Temperature Sine Amplitude Google Sheet Interval v25.1.1 89 I 2025-01-23 15:38 20.11 20 0 7 0 60 @@ -302,7 +303,8 @@ v25.1.1 89 I 2025-01-23 15:40 20.24 20 0 7 6.9 180 v25.1.1 89 I 2025-01-23 15:43 20.38 20 0 7 0 60 v25.1.1 89 I 2025-01-23 15:44 20.44 20 0 7 0 121 '''; - } else if (filePath == 'sample_long.log') { + } else if (filePath == 'sample_long.log' || + filePath == '/logs/sample_long.log') { return sampleData(); } else if (filePath == 'api/sample_long.log') { return sampleSnapshotData(); diff --git a/extras/log_file_client/lib/utils/sample_data.dart b/extras/log_file_client/lib/utils/sample_data.dart index cb4bf8f7..c4a23441 100644 --- a/extras/log_file_client/lib/utils/sample_data.dart +++ b/extras/log_file_client/lib/utils/sample_data.dart @@ -580,7 +580,6 @@ k ID Severity Date Time Message Temperature Target Temperature Mean Temperature v25.1.1 89 I 2025-01-24 16:29 16.3 20 0 7.5 0 240 v25.1.1 89 I 2025-01-24 16:30 16.37 20 0 7.5 0 300 v25.1.1 89 I 2025-01-24 16:32 16.48 21 0 7.5 7 60 - '''; } diff --git a/extras/log_file_client/pubspec.lock b/extras/log_file_client/pubspec.lock index 8eae9580..17b7c380 100644 --- a/extras/log_file_client/pubspec.lock +++ b/extras/log_file_client/pubspec.lock @@ -153,6 +153,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.1" + date_picker_plus: + dependency: "direct main" + description: + name: date_picker_plus + sha256: "9b22c94f86c76b20ebe46fa3e54544266400556a8ec9ad5508868ef3180ee513" + url: "https://pub.dev" + source: hosted + version: "4.1.0" fake_async: dependency: transitive description: @@ -379,6 +387,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.1" + popover: + dependency: "direct main" + description: + name: popover + sha256: "0606f3e10f92fc0459f5c52fd917738c29e7552323b28694d50c2d3312d0e1a2" + url: "https://pub.dev" + source: hosted + version: "0.3.1" pub_semver: dependency: transitive description: @@ -600,6 +616,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + wheel_picker: + dependency: "direct main" + description: + name: wheel_picker + sha256: c116ef7e801d50b090ecd033b41efecd82f90fb51dbfc3543408bba54e36ed83 + url: "https://pub.dev" + source: hosted + version: "0.2.1" yaml: dependency: transitive description: @@ -610,4 +634,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.7.0-0 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + flutter: ">=3.24.0" diff --git a/extras/log_file_client/pubspec.yaml b/extras/log_file_client/pubspec.yaml index 140c61a8..4570e42e 100644 --- a/extras/log_file_client/pubspec.yaml +++ b/extras/log_file_client/pubspec.yaml @@ -9,16 +9,19 @@ environment: dependencies: csv: ^6.0.0 cupertino_icons: ^1.0.6 + date_picker_plus: ^4.1.0 flutter: sdk: flutter html: ^0.15.4 http: ^1.2.1 intl: ^0.20.2 mockito: ^5.4.4 + popover: ^0.3.1 skeletonizer: ^1.4.3 syncfusion_flutter_charts: ^28.1.35 test: ^1.24.9 web: ^1.1.0 + wheel_picker: ^0.2.1 dev_dependencies: flutter_lints: ^5.0.0 diff --git a/extras/log_file_client/test/main_test.dart b/extras/log_file_client/test/main_test.dart index ff4266bf..05f03263 100644 --- a/extras/log_file_client/test/main_test.dart +++ b/extras/log_file_client/test/main_test.dart @@ -1,12 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:log_file_client/components/chart_series_selector.dart'; import 'package:log_file_client/components/graph_view.dart'; import 'package:log_file_client/components/project_card.dart'; import 'package:log_file_client/components/table_view.dart'; import 'package:log_file_client/components/tank_card.dart'; import 'package:log_file_client/components/tank_thumbnail.dart'; -import 'package:log_file_client/components/toggle_button.dart'; +import 'package:log_file_client/components/time_range_selector.dart'; import 'package:log_file_client/main.dart'; +import 'package:log_file_client/pages/graph_page.dart'; import 'package:log_file_client/pages/home_page.dart'; import 'package:log_file_client/pages/project_page.dart'; import 'package:log_file_client/utils/http_client.dart'; @@ -134,7 +136,7 @@ void main() { await tester.pumpAndSettle(); // Verify that the graph page is displayed - expect(find.byType(GraphView), findsOneWidget); + expect(find.byType(GraphPage), findsOneWidget); }); testWidgets('TableView displays table with log data from file', @@ -206,12 +208,13 @@ void main() { }); testWidgets('GraphView widget test', (WidgetTester tester) async { - // Build GraphView widget + tester.view.physicalSize = const Size(1920, 1080); + tester.view.devicePixelRatio = 1.0; await tester.pumpWidget( MaterialApp( home: Scaffold( - body: GraphView( - filePath: 'sample_short.log', + body: GraphPage( + log: Log('sample_short.log', 'sample_short.log'), httpClient: HttpClientTest(), ), ), @@ -224,7 +227,8 @@ void main() { // Wait for the FutureBuilder to complete await tester.pumpAndSettle(); - // Verify that the SfCartesianChart is rendered + // Verify that the graph is rendered + expect(find.byType(GraphView), findsOneWidget); expect(find.byType(SfCartesianChart), findsOneWidget); // Verify that the chart contains the correct line series for temperature and pH @@ -240,26 +244,25 @@ void main() { ); }); - testWidgets('ToggleButton widget test', (WidgetTester tester) async { - // Build GraphView widget + testWidgets('ChartSeriesSelector widget test', (WidgetTester tester) async { + tester.view.physicalSize = const Size(1920, 1080); + tester.view.devicePixelRatio = 1.0; await tester.pumpWidget( MaterialApp( home: Scaffold( - body: GraphView( - filePath: 'sample_short.log', + body: GraphPage( + log: Log('sample_short.log', 'sample_short.log'), httpClient: HttpClientTest(), ), ), ), ); - // Verify that ToggleButton is shown while loading - expect(find.byType(ToggleButton), findsOneWidget); - expect(find.byType(CircularProgressIndicator), findsOneWidget); - + // Verify that ChartSeriesSelector is shown await tester.pumpAndSettle(); + expect(find.byType(ChartSeriesSelector), findsOneWidget); - // Check that ToggleButton removes line series + // Check that ChartSeriesSelector removes line series expect( find.byWidgetPredicate( (widget) => widget is LineSeries && widget.initialIsVisible, @@ -267,24 +270,79 @@ void main() { findsNWidgets(4), ); - await tester.tap(find.byKey(const Key('pH'))); + await tester.tap(find.text('pH')); await tester.pumpAndSettle(); expect( find.byWidgetPredicate( - (widget) => widget is LineSeries && widget.initialIsVisible, + (widget) => widget is LineSeries && widget.color != Colors.transparent, ), findsNWidgets(2), ); - // Check that ToggleButton adds line series - await tester.tap(find.byKey(const Key('pH'))); + // Check that ChartSeriesSelector adds line series + await tester.tap(find.text('pH')); await tester.pumpAndSettle(); expect( find.byWidgetPredicate( - (widget) => widget is LineSeries && widget.initialIsVisible, + (widget) => widget is LineSeries && widget.color != Colors.transparent, ), findsNWidgets(4), ); }); + + testWidgets('TimeRangeSelector widget test', (WidgetTester tester) async { + tester.view.physicalSize = const Size(1920, 1080); + tester.view.devicePixelRatio = 1.0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: GraphPage( + log: Log('sample_long.log', 'sample_long.log'), + httpClient: HttpClientTest(), + ), + ), + ), + ); + + // Verify that TimeRangeSelector is shown + await tester.pumpAndSettle(); + expect(find.byType(TimeRangeSelector), findsOneWidget); + + // Check that 24H (or max) is shown by default + await checkOption(tester, '24H', 1440, false); + + // Check that selecting 6H shows 6H of data + await checkOption(tester, '6H', 360, true); + + // Check that selecting Max shows all data + await checkOption(tester, 'Max', 576, true); + }); +} + +Future checkOption( + WidgetTester tester, + String text, + int length, + bool exact, +) async { + // Tap selector + await tester.tap(find.text(text)); + await tester.pumpAndSettle(); + + // Text is selected + final selectedFinder = find.text(text); + expect( + tester.widget(selectedFinder).style!.color, + equals(Colors.black), + ); + + // Data points are displayed accurately + final graphFinder = find.byType(SfCartesianChart); + expect(graphFinder, findsOneWidget); + final graphWidget = tester.widget(graphFinder); + expect( + graphWidget.series.first.dataSource!.length, + exact ? length : lessThanOrEqualTo(length), + ); }