From bfd0c3755678ebc5c9ad68d18a1d5897685e46ff Mon Sep 17 00:00:00 2001 From: Anna Stefaniv Date: Tue, 25 Feb 2025 14:08:25 -0800 Subject: [PATCH 1/7] Renamed ToggleButton to more descriptive ChartSeriesSelector --- .../{toggle_button.dart => chart_series_selector.dart} | 8 ++++---- extras/log_file_client/lib/components/graph_view.dart | 8 ++++---- extras/log_file_client/test/main_test.dart | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) rename extras/log_file_client/lib/components/{toggle_button.dart => chart_series_selector.dart} (88%) diff --git a/extras/log_file_client/lib/components/toggle_button.dart b/extras/log_file_client/lib/components/chart_series_selector.dart similarity index 88% rename from extras/log_file_client/lib/components/toggle_button.dart rename to extras/log_file_client/lib/components/chart_series_selector.dart index 491bf395..86a41b0d 100644 --- a/extras/log_file_client/lib/components/toggle_button.dart +++ b/extras/log_file_client/lib/components/chart_series_selector.dart @@ -1,14 +1,14 @@ import 'package:flutter/material.dart'; -class ToggleButton extends StatefulWidget { - const ToggleButton({required this.onPressed, super.key}); +class ChartSeriesSelector extends StatefulWidget { + const ChartSeriesSelector({required this.onPressed, super.key}); final void Function(int index) onPressed; @override - State createState() => _ToggleButtonState(); + State createState() => _ChartSeriesSelectorState(); } -class _ToggleButtonState extends State { +class _ChartSeriesSelectorState extends State { List buttonState = [true, true]; void toggleButton(int index) { diff --git a/extras/log_file_client/lib/components/graph_view.dart b/extras/log_file_client/lib/components/graph_view.dart index ec66ea50..5dba4162 100644 --- a/extras/log_file_client/lib/components/graph_view.dart +++ b/extras/log_file_client/lib/components/graph_view.dart @@ -1,5 +1,5 @@ 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/utils/http_client.dart'; import 'package:syncfusion_flutter_charts/charts.dart'; @@ -47,7 +47,7 @@ class _GraphViewState extends State { mainAxisAlignment: MainAxisAlignment.end, children: [ _graphBuilder(logData), - _toggleButton(), + _chartSeriesSelector(), ], ), ), @@ -77,11 +77,11 @@ class _GraphViewState extends State { ); } - Widget _toggleButton() { + Widget _chartSeriesSelector() { return Center( child: Padding( padding: const EdgeInsets.only(bottom: 8.0), - child: ToggleButton( + child: ChartSeriesSelector( onPressed: toggleSeriesView, ), ), diff --git a/extras/log_file_client/test/main_test.dart b/extras/log_file_client/test/main_test.dart index ff4266bf..08186485 100644 --- a/extras/log_file_client/test/main_test.dart +++ b/extras/log_file_client/test/main_test.dart @@ -1,11 +1,11 @@ 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/main.dart'; import 'package:log_file_client/pages/home_page.dart'; import 'package:log_file_client/pages/project_page.dart'; @@ -254,7 +254,7 @@ void main() { ); // Verify that ToggleButton is shown while loading - expect(find.byType(ToggleButton), findsOneWidget); + expect(find.byType(ChartSeriesSelector), findsOneWidget); expect(find.byType(CircularProgressIndicator), findsOneWidget); await tester.pumpAndSettle(); From 7bdfc856016908d2ad1dc6c140317a2b8c4d2395 Mon Sep 17 00:00:00 2001 From: Anna Stefaniv Date: Tue, 25 Feb 2025 17:40:45 -0800 Subject: [PATCH 2/7] Added time range selector --- .../lib/components/chart_series_selector.dart | 20 ++-- .../lib/components/graph_view.dart | 86 +++++++++++++++--- .../lib/components/time_range_selector.dart | 91 +++++++++++++++++++ 3 files changed, 172 insertions(+), 25 deletions(-) create mode 100644 extras/log_file_client/lib/components/time_range_selector.dart diff --git a/extras/log_file_client/lib/components/chart_series_selector.dart b/extras/log_file_client/lib/components/chart_series_selector.dart index 86a41b0d..6e4e306d 100644 --- a/extras/log_file_client/lib/components/chart_series_selector.dart +++ b/extras/log_file_client/lib/components/chart_series_selector.dart @@ -21,15 +21,15 @@ class _ChartSeriesSelectorState extends State { @override Widget build(BuildContext context) { return Container( + padding: const EdgeInsets.all(4), decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - color: Colors.grey[500], + borderRadius: BorderRadius.circular(8), + // color: Colors.grey[500], ), - padding: const EdgeInsets.all(0.5), child: SizedBox( - width: 148, child: Row( mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, children: [ _halfButton('pH', Colors.green, 0), _halfButton('temp', Colors.blue, 1), @@ -59,12 +59,12 @@ class _ChartSeriesSelectorState extends State { RoundedRectangleBorder( borderRadius: index == 0 ? BorderRadius.only( - topLeft: Radius.circular(10), - bottomLeft: Radius.circular(10), + topLeft: Radius.circular(8), + bottomLeft: Radius.circular(8), ) : BorderRadius.only( - topRight: Radius.circular(10), - bottomRight: Radius.circular(10), + topRight: Radius.circular(8), + bottomRight: Radius.circular(8), ), ), ), @@ -73,8 +73,8 @@ class _ChartSeriesSelectorState extends State { Widget _buttonText(String text, Color color, int index) { return SizedBox( - width: 50, - height: 40, + width: 40, + // height: 40, child: Center( child: Text( text, diff --git a/extras/log_file_client/lib/components/graph_view.dart b/extras/log_file_client/lib/components/graph_view.dart index 5dba4162..f7fbaae9 100644 --- a/extras/log_file_client/lib/components/graph_view.dart +++ b/extras/log_file_client/lib/components/graph_view.dart @@ -1,5 +1,8 @@ +import 'dart:math'; + import 'package:flutter/material.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'; @@ -20,6 +23,7 @@ class GraphView extends StatefulWidget { class _GraphViewState extends State { bool _showPH = true; bool _showTemp = true; + int _timeRange = 1440; // 1 day Future> getLogData() async { final table = await widget.httpClient.getLogData(widget.filePath); @@ -36,6 +40,22 @@ class _GraphViewState extends State { }); } + void toggleTimeRange(int index) { + final ranges = [ + 360, // 6 hours + 720, // 12 hours + 1440, // 1 day + 4320, // 3 days + 10080, // 7 days + 43200, // 30 days + 525600, // 365 days + ]; + + setState(() { + _timeRange = ranges[index]; + }); + } + @override Widget build(BuildContext context) { // ignore: discarded_futures @@ -44,16 +64,61 @@ class _GraphViewState extends State { return Scaffold( body: Center( child: Column( - mainAxisAlignment: MainAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.start, children: [ + _topRow(logData), _graphBuilder(logData), - _chartSeriesSelector(), ], ), ), ); } + Padding _topRow( + Future> logData, + ) { + return Padding( + padding: const EdgeInsets.only(left: 70, right: 70, top: 15, bottom: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _titleBuilder(logData), + Row( + children: [ + ChartSeriesSelector(onPressed: toggleSeriesView), + const SizedBox(width: 10), + TimeRangeSelector(onSelected: toggleTimeRange), + ], + ), + ], + ), + ); + } + + FutureBuilder _titleBuilder(Future> logData) { + return FutureBuilder>( + future: logData, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return SizedBox(); + } else if (snapshot.hasError) { + return Text('Error: ${snapshot.error}'); + } else if (!snapshot.hasData || snapshot.data!.isEmpty) { + return const Text('No data found'); + } else { + final logData = snapshot.data!; + return Text( + 'Tank ID: ${logData.first.tankid}', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 20, + ), + ); + } + }, + ); + } + FutureBuilder _graphBuilder( Future> logData, ) { @@ -70,24 +135,16 @@ class _GraphViewState extends State { } else if (!snapshot.hasData || snapshot.data!.isEmpty) { return const Center(child: Text('No data found')); } else { - final logData = snapshot.data!; + final logData = snapshot.data!.sublist( + max(snapshot.data!.length - _timeRange, 0), + snapshot.data!.length, + ); return _graph(logData); } }, ); } - Widget _chartSeriesSelector() { - return Center( - child: Padding( - padding: const EdgeInsets.only(bottom: 8.0), - child: ChartSeriesSelector( - onPressed: toggleSeriesView, - ), - ), - ); - } - Widget _graph(List logData) { final trackballBehavior = TrackballBehavior( enable: true, @@ -107,7 +164,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'), 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..05e22753 --- /dev/null +++ b/extras/log_file_client/lib/components/time_range_selector.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; + +class TimeRangeSelector extends StatefulWidget { + const TimeRangeSelector({required this.onSelected, super.key}); + final Function(int) onSelected; + + @override + State createState() => _TimeRangeSelectorState(); +} + +class _TimeRangeSelectorState extends State { + final List _isSelected = [ + false, + false, + true, + false, + false, + false, + false, + false, + ]; + + void onPressed(int index) { + setState(() { + _isSelected.fillRange(0, _isSelected.length, false); + _isSelected[index] = true; + widget.onSelected(index); + }); + } + + @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: GestureDetector( + onTap: () => onPressed(index), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: active ? Colors.white : Colors.grey[100], + borderRadius: BorderRadius.circular(8), + ), + child: Text( + text, + style: TextStyle(color: active ? Colors.black : Colors.grey[600]), + ), + ), + ), + ); + } +} From 05c78bc36b4ba8e40dfac236bc830858c54383f4 Mon Sep 17 00:00:00 2001 From: Anna Stefaniv Date: Wed, 26 Feb 2025 11:21:53 -0800 Subject: [PATCH 3/7] Tests for TimeRangeSelector --- .../lib/utils/sample_data.dart | 1 - extras/log_file_client/test/main_test.dart | 59 +++++++++++++++++-- 2 files changed, 55 insertions(+), 5 deletions(-) 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/test/main_test.dart b/extras/log_file_client/test/main_test.dart index 08186485..93fe11cd 100644 --- a/extras/log_file_client/test/main_test.dart +++ b/extras/log_file_client/test/main_test.dart @@ -6,6 +6,7 @@ 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/time_range_selector.dart'; import 'package:log_file_client/main.dart'; import 'package:log_file_client/pages/home_page.dart'; import 'package:log_file_client/pages/project_page.dart'; @@ -240,7 +241,7 @@ void main() { ); }); - testWidgets('ToggleButton widget test', (WidgetTester tester) async { + testWidgets('ChartSeriesSelector widget test', (WidgetTester tester) async { // Build GraphView widget await tester.pumpWidget( MaterialApp( @@ -253,13 +254,13 @@ void main() { ), ); - // Verify that ToggleButton is shown while loading + // Verify that ChartSeriesSelector is shown while loading expect(find.byType(ChartSeriesSelector), findsOneWidget); expect(find.byType(CircularProgressIndicator), findsOneWidget); await tester.pumpAndSettle(); - // Check that ToggleButton removes line series + // Check that ChartSeriesSelector removes line series expect( find.byWidgetPredicate( (widget) => widget is LineSeries && widget.initialIsVisible, @@ -277,7 +278,7 @@ void main() { findsNWidgets(2), ); - // Check that ToggleButton adds line series + // Check that ChartSeriesSelector adds line series await tester.tap(find.byKey(const Key('pH'))); await tester.pumpAndSettle(); expect( @@ -287,4 +288,54 @@ void main() { 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: GraphView( + filePath: 'sample_long.log', + httpClient: HttpClientTest(), + ), + ), + ), + ); + + // Verify that TimeRangeSelector is shown while loading + expect(find.byType(TimeRangeSelector), findsOneWidget); + expect(find.byType(CircularProgressIndicator), 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), + ); } From 4e124e4fce08bc2ec48b2b1a3a5e4952f45e67af Mon Sep 17 00:00:00 2001 From: Anna Stefaniv Date: Wed, 26 Feb 2025 14:27:30 -0800 Subject: [PATCH 4/7] Redesigned pH/temp toggle --- .../lib/components/chart_series_selector.dart | 78 ++++++++----------- .../lib/components/time_range_selector.dart | 11 ++- 2 files changed, 42 insertions(+), 47 deletions(-) diff --git a/extras/log_file_client/lib/components/chart_series_selector.dart b/extras/log_file_client/lib/components/chart_series_selector.dart index 6e4e306d..e1625c92 100644 --- a/extras/log_file_client/lib/components/chart_series_selector.dart +++ b/extras/log_file_client/lib/components/chart_series_selector.dart @@ -2,18 +2,18 @@ import 'package:flutter/material.dart'; class ChartSeriesSelector extends StatefulWidget { const ChartSeriesSelector({required this.onPressed, super.key}); - final void Function(int index) onPressed; + final void Function(int) onPressed; @override State createState() => _ChartSeriesSelectorState(); } class _ChartSeriesSelectorState extends State { - List buttonState = [true, true]; + final List _buttonState = [true, true]; void toggleButton(int index) { setState(() { - buttonState[index] = !buttonState[index]; + _buttonState[index] = !_buttonState[index]; widget.onPressed(index); }); } @@ -23,62 +23,48 @@ class _ChartSeriesSelectorState extends State { return Container( padding: const EdgeInsets.all(4), decoration: BoxDecoration( + color: Colors.grey[100], borderRadius: BorderRadius.circular(8), - // color: Colors.grey[500], ), child: SizedBox( child: Row( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ - _halfButton('pH', Colors.green, 0), - _halfButton('temp', Colors.blue, 1), + _chartSeriesOption('pH', Colors.green, 0), + SizedBox(width: 4), + _chartSeriesOption('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(8), - bottomLeft: Radius.circular(8), - ) - : BorderRadius.only( - topRight: Radius.circular(8), - bottomRight: Radius.circular(8), - ), - ), - ), - ); - } - - Widget _buttonText(String text, Color color, int index) { - return SizedBox( - width: 40, - // height: 40, - child: Center( - child: Text( - text, - style: TextStyle(color: buttonState[index] ? Colors.white : color), + 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/time_range_selector.dart b/extras/log_file_client/lib/components/time_range_selector.dart index 05e22753..70982aa3 100644 --- a/extras/log_file_client/lib/components/time_range_selector.dart +++ b/extras/log_file_client/lib/components/time_range_selector.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; class TimeRangeSelector extends StatefulWidget { const TimeRangeSelector({required this.onSelected, super.key}); - final Function(int) onSelected; + final void Function(int) onSelected; @override State createState() => _TimeRangeSelectorState(); @@ -79,6 +79,15 @@ class _TimeRangeSelectorState extends State { 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, From 48eece101924e25d4394eb365bed414b6efdf2a4 Mon Sep 17 00:00:00 2001 From: Anna Stefaniv Date: Fri, 28 Feb 2025 11:11:54 -0800 Subject: [PATCH 5/7] Fetching log data in GraphPage instead - prevents re-fetching every time state of GraphView is changed. --- .../lib/components/chart_series_selector.dart | 2 +- .../lib/components/graph_view.dart | 144 ++++++------------ .../log_file_client/lib/pages/graph_page.dart | 31 +++- .../lib/utils/http_client.dart | 6 +- extras/log_file_client/test/main_test.dart | 49 +++--- 5 files changed, 111 insertions(+), 121 deletions(-) diff --git a/extras/log_file_client/lib/components/chart_series_selector.dart b/extras/log_file_client/lib/components/chart_series_selector.dart index e1625c92..a0291185 100644 --- a/extras/log_file_client/lib/components/chart_series_selector.dart +++ b/extras/log_file_client/lib/components/chart_series_selector.dart @@ -40,7 +40,7 @@ class _ChartSeriesSelectorState extends State { ); } - Widget _chartSeriesOption(String text, Color color, int index) { + Widget _chartSeriesOption(String text, Color color, int index) { final active = _buttonState[index]; return MouseRegion( cursor: SystemMouseCursors.click, diff --git a/extras/log_file_client/lib/components/graph_view.dart b/extras/log_file_client/lib/components/graph_view.dart index f7fbaae9..fcd64c3b 100644 --- a/extras/log_file_client/lib/components/graph_view.dart +++ b/extras/log_file_client/lib/components/graph_view.dart @@ -8,13 +8,11 @@ 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(); @@ -23,11 +21,16 @@ class GraphView extends StatefulWidget { class _GraphViewState extends State { bool _showPH = true; bool _showTemp = true; - int _timeRange = 1440; // 1 day + List _displayedTimeRange = [1440, 0]; // 1 day + late final DateTimeRange avaliableTimeRange; - 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) { @@ -48,41 +51,47 @@ class _GraphViewState extends State { 4320, // 3 days 10080, // 7 days 43200, // 30 days - 525600, // 365 days + 9999999, // Max ]; setState(() { - _timeRange = ranges[index]; + _displayedTimeRange = [ranges[index], 0]; }); } @override Widget build(BuildContext context) { - // ignore: discarded_futures - final Future> logData = getLogData(); - return Scaffold( body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.start, children: [ - _topRow(logData), - _graphBuilder(logData), + _topRow(widget.logData), + _graph( + widget.logData.sublist( + max(widget.logData.length - _displayedTimeRange[0], 0), + widget.logData.length - _displayedTimeRange[1], + ), + ), ], ), ), ); } - Padding _topRow( - Future> logData, - ) { + Widget _topRow(List logData) { return Padding( padding: const EdgeInsets.only(left: 70, right: 70, top: 15, bottom: 10), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - _titleBuilder(logData), + Text( + 'Tank ID: ${logData.first.tankid}', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 20, + ), + ), Row( children: [ ChartSeriesSelector(onPressed: toggleSeriesView), @@ -95,56 +104,6 @@ class _GraphViewState extends State { ); } - FutureBuilder _titleBuilder(Future> logData) { - return FutureBuilder>( - future: logData, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return SizedBox(); - } else if (snapshot.hasError) { - return Text('Error: ${snapshot.error}'); - } else if (!snapshot.hasData || snapshot.data!.isEmpty) { - return const Text('No data found'); - } else { - final logData = snapshot.data!; - return Text( - 'Tank ID: ${logData.first.tankid}', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 20, - ), - ); - } - }, - ); - } - - 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!.sublist( - max(snapshot.data!.length - _timeRange, 0), - snapshot.data!.length, - ); - return _graph(logData); - } - }, - ); - } - Widget _graph(List logData) { final trackballBehavior = TrackballBehavior( enable: true, @@ -173,13 +132,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, @@ -191,48 +151,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/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/test/main_test.dart b/extras/log_file_client/test/main_test.dart index 93fe11cd..05f03263 100644 --- a/extras/log_file_client/test/main_test.dart +++ b/extras/log_file_client/test/main_test.dart @@ -8,6 +8,7 @@ import 'package:log_file_client/components/tank_card.dart'; import 'package:log_file_client/components/tank_thumbnail.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'; @@ -135,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', @@ -207,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(), ), ), @@ -225,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 @@ -242,23 +245,22 @@ void main() { }); testWidgets('ChartSeriesSelector 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(), ), ), ), ); - // Verify that ChartSeriesSelector is shown while loading - expect(find.byType(ChartSeriesSelector), findsOneWidget); - expect(find.byType(CircularProgressIndicator), findsOneWidget); - + // Verify that ChartSeriesSelector is shown await tester.pumpAndSettle(); + expect(find.byType(ChartSeriesSelector), findsOneWidget); // Check that ChartSeriesSelector removes line series expect( @@ -268,22 +270,22 @@ 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 ChartSeriesSelector adds line series - 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(4), ); @@ -295,17 +297,17 @@ void main() { await tester.pumpWidget( MaterialApp( home: Scaffold( - body: GraphView( - filePath: 'sample_long.log', + body: GraphPage( + log: Log('sample_long.log', 'sample_long.log'), httpClient: HttpClientTest(), ), ), ), ); - // Verify that TimeRangeSelector is shown while loading + // Verify that TimeRangeSelector is shown + await tester.pumpAndSettle(); expect(find.byType(TimeRangeSelector), findsOneWidget); - expect(find.byType(CircularProgressIndicator), findsOneWidget); // Check that 24H (or max) is shown by default await checkOption(tester, '24H', 1440, false); @@ -318,7 +320,12 @@ void main() { }); } -Future checkOption(WidgetTester tester, String text, int length, bool exact) async { +Future checkOption( + WidgetTester tester, + String text, + int length, + bool exact, +) async { // Tap selector await tester.tap(find.text(text)); await tester.pumpAndSettle(); From 51569e55c58c944f1517eec10fe353e874904253 Mon Sep 17 00:00:00 2001 From: Anna Stefaniv Date: Fri, 28 Feb 2025 14:09:46 -0800 Subject: [PATCH 6/7] Custom time range picker --- .../components/custom_time_range_picker.dart | 183 ++++++++++++++++++ .../lib/components/graph_view.dart | 29 ++- .../lib/components/time_range_selector.dart | 94 ++++++--- .../lib/components/wheel_picker.dart | 174 +++++++++++++++++ extras/log_file_client/pubspec.lock | 26 ++- extras/log_file_client/pubspec.yaml | 3 + 6 files changed, 472 insertions(+), 37 deletions(-) create mode 100644 extras/log_file_client/lib/components/custom_time_range_picker.dart create mode 100644 extras/log_file_client/lib/components/wheel_picker.dart 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..0061d60e --- /dev/null +++ b/extras/log_file_client/lib/components/custom_time_range_picker.dart @@ -0,0 +1,183 @@ +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 { + DateTime? minTime; + 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() { + 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 fcd64c3b..7516af2e 100644 --- a/extras/log_file_client/lib/components/graph_view.dart +++ b/extras/log_file_client/lib/components/graph_view.dart @@ -21,8 +21,11 @@ class GraphView extends StatefulWidget { class _GraphViewState extends State { bool _showPH = true; bool _showTemp = true; - List _displayedTimeRange = [1440, 0]; // 1 day late final DateTimeRange avaliableTimeRange; + late List _displayedTimeRange = [ + max(widget.logData.length - 1400, 0), + widget.logData.length, + ]; // 1 day @override void initState() { @@ -43,7 +46,7 @@ class _GraphViewState extends State { }); } - void toggleTimeRange(int index) { + void toggleTimeRange(int index, DateTimeRange? customRange) { final ranges = [ 360, // 6 hours 720, // 12 hours @@ -55,7 +58,18 @@ class _GraphViewState extends State { ]; setState(() { - _displayedTimeRange = [ranges[index], 0]; + 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]; + } }); } @@ -69,8 +83,8 @@ class _GraphViewState extends State { _topRow(widget.logData), _graph( widget.logData.sublist( - max(widget.logData.length - _displayedTimeRange[0], 0), - widget.logData.length - _displayedTimeRange[1], + _displayedTimeRange[0], + _displayedTimeRange[1], ), ), ], @@ -96,7 +110,10 @@ class _GraphViewState extends State { children: [ ChartSeriesSelector(onPressed: toggleSeriesView), const SizedBox(width: 10), - TimeRangeSelector(onSelected: toggleTimeRange), + TimeRangeSelector( + onSelected: toggleTimeRange, + avaliableTimeRange: avaliableTimeRange, + ), ], ), ], diff --git a/extras/log_file_client/lib/components/time_range_selector.dart b/extras/log_file_client/lib/components/time_range_selector.dart index 70982aa3..26fbfca2 100644 --- a/extras/log_file_client/lib/components/time_range_selector.dart +++ b/extras/log_file_client/lib/components/time_range_selector.dart @@ -1,8 +1,17 @@ +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, super.key}); - final void Function(int) onSelected; + const TimeRangeSelector({ + required this.onSelected, + required this.avaliableTimeRange, + super.key, + }); + final void Function(int, DateTimeRange?) onSelected; + final DateTimeRange avaliableTimeRange; @override State createState() => _TimeRangeSelectorState(); @@ -20,12 +29,32 @@ class _TimeRangeSelectorState extends State { false, ]; - void onPressed(int index) { - setState(() { - _isSelected.fillRange(0, _isSelected.length, false); - _isSelected[index] = true; - widget.onSelected(index); - }); + 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 @@ -72,28 +101,33 @@ class _TimeRangeSelectorState extends State { final active = _isSelected[index]; return MouseRegion( cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () => onPressed(index), - 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]), - ), - ), + child: Builder( + builder: (context) { + return GestureDetector( + onTap: () => 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/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/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 From df6b5cecd39df917a63efaa51086fb8f3d74cbb0 Mon Sep 17 00:00:00 2001 From: Anna Stefaniv Date: Sat, 1 Mar 2025 19:59:16 -0800 Subject: [PATCH 7/7] Ensured custom time range cannot exceed avaliable time range --- .../components/custom_time_range_picker.dart | 46 +++++++++++-------- .../lib/components/graph_view.dart | 2 +- .../lib/components/time_range_selector.dart | 2 +- 3 files changed, 29 insertions(+), 21 deletions(-) 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 index 0061d60e..2dedcc7a 100644 --- a/extras/log_file_client/lib/components/custom_time_range_picker.dart +++ b/extras/log_file_client/lib/components/custom_time_range_picker.dart @@ -20,8 +20,8 @@ class CustomTimeRangePicker extends StatefulWidget { } class _CustomTimeRangePickerState extends State { - DateTime? minTime; - DateTime? maxTime; + late DateTime minTime; + late DateTime maxTime; @override void initState() { @@ -35,32 +35,32 @@ class _CustomTimeRangePickerState extends State { value.start.year, value.start.month, value.start.day, - minTime!.hour, - minTime!.minute, + minTime.hour, + minTime.minute, ); maxTime = DateTime( value.end.year, value.end.month, value.end.day, - maxTime!.hour, - maxTime!.minute, + maxTime.hour, + maxTime.minute, ); } void onTimeSelected(TimeOfDay time, RangeEndpointType type) { if (type == RangeEndpointType.start) { minTime = DateTime( - minTime!.year, - minTime!.month, - minTime!.day, + minTime.year, + minTime.month, + minTime.day, time.hour, time.minute, ); } else if (type == RangeEndpointType.end) { maxTime = DateTime( - maxTime!.year, - maxTime!.month, - maxTime!.day, + maxTime.year, + maxTime.month, + maxTime.day, time.hour, time.minute, ); @@ -68,7 +68,15 @@ class _CustomTimeRangePickerState extends State { } void onApplied() { - widget.onApplied(DateTimeRange(start: minTime!, end: maxTime!)); + // 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 @@ -133,8 +141,8 @@ class _CustomTimeRangePickerState extends State { Widget _dateDisplay(RangeEndpointType type) { return Text( type == RangeEndpointType.start - ? DateFormat.yMMMd('en_US').format(minTime!) - : DateFormat.yMMMd('en_US').format(maxTime!), + ? DateFormat.yMMMd('en_US').format(minTime) + : DateFormat.yMMMd('en_US').format(maxTime), style: const TextStyle(fontSize: 16), ); } @@ -145,8 +153,8 @@ class _CustomTimeRangePickerState extends State { height: 300, child: RangeDatePicker( centerLeadingDate: true, - minDate: minTime!, - maxDate: maxTime!, + minDate: minTime, + maxDate: maxTime, onRangeSelected: onDatesSelected, splashColor: Colors.transparent, daysOfTheWeekTextStyle: TextStyle( @@ -165,8 +173,8 @@ class _CustomTimeRangePickerState extends State { Widget _timePicker(RangeEndpointType type) { final TimeOfDay selectedTime = type == RangeEndpointType.start - ? TimeOfDay(hour: minTime!.hour, minute: minTime!.minute) - : TimeOfDay(hour: maxTime!.hour, minute: maxTime!.minute); + ? TimeOfDay(hour: minTime.hour, minute: minTime.minute) + : TimeOfDay(hour: maxTime.hour, minute: maxTime.minute); return SizedBox( height: 100, diff --git a/extras/log_file_client/lib/components/graph_view.dart b/extras/log_file_client/lib/components/graph_view.dart index 7516af2e..6ceb2fb0 100644 --- a/extras/log_file_client/lib/components/graph_view.dart +++ b/extras/log_file_client/lib/components/graph_view.dart @@ -66,7 +66,7 @@ class _GraphViewState extends State { } else if (index == 7) { final int endOffset = avaliableTimeRange.end.difference(customRange!.end).inMinutes; - final int endIndex = widget.logData.length - endOffset; + final int endIndex = widget.logData.length - endOffset; final int startIndex = endIndex - customRange.duration.inMinutes; _displayedTimeRange = [startIndex, endIndex]; } diff --git a/extras/log_file_client/lib/components/time_range_selector.dart b/extras/log_file_client/lib/components/time_range_selector.dart index 26fbfca2..0d81ceaf 100644 --- a/extras/log_file_client/lib/components/time_range_selector.dart +++ b/extras/log_file_client/lib/components/time_range_selector.dart @@ -104,7 +104,7 @@ class _TimeRangeSelectorState extends State { child: Builder( builder: (context) { return GestureDetector( - onTap: () => onPressed(index, context), + onTap: () => unawaited(onPressed(index, context)), child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration(