diff --git a/extras/log_file_client/lib/components/advanced_options_dropdown.dart b/extras/log_file_client/lib/components/advanced_options_dropdown.dart new file mode 100644 index 00000000..8cd404c5 --- /dev/null +++ b/extras/log_file_client/lib/components/advanced_options_dropdown.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:popover/popover.dart'; + +class AdvancedOptionsDropdown extends StatelessWidget { + const AdvancedOptionsDropdown({ + required this.tempController, + required this.phController, + super.key, + }); + + final TextEditingController tempController; + final TextEditingController phController; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(right: 16.0), + child: Builder( + builder: (buttonContext) { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () async { + await showPopover( + context: buttonContext, + bodyBuilder: _popoverBuilder, + direction: PopoverDirection.bottom, + width: 160, + arrowHeight: 0, + backgroundColor: Theme.of(context).cardColor, + radius: 8, + ); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Advanced Options', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(width: 4), + const Icon(Icons.arrow_drop_down), + ], + ), + ), + ); + }, + ), + ); + } + + Widget _popoverBuilder(context) => Padding( + padding: const EdgeInsets.all(12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _decimalTextField(tempController, 'Temp Deviation'), + const SizedBox(height: 8), + _decimalTextField(phController, 'pH Deviation'), + ], + ), + ); + + TextField _decimalTextField( + TextEditingController controller, + String label, + ) { + return TextField( + controller: controller, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration(labelText: label), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')), + ], + ); + } +} diff --git a/extras/log_file_client/lib/components/tank_card.dart b/extras/log_file_client/lib/components/tank_card.dart index 1ef79eeb..2821cfdf 100644 --- a/extras/log_file_client/lib/components/tank_card.dart +++ b/extras/log_file_client/lib/components/tank_card.dart @@ -13,12 +13,16 @@ class TankCard extends StatelessWidget { required this.log, required this.onTap, required this.httpClient, + required this.tempDeviation, + required this.pHDeviation, super.key, }); final Log log; final void Function() onTap; final HttpClient httpClient; + final double tempDeviation; + final double pHDeviation; Future getTankSnapshot() async { final snapshot = await httpClient.getTankSnapshot(log); @@ -107,7 +111,11 @@ class TankCard extends StatelessWidget { top: Radius.circular(20), ), child: snapshot != null - ? TankThumbnail(snapshot: snapshot.data!) + ? TankThumbnail( + snapshot: snapshot.data!, + tempDeviation: tempDeviation, + pHDeviation: pHDeviation, + ) : Container( decoration: BoxDecoration( image: DecorationImage( diff --git a/extras/log_file_client/lib/components/tank_thumbnail.dart b/extras/log_file_client/lib/components/tank_thumbnail.dart index 8c1b5965..d1a45e07 100644 --- a/extras/log_file_client/lib/components/tank_thumbnail.dart +++ b/extras/log_file_client/lib/components/tank_thumbnail.dart @@ -3,11 +3,20 @@ import 'package:log_file_client/utils/http_client.dart'; import 'package:syncfusion_flutter_charts/charts.dart'; class TankThumbnail extends StatelessWidget { - TankThumbnail({required this.snapshot, DateTime? now, super.key}) - : now = now ?? DateTime.now(); + TankThumbnail({ + required this.snapshot, + double? tempDeviation, + double? pHDeviation, + DateTime? now, + super.key, + }) : now = now ?? DateTime.now(), + tempDeviation = tempDeviation ?? 0.5, + pHDeviation = pHDeviation ?? 0.5; final TankSnapshot snapshot; final DateTime now; + final double tempDeviation; + final double pHDeviation; @override Widget build(BuildContext context) { @@ -32,6 +41,7 @@ class TankThumbnail extends StatelessWidget { Widget _graph(series, String axis) { final double setpoint = axis == 'pHAxis' ? snapshot.pHSetpoint! : snapshot.temperatureSetpoint!; + final double deviation = axis == 'pHAxis' ? pHDeviation : tempDeviation; return Expanded( child: SfCartesianChart( @@ -46,8 +56,8 @@ class TankThumbnail extends StatelessWidget { ), primaryYAxis: NumericAxis( name: axis, - minimum: setpoint - 0.5, - maximum: setpoint + 0.5, + minimum: setpoint - deviation, + maximum: setpoint + deviation, anchorRangeToVisiblePoints: false, labelStyle: TextStyle(color: Colors.grey.shade700), ), diff --git a/extras/log_file_client/lib/pages/project_page.dart b/extras/log_file_client/lib/pages/project_page.dart index 744ffab7..1539bb36 100644 --- a/extras/log_file_client/lib/pages/project_page.dart +++ b/extras/log_file_client/lib/pages/project_page.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:log_file_client/components/advanced_options_dropdown.dart'; import 'package:log_file_client/components/page_header.dart'; import 'package:log_file_client/components/tank_card.dart'; import 'package:log_file_client/pages/graph_page.dart'; @@ -34,6 +35,34 @@ class _ProjectPageState extends State { ); } + final _tempDeviationController = TextEditingController(text: '0.5'); + final _pHDeviationController = TextEditingController(text: '0.5'); + double _tempDeviation = 0.5; + double _pHDeviation = 0.5; + + @override + void initState() { + super.initState(); + _tempDeviationController.addListener(_onDeviationChanged); + _pHDeviationController.addListener(_onDeviationChanged); + } + + void _onDeviationChanged() { + setState(() { + _tempDeviation = double.tryParse(_tempDeviationController.text) ?? 0.5; + _pHDeviation = double.tryParse(_pHDeviationController.text) ?? 0.5; + }); + } + + @override + void dispose() { + _tempDeviationController.removeListener(_onDeviationChanged); + _pHDeviationController.removeListener(_onDeviationChanged); + _tempDeviationController.dispose(); + _pHDeviationController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final screenWidth = MediaQuery.of(context).size.width; @@ -48,6 +77,12 @@ class _ProjectPageState extends State { appBar: AppBar( backgroundColor: Theme.of(context).colorScheme.inversePrimary, title: const Text('Tank Monitor'), + actions: [ + AdvancedOptionsDropdown( + tempController: _tempDeviationController, + phController: _pHDeviationController, + ), + ], ), body: Center( child: Column( @@ -71,6 +106,8 @@ class _ProjectPageState extends State { return TankCard( log: widget.project.logs[index], httpClient: widget.httpClient, + tempDeviation: _tempDeviation, + pHDeviation: _pHDeviation, onTap: () => unawaited(openTankGraph(widget.project.logs[index])), ); }, diff --git a/extras/log_file_client/test/main_test.dart b/extras/log_file_client/test/main_test.dart index 90451f17..6fd502cc 100644 --- a/extras/log_file_client/test/main_test.dart +++ b/extras/log_file_client/test/main_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:log_file_client/components/advanced_options_dropdown.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'; @@ -99,6 +100,69 @@ void main() { expect(find.text('tank-70'), findsOneWidget); }); + testWidgets('Changing deviation controllers updates TankCard widgets', + (WidgetTester tester) async { + tester.view.physicalSize = const Size(1920, 1080); + tester.view.devicePixelRatio = 1.0; + await tester.pumpWidget( + MaterialApp( + home: ProjectPage( + project: Project('ProjectA', [ + Log('tank-24', 'ProjectA-tank-24.log'), + ]), + httpClient: HttpClientTest(), + ), + ), + ); + await tester.pumpAndSettle(); + + // Find the chart widgets and verify it built with default deviation values + SfCartesianChart phChart = + tester.widget(find.byType(SfCartesianChart).first); + SfCartesianChart tempChart = + tester.widget(find.byType(SfCartesianChart).last); + NumericAxis phAxis = phChart.primaryYAxis as NumericAxis; + NumericAxis tempAxis = tempChart.primaryYAxis as NumericAxis; + expect(phAxis.minimum, closeTo(6.25 - 0.5, 0.01)); + expect(phAxis.maximum, closeTo(6.25 + 0.5, 0.01)); + expect(tempAxis.minimum, closeTo(21.45 - 0.5, 0.01)); + expect(tempAxis.maximum, closeTo(21.45 + 0.5, 0.01)); + + // Click on the AdvancedOptionsDropdown to open it + await tester.tap(find.byType(AdvancedOptionsDropdown).first); + await tester.pumpAndSettle(); + + // Find the AdvancedOptionsDropdown text fields + final tempField = find.byWidgetPredicate( + (w) => + w is TextField && + w.decoration!.labelText!.toLowerCase().contains('temp deviation'), + ); + final phField = find.byWidgetPredicate( + (w) => + w is TextField && + w.decoration!.labelText!.toLowerCase().contains('ph deviation'), + ); + + expect(tempField, findsOneWidget); + expect(phField, findsOneWidget); + + // Enter new values in the controllers + await tester.enterText(phField, '2.5'); + await tester.enterText(tempField, '1.2'); + await tester.pumpAndSettle(); + + // Verify the chart rebuilt with new deviation values + phChart = tester.widget(find.byType(SfCartesianChart).first); + tempChart = tester.widget(find.byType(SfCartesianChart).last); + phAxis = phChart.primaryYAxis as NumericAxis; + tempAxis = tempChart.primaryYAxis as NumericAxis; + expect(phAxis.minimum, closeTo(6.25 - 2.5, 0.01)); + expect(phAxis.maximum, closeTo(6.25 + 2.5, 0.01)); + expect(tempAxis.minimum, closeTo(21.45 - 1.2, 0.01)); + expect(tempAxis.maximum, closeTo(21.45 + 1.2, 0.01)); + }); + testWidgets('TankCards have thumbnail graphs', (WidgetTester tester) async { // Build the ProjectPage widget await tester.pumpWidget(