From c36161b825b7c1dad172c2349930a19a3a3d1215 Mon Sep 17 00:00:00 2001 From: Naveed Jooma Date: Wed, 2 Aug 2023 15:33:02 -0400 Subject: [PATCH] [RSDK-4265] Board and Sensor Widgets (#80) --- example/viam_example_app/.gitignore | 1 + example/viam_example_app/lib/main.dart | 19 ++- .../viam_example_app/lib/screens/base.dart | 5 +- .../viam_example_app/lib/screens/board.dart | 75 +--------- .../viam_example_app/lib/screens/sensor.dart | 34 +---- example/viam_example_app/pubspec.yaml | 7 +- lib/widgets.dart | 2 + lib/widgets/button.dart | 99 +++++++++++--- lib/widgets/resources/base.dart | 4 +- lib/widgets/resources/board.dart | 101 ++++++++++++++ lib/widgets/resources/sensor.dart | 128 ++++++++++++++++++ pubspec.yaml | 1 + 12 files changed, 347 insertions(+), 129 deletions(-) create mode 100644 example/viam_example_app/.gitignore create mode 100644 lib/widgets/resources/board.dart create mode 100644 lib/widgets/resources/sensor.dart diff --git a/example/viam_example_app/.gitignore b/example/viam_example_app/.gitignore new file mode 100644 index 0000000000..03bd4129be --- /dev/null +++ b/example/viam_example_app/.gitignore @@ -0,0 +1 @@ +*.env diff --git a/example/viam_example_app/lib/main.dart b/example/viam_example_app/lib/main.dart index 451614eb4c..730212a967 100644 --- a/example/viam_example_app/lib/main.dart +++ b/example/viam_example_app/lib/main.dart @@ -1,5 +1,6 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:viam_example_app/screens/base.dart'; import 'package:viam_example_app/screens/board.dart'; @@ -10,7 +11,8 @@ import 'package:viam_example_app/screens/stream.dart'; import 'package:viam_sdk/viam_sdk.dart'; import 'package:viam_sdk/widgets.dart'; -void main() { +void main() async { + await dotenv.load(); runApp(const MyApp()); } @@ -92,8 +94,8 @@ class _MyHomePageState extends State { _loading = true; }); final robotFut = RobotClient.atAddress( - '', - RobotClientOptions.withLocationSecret(''), + dotenv.env['ROBOT_LOCATION'] ?? '', + RobotClientOptions.withLocationSecret(dotenv.env['LOCATION_SECRET'] ?? ''), ); robotFut.then((value) { @@ -138,7 +140,6 @@ class _MyHomePageState extends State { } if (rname.subtype == Base.subtype.resourceSubtype && _cameraName != null) { return BaseScreen( - resourceName: rname, base: Base.fromRobot(_robot, rname.name), cameras: _robot.resourceNames.where((e) => e.subtype == Camera.subtype.resourceSubtype).map((e) => Camera.fromRobot(_robot, e.name)), @@ -200,7 +201,15 @@ class _MyHomePageState extends State { ]) : _loading ? PlatformCircularProgressIndicator() - : ViamButton(onPressed: _login, text: 'Login', role: ViamButtonRole.inverse, style: ViamButtonStyle.filled) + : Column(children: [ + ViamButton( + onPressed: _login, + text: 'Login', + role: ViamButtonRole.inverse, + style: ViamButtonFillStyle.filled, + size: ViamButtonSizeClass.xl, + ) + ]) ], ), ), diff --git a/example/viam_example_app/lib/screens/base.dart b/example/viam_example_app/lib/screens/base.dart index 522311db91..d8846123b4 100644 --- a/example/viam_example_app/lib/screens/base.dart +++ b/example/viam_example_app/lib/screens/base.dart @@ -5,13 +5,10 @@ import 'package:viam_sdk/widgets.dart'; class BaseScreen extends StatelessWidget { final Base base; - final ResourceName resourceName; final Iterable cameras; final RobotClient robot; - // TODO change BaseScreen to accept camera ResourceName. - const BaseScreen({Key? key, required this.base, required this.resourceName, required this.cameras, required this.robot}) - : super(key: key); + const BaseScreen({Key? key, required this.base, required this.cameras, required this.robot}) : super(key: key); @override Widget build(BuildContext context) { diff --git a/example/viam_example_app/lib/screens/board.dart b/example/viam_example_app/lib/screens/board.dart index 58b3270221..3318062c1f 100644 --- a/example/viam_example_app/lib/screens/board.dart +++ b/example/viam_example_app/lib/screens/board.dart @@ -1,41 +1,19 @@ import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:viam_sdk/viam_sdk.dart'; +import 'package:viam_sdk/widgets/resources/board.dart'; -class BoardScreen extends StatefulWidget { +class BoardScreen extends StatelessWidget { final Board board; final ResourceName resourceName; const BoardScreen({Key? key, required this.board, required this.resourceName}) : super(key: key); @override - State createState() { - return _BoardScreenState(); - } -} - -class _BoardScreenState extends State { - String getPin = ''; - String setPin = ''; - bool high = false; - late BoardStatus status = const BoardStatus({}, {}); - - Future _fetchStatus() async { - status = await widget.board.status(); - setState(() {}); - } - - @override - void initState() { - super.initState(); - _fetchStatus(); - } - - @override - Widget build(BuildContext context) { + Widget build(Object context) { return PlatformScaffold( appBar: PlatformAppBar( - title: Text(widget.resourceName.name.toUpperCase()), + title: Text(resourceName.name.toUpperCase()), ), iosContentPadding: true, body: Center( @@ -43,52 +21,11 @@ class _BoardScreenState extends State { children: [ const SizedBox(height: 16), PlatformText( - '${widget.resourceName.namespace}:${widget.resourceName.type}:${widget.resourceName.subtype}/${widget.resourceName.name}', + '${resourceName.namespace}:${resourceName.type}:${resourceName.subtype}/${resourceName.name}', style: const TextStyle(fontWeight: FontWeight.w300), ), const SizedBox(height: 16), - PlatformText( - 'Analogs: ${status.analogs}', - style: const TextStyle(fontWeight: FontWeight.w300), - ), - const SizedBox(height: 16), - PlatformText( - 'Digital Interrupts: ${status.digitalInterrupts}', - style: const TextStyle(fontWeight: FontWeight.w300), - ), - const SizedBox(height: 16), - Text('GPIO', style: Theme.of(context).textTheme.headlineSmall), - Row( - children: [ - const Spacer(), - Expanded( - child: TextFormField( - onChanged: (value) => setPin = value, - decoration: const InputDecoration( - border: UnderlineInputBorder(), - labelText: 'Pin', - ), - ), - ), - const Spacer(), - DropdownButton( - value: high, - items: const [ - DropdownMenuItem(value: true, child: Text('High')), - DropdownMenuItem(value: false, child: Text('Low')), - ], - onChanged: ((value) => setState(() { - high = value!; - })), - ), - const Spacer() - ], - ), - const SizedBox(height: 16), - PlatformElevatedButton( - child: const Text('Set Pin State'), - onPressed: () => widget.board.setGpioState(setPin, high), - ) + ViamBoardWidget(board: board) ], ), ), diff --git a/example/viam_example_app/lib/screens/sensor.dart b/example/viam_example_app/lib/screens/sensor.dart index 605b60d566..353d312a75 100644 --- a/example/viam_example_app/lib/screens/sensor.dart +++ b/example/viam_example_app/lib/screens/sensor.dart @@ -1,36 +1,19 @@ import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:viam_sdk/viam_sdk.dart'; +import 'package:viam_sdk/widgets.dart'; -class SensorScreen extends StatefulWidget { +class SensorScreen extends StatelessWidget { final Sensor sensor; final ResourceName resourceName; const SensorScreen({Key? key, required this.sensor, required this.resourceName}) : super(key: key); - @override - State createState() { - return _SensorScreenState(); - } -} - -class _SensorScreenState extends State { - Map readings = {}; - - void _getReadings() { - final readingsFut = widget.sensor.readings(); - readingsFut.then((value) => setState( - () { - readings = value; - }, - )); - } - @override Widget build(BuildContext context) { return PlatformScaffold( appBar: PlatformAppBar( - title: Text(widget.resourceName.name.toUpperCase()), + title: Text(resourceName.name.toUpperCase()), ), iosContentPadding: true, body: Center( @@ -38,18 +21,11 @@ class _SensorScreenState extends State { children: [ const Padding(padding: EdgeInsets.symmetric(vertical: 8, horizontal: 0)), PlatformText( - '${widget.resourceName.namespace}:${widget.resourceName.type}:${widget.resourceName.subtype}/${widget.resourceName.name}', + '${resourceName.namespace}:${resourceName.type}:${resourceName.subtype}/${resourceName.name}', style: const TextStyle(fontWeight: FontWeight.w300), ), const Padding(padding: EdgeInsets.symmetric(vertical: 8, horizontal: 0)), - DataTable( - columns: const [DataColumn(label: Text('Reading')), DataColumn(label: Text('Value'))], - rows: readings.keys.map((e) => DataRow(cells: [DataCell(Text(e)), DataCell(Text(readings[e].toString()))])).toList()), - const Padding(padding: EdgeInsets.symmetric(vertical: 8, horizontal: 0)), - PlatformElevatedButton( - child: const Text('Get readings'), - onPressed: () => _getReadings(), - ) + ViamSensorWidget(sensor: sensor), ], ), ), diff --git a/example/viam_example_app/pubspec.yaml b/example/viam_example_app/pubspec.yaml index 16b2752714..91ce1762cb 100644 --- a/example/viam_example_app/pubspec.yaml +++ b/example/viam_example_app/pubspec.yaml @@ -1,11 +1,11 @@ name: viam_example_app description: An example app using Viam's Flutter SDK -publish_to: 'none' # Remove this line if you wish to publish to pub.dev +publish_to: "none" # Remove this line if you wish to publish to pub.dev version: 1.0.0+1 environment: - sdk: '>=3.0.0 <4.0.0' + sdk: ">=3.0.0 <4.0.0" dependencies: flutter: @@ -16,6 +16,7 @@ dependencies: flutter_platform_widgets: ^3.2.1 flutter_webrtc: ^0.9.35 image: ^4.0.17 + flutter_dotenv: ^5.1.0 dev_dependencies: flutter_launcher_icons: ^0.13.1 @@ -25,6 +26,8 @@ dev_dependencies: flutter: uses-material-design: true + assets: + - .env flutter_launcher_icons: android: "launcher_icon" diff --git a/lib/widgets.dart b/lib/widgets.dart index 1218e130f7..6cc7e079a1 100644 --- a/lib/widgets.dart +++ b/lib/widgets.dart @@ -2,3 +2,5 @@ export 'widgets/button.dart'; export 'widgets/camera_stream.dart'; export 'widgets/joystick.dart'; export 'widgets/resources/base.dart'; +export 'widgets/resources/board.dart'; +export 'widgets/resources/sensor.dart'; diff --git a/lib/widgets/button.dart b/lib/widgets/button.dart index 05a94f7189..17fd6d9c92 100644 --- a/lib/widgets/button.dart +++ b/lib/widgets/button.dart @@ -1,12 +1,22 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; +/// The role of the button. enum ViamButtonRole { + /// Standard button. Light gray background, dark gray foreground (inverse when in dark mode) primary, + + /// Inverse of the primary button, Dark gray background, light gray foreground (inverse when in dark mode) inverse, + + /// A button to indicate a successful operation, will result in a green color scheme success, - danger, - warning; + + /// A button to indicate a warning state, will result in an amber color scheme + warning, + + /// A button to indicate a dangerous operation, will result in an red color scheme + danger; Color get backgroundColor { final brightness = SchedulerBinding.instance.platformDispatcher.platformBrightness; @@ -61,12 +71,54 @@ enum ViamButtonRole { ButtonStyle(backgroundColor: MaterialStatePropertyAll(backgroundColor), foregroundColor: MaterialStatePropertyAll(foregroundColor)); } -enum ViamButtonStyle { +enum ViamButtonFillStyle { filled, outline, ghost; } +enum ViamButtonSizeClass { + xs, + small, + medium, + large, + xl; + + double get fontSize { + switch (this) { + case xs: + return 10; + case small: + return 12; + case medium: + return 14; + case large: + return 18; + case xl: + return 24; + } + } + + EdgeInsets get padding { + switch (this) { + case xs: + return const EdgeInsets.symmetric(vertical: 0, horizontal: 0); + case small: + return const EdgeInsets.symmetric(vertical: 16, horizontal: 20); + case medium: + return const EdgeInsets.symmetric(vertical: 18, horizontal: 32); + case large: + return const EdgeInsets.symmetric(vertical: 20, horizontal: 40); + case xl: + return const EdgeInsets.symmetric(vertical: 22, horizontal: 52); + } + } + + ButtonStyle get style { + return ButtonStyle(textStyle: MaterialStatePropertyAll(TextStyle(fontSize: fontSize)), padding: MaterialStatePropertyAll(padding)); + } +} + enum ViamButtonVariant { iconOnly, iconLeading, @@ -76,10 +128,11 @@ enum ViamButtonVariant { class ViamButton extends StatelessWidget { final String text; final VoidCallback onPressed; - final Widget? icon; + final IconData? icon; final ViamButtonRole role; - final ViamButtonStyle style; + final ViamButtonFillStyle style; final ViamButtonVariant variant; + final ViamButtonSizeClass size; const ViamButton( {required this.onPressed, @@ -87,13 +140,14 @@ class ViamButton extends StatelessWidget { super.key, this.icon, this.role = ViamButtonRole.primary, - this.style = ViamButtonStyle.filled, + this.style = ViamButtonFillStyle.filled, + this.size = ViamButtonSizeClass.medium, this.variant = ViamButtonVariant.iconLeading}); ButtonStyle get _buttonStyle { - const mainStyle = ButtonStyle(splashFactory: NoSplash.splashFactory); + final mainStyle = const ButtonStyle(splashFactory: NoSplash.splashFactory).merge(size.style); - if (style == ViamButtonStyle.ghost) { + if (style == ViamButtonFillStyle.ghost) { var fgColor = role.backgroundColor; if (role == ViamButtonRole.primary || role == ViamButtonRole.inverse) { fgColor = role.foregroundColor; @@ -103,7 +157,7 @@ class ViamButton extends StatelessWidget { foregroundColor: MaterialStatePropertyAll(fgColor), ); } - if (style == ViamButtonStyle.outline) { + if (style == ViamButtonFillStyle.outline) { var alpha = 25; var fgColor = role.backgroundColor; var outlineColor = role.backgroundColor; @@ -128,19 +182,26 @@ class ViamButton extends StatelessWidget { Widget build(BuildContext context) { Widget child; if (icon != null) { - if (variant == ViamButtonVariant.iconOnly) { - child = IconButton(onPressed: onPressed, icon: icon!, style: _buttonStyle); - } - if (style == ViamButtonStyle.outline) { - child = OutlinedButton.icon(onPressed: onPressed, icon: icon!, label: Text(text), style: _buttonStyle); + final prePadding = (variant == ViamButtonVariant.iconOnly) ? const Text(' ') : const SizedBox.shrink(); + final iconWidget = Row(children: [prePadding, Icon(icon!)]); + final label = (variant == ViamButtonVariant.iconOnly) + ? const SizedBox.shrink() + : Text( + text, + style: const TextStyle(fontWeight: FontWeight.bold), + ); + final first = (variant == ViamButtonVariant.iconTrailing) ? label : iconWidget; + final second = (variant == ViamButtonVariant.iconTrailing) ? iconWidget : label; + if (style == ViamButtonFillStyle.outline) { + child = OutlinedButton.icon(onPressed: onPressed, icon: first, label: second, style: _buttonStyle); + } else { + child = TextButton.icon(onPressed: onPressed, icon: first, label: second, style: _buttonStyle); } - child = TextButton.icon(onPressed: onPressed, icon: icon!, label: Text(text), style: _buttonStyle); - } - if (style == ViamButtonStyle.outline) { + } else if (style == ViamButtonFillStyle.outline) { child = OutlinedButton(onPressed: onPressed, style: _buttonStyle, child: Text(text)); + } else { + child = TextButton(onPressed: onPressed, style: _buttonStyle, child: Text(text)); } - child = TextButton(onPressed: onPressed, style: _buttonStyle, child: Text(text)); - return Theme(data: ThemeData(primarySwatch: role.materialColor), child: child); } } diff --git a/lib/widgets/resources/base.dart b/lib/widgets/resources/base.dart index 355ef4c2e7..f72594c1ad 100644 --- a/lib/widgets/resources/base.dart +++ b/lib/widgets/resources/base.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:viam_sdk/viam_sdk.dart'; -import 'package:viam_sdk/widgets.dart'; + +import '../camera_stream.dart'; +import '../joystick.dart'; class ViamBaseScreen extends StatefulWidget { final Base base; diff --git a/lib/widgets/resources/board.dart b/lib/widgets/resources/board.dart new file mode 100644 index 0000000000..4afc8c5b7f --- /dev/null +++ b/lib/widgets/resources/board.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:viam_sdk/viam_sdk.dart'; + +import '../button.dart'; + +class ViamBoardWidget extends StatefulWidget { + final Board board; + + const ViamBoardWidget({ + Key? key, + required this.board, + }) : super(key: key); + + @override + State createState() => _ViamBoardWidgetState(); +} + +class _ViamBoardWidgetState extends State { + String setPin = ''; + bool high = false; + BoardStatus status = const BoardStatus({}, {}); + + Future _fetchStatus() async { + final response = await widget.board.status(); + setState(() { + status = response; + }); + } + + @override + void initState() { + super.initState(); + _fetchStatus(); + } + + @override + Widget build(BuildContext context) { + return Theme( + data: ThemeData(primarySwatch: Colors.grey), + child: SingleChildScrollView( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (status.analogs.isNotEmpty) + Column(children: [ + const Text('Analogs'), + DataTable( + columns: const [DataColumn(label: Text('Analog')), DataColumn(label: Text('Value'))], + rows: status.analogs.keys + .map((e) => DataRow(cells: [DataCell(Text(e)), DataCell(Text(status.analogs[e].toString()))])) + .toList()), + const SizedBox(height: 16), + ]), + if (status.digitalInterrupts.isNotEmpty) + Column(children: [ + const Text('Digital Interrupts'), + DataTable( + columns: const [DataColumn(label: Text('Digital Interrupt')), DataColumn(label: Text('Value'))], + rows: status.digitalInterrupts.keys + .map((e) => DataRow(cells: [DataCell(Text(e)), DataCell(Text(status.digitalInterrupts[e].toString()))])) + .toList()), + const SizedBox(height: 16), + ]), + const Text('GPIO', style: TextStyle(fontSize: 24)), + Row( + children: [ + const Spacer(), + Expanded( + child: TextFormField( + onChanged: (value) => setState(() { + setPin = value; + }), + decoration: const InputDecoration(border: UnderlineInputBorder(), labelText: 'Pin'), + )), + const Spacer(), + DropdownButton( + value: high, + items: const [ + DropdownMenuItem(value: true, child: Text('High')), + DropdownMenuItem(value: false, child: Text('Low')) + ], + onChanged: (value) => setState(() { + high = value!; + })), + const Spacer(), + ], + ), + const SizedBox(height: 16), + ViamButton( + onPressed: () => widget.board.setGpioState(setPin, high), + text: 'Set Pin State', + role: ViamButtonRole.inverse, + size: ViamButtonSizeClass.large, + ) + ], + ), + ), + )); + } +} diff --git a/lib/widgets/resources/sensor.dart b/lib/widgets/resources/sensor.dart new file mode 100644 index 0000000000..0156268861 --- /dev/null +++ b/lib/widgets/resources/sensor.dart @@ -0,0 +1,128 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:viam_sdk/viam_sdk.dart'; + +import '../button.dart'; + +class ViamSensorWidget extends StatefulWidget { + final Sensor sensor; + final Duration? refreshInterval; + final bool showLastRefreshed; + final bool showRefreshControls; + + const ViamSensorWidget({ + Key? key, + required this.sensor, + this.refreshInterval = const Duration(milliseconds: 5000), + this.showLastRefreshed = true, + this.showRefreshControls = true, + }) : super(key: key); + + @override + State createState() { + return _ViamSensorWidgetState(); + } +} + +class _ViamSensorWidgetState extends State { + Map readings = {}; + Timer? timer; + DateTime? lastRefreshed; + bool isPaused = false; + + Future getReadings() async { + try { + final response = await widget.sensor.readings(); + setState( + () { + readings = Map.fromEntries(response.entries.toList()..sort((a, b) => a.key.compareTo(b.key))); + lastRefreshed = DateTime.now(); + }, + ); + } catch (e) { + Text('Error: $e'); + } + } + + void refresh() { + getReadings(); + } + + void playPause() { + setState(() => isPaused = !isPaused); + if (isPaused) { + timer?.cancel(); + } else { + getReadings(); + _createTimer(); + } + } + + void _createTimer() { + if (widget.refreshInterval != null) { + timer = Timer.periodic(widget.refreshInterval!, (_) { + refresh(); + }); + } + } + + String formattedDated(DateTime date) { + return DateFormat('yyyy-MM-dd HH:ss:SS').format(date); + } + + @override + void initState() { + super.initState(); + getReadings(); + _createTimer(); + } + + @override + void dispose() { + timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Center( + child: Column( + children: [ + DataTable( + columns: const [DataColumn(label: Text('Reading')), DataColumn(label: Text('Value'))], + rows: readings.keys.map((e) => DataRow(cells: [DataCell(Text(e)), DataCell(Text(readings[e].toString()))])).toList()), + if (widget.showLastRefreshed && lastRefreshed != null) + Column(children: [ + const SizedBox(height: 8), + Text('Updated at: ${formattedDated(lastRefreshed!)}'), + ]), + if (widget.showRefreshControls) + Column(children: [ + const SizedBox(height: 8), + Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + ViamButton( + onPressed: playPause, + text: isPaused ? 'Play' : 'Pause', + icon: isPaused ? Icons.play_arrow : Icons.pause, + variant: ViamButtonVariant.iconOnly, + style: ViamButtonFillStyle.ghost, + ), + const SizedBox(width: 8), + ViamButton( + onPressed: refresh, + text: 'Refresh', + icon: Icons.refresh, + variant: ViamButtonVariant.iconOnly, + style: ViamButtonFillStyle.ghost, + ), + ]), + ]), + ], + ), + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 2fa2b46c4f..585263a04e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,6 +18,7 @@ dependencies: fixnum: ^1.1.0 flutter_joystick: ^0.0.3 collection: ^1.17.1 + intl: ^0.18.1 dev_dependencies: flutter_test: