diff --git a/lib/api/api.dart b/lib/api/api.dart index aa7731f3..df0ce992 100644 --- a/lib/api/api.dart +++ b/lib/api/api.dart @@ -45,10 +45,10 @@ class API { }); final response = await request.send(); final body = await response.stream.bytesToString(); + debugPrint(body.toString()); + debugPrint(response.headers.toString()); if (response.statusCode == 200) { - debugPrint(body.toString()); - debugPrint(response.headers.toString()); final json = await compute(jsonDecode, body); return server.copyWith( serverUUID: json['server_uuid'], @@ -65,7 +65,7 @@ class API { /// Gets [Device] devices present on the [server] after login. /// Returns `true` if it is a success or `false` if it failed. /// The found [Device] devices are saved in [Server.devices]. - Future getDevices(Server server) async { + Future?> getDevices(Server server) async { try { assert(server.serverUUID != null && server.cookie != null); final response = await get( @@ -77,34 +77,37 @@ class API { }, ), headers: { - 'Cookie': server.cookie!, + if (server.cookie != null) 'Cookie': server.cookie!, }, ); + debugPrint(response.body); final parser = Xml2Json()..parse(response.body); - server.devices.clear(); - server.devices.addAll( - (await compute(jsonDecode, parser.toParker()))['devices']['device'] - .map( - (e) => Device( - e['device_name'], - 'live/${e['id']}', - e['status'] == 'OK', - e['resolutionX'] == null ? null : int.parse(e['resolutionX']), - e['resolutionX'] == null ? null : int.parse(e['resolutionY']), - server, - ), - ) - .toList() - .cast() - // cause `online` devies to show on top. - ..sort((a, b) => a.status ? 0 : 1), - ); - return true; + final devices = + (await compute(jsonDecode, parser.toParker()))['devices']['device'] + .map( + (e) => Device( + e['device_name'], + 'live/${e['id']}', + e['status'] == 'OK', + e['resolutionX'] == null ? null : int.parse(e['resolutionX']), + e['resolutionX'] == null ? null : int.parse(e['resolutionY']), + server, + ), + ) + .toList() + .cast() + // cause `online` devies to show on top. + ..sort((Device a, Device b) => a.status ? 0 : 1); + + server.devices + ..clear() + ..addAll(devices); + return devices; } catch (exception, stacktrace) { debugPrint(exception.toString()); debugPrint(stacktrace.toString()); } - return false; + return null; } /// Gets [Event]s present on the [server] after login. @@ -136,7 +139,7 @@ class API { return Event( server, int.parse(e['id']['raw']), - int.parse(e['category']['term'].split('/').first), + int.parse((e['category']['term'] as String).split('/').first), e['title']['\$t'], e['published'] == null || e['published']['\$t'] == null ? DateTime.now() diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 1a148429..49d3e0fa 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -211,11 +211,25 @@ "fromDate": "From", "toDate": "To", "allowAlarms": "Allow alarms", + "@Event Priorities": {}, + "info": "Info", "warn": "Warning", "alarm": "Alarm", + "critical": "Critical", + "@Event Types": {}, "motion": "Motion", "continuous": "Continouous", "notFound": "Not found", + "cameraVideoLost": "Video Lost", + "cameraAudioLost": "Audio Lost", + "systemDiskSpace": "Disk Space", + "systemCrash": "Crash", + "systemBoot": "Startup", + "systemShutdown": "Shutdown", + "systemReboot": "Reboot", + "systemPowerOutage": "Power Lost", + "unknown": "Unknown", + "@": {}, "close": "Close", "open": "Open" } diff --git a/lib/models/event.dart b/lib/models/event.dart index 52a97f58..ff326b8b 100644 --- a/lib/models/event.dart +++ b/lib/models/event.dart @@ -207,7 +207,6 @@ class Event { } } -// TODO(bdlukaa): locale for these enum EventPriority { info, warning, @@ -218,12 +217,13 @@ enum EventPriority { final localizations = AppLocalizations.of(context); switch (this) { case EventPriority.info: + return localizations.info; case EventPriority.warning: return localizations.warn; case EventPriority.alarm: return localizations.alarm; case EventPriority.critical: - return localizations.notFound; + return localizations.critical; } } } @@ -250,8 +250,25 @@ enum EventType { case EventType.continuous: return localizations.continuous; case EventType.notFound: - default: return localizations.notFound; + case EventType.cameraVideoLost: + return localizations.cameraVideoLost; + case EventType.cameraAudioLost: + return localizations.cameraAudioLost; + case EventType.systemDiskSpace: + return localizations.systemDiskSpace; + case EventType.systemCrash: + return localizations.systemCrash; + case EventType.systemBoot: + return localizations.systemBoot; + case EventType.systemShutdown: + return localizations.systemShutdown; + case EventType.systemReboot: + return localizations.systemReboot; + case EventType.systemPowerOutage: + return localizations.systemPowerOutage; + case EventType.unknown: + return localizations.unknown; } } } diff --git a/lib/providers/server_provider.dart b/lib/providers/server_provider.dart index 8cf32caf..efe12f63 100644 --- a/lib/providers/server_provider.dart +++ b/lib/providers/server_provider.dart @@ -57,6 +57,17 @@ class ServersProvider extends ChangeNotifier { } else { await _restore(); } + + /// Fetch for any new device at startup + // for (final server in servers) { + // final devices = await API.instance.getDevices(server); + // if (devices != null) { + // server.devices + // ..clear() + // ..addAll(devices); + // } + // } + // await _save(); } /// Adds a new [Server] to the cache. diff --git a/lib/utils/extensions.dart b/lib/utils/extensions.dart index 04d96e1a..00ad82b4 100644 --- a/lib/utils/extensions.dart +++ b/lib/utils/extensions.dart @@ -205,3 +205,12 @@ extension EventsExtension on Iterable { return where((e) => e.published.isInBetween(d1, d2)); } } + +extension DeviceListExtension on List { + /// Returns this device list sorted properly + List sorted() { + return [...this] + ..sort((a, b) => a.name.compareTo(b.name)) + ..sort((a, b) => a.status ? 0 : 1); + } +} diff --git a/lib/utils/theme.dart b/lib/utils/theme.dart index 8a9999a9..4db5c45d 100644 --- a/lib/utils/theme.dart +++ b/lib/utils/theme.dart @@ -174,8 +174,10 @@ ThemeData createTheme({ }, ), ), - buttonTheme: - ButtonThemeData(disabledColor: light ? Colors.black12 : Colors.white24), + buttonTheme: ButtonThemeData( + disabledColor: light ? Colors.black12 : Colors.white24, + alignedDropdown: true, + ), splashFactory: InkSparkle.splashFactory, highlightColor: defaultTargetPlatform == TargetPlatform.android ? Colors.transparent diff --git a/lib/utils/tree_view/tree_view.dart b/lib/utils/tree_view/tree_view.dart new file mode 100644 index 00000000..03025699 --- /dev/null +++ b/lib/utils/tree_view/tree_view.dart @@ -0,0 +1,185 @@ +// ORIGINAL PACKAGE: https://pub.dev/packages/flutter_simple_treeview + +import 'package:flutter/material.dart'; +import 'package:flutter_simple_treeview/flutter_simple_treeview.dart' + show TreeNode, TreeController; + +export 'package:flutter_simple_treeview/flutter_simple_treeview.dart' + show TreeNode, TreeController; + +/// Tree view with collapsible and expandable nodes. +class TreeView extends StatefulWidget { + /// List of root level tree nodes. + final List nodes; + + /// Horizontal indent between levels. + final double? indent; + + /// Size of the expand/collapse icon. + final double? iconSize; + + /// Tree controller to manage the tree state. + final TreeController? treeController; + + TreeView( + {Key? key, + required List nodes, + this.indent = 40, + this.iconSize, + this.treeController}) + : nodes = copyTreeNodes(nodes), + super(key: key); + + @override + State createState() => _TreeViewState(); +} + +class _TreeViewState extends State { + TreeController? _controller; + + @override + void initState() { + _controller = widget.treeController ?? TreeController(); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return buildNodes( + widget.nodes, widget.indent, _controller!, widget.iconSize); + } +} + +/// Builds set of [nodes] respecting [state], [indent] and [iconSize]. +Widget buildNodes(Iterable nodes, double? indent, + TreeController state, double? iconSize) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var node in nodes) + NodeWidget( + treeNode: node, + indent: indent, + state: state, + iconSize: iconSize, + ) + ], + ); +} + +/// Copies nodes to unmodifiable list, assigning missing keys and checking for duplicates. +List copyTreeNodes(List? nodes) { + return _copyNodesRecursively(nodes, KeyProvider())!; +} + +List? _copyNodesRecursively( + List? nodes, KeyProvider keyProvider) { + if (nodes == null) { + return null; + } + return List.unmodifiable(nodes.map((n) { + return TreeNode( + key: keyProvider.key(n.key), + content: n.content, + children: _copyNodesRecursively(n.children, keyProvider), + ); + })); +} + +class _TreeNodeKey extends ValueKey { + const _TreeNodeKey(dynamic value) : super(value); +} + +/// Provides unique keys and verifies duplicates. +class KeyProvider { + int _nextIndex = 0; + final Set _keys = {}; + + /// If [originalKey] is null, generates new key, otherwise verifies the key + /// was not met before. + Key key(Key? originalKey) { + if (originalKey == null) { + return _TreeNodeKey(_nextIndex++); + } + if (_keys.contains(originalKey)) { + throw ArgumentError('There should not be nodes with the same kays. ' + 'Duplicate value found: $originalKey.'); + } + _keys.add(originalKey); + return originalKey; + } +} + +/// Widget that displays one [TreeNode] and its children. +class NodeWidget extends StatefulWidget { + final TreeNode treeNode; + final double? indent; + final double? iconSize; + final TreeController state; + + const NodeWidget( + {Key? key, + required this.treeNode, + this.indent, + required this.state, + this.iconSize}) + : super(key: key); + + @override + State createState() => _NodeWidgetState(); +} + +class _NodeWidgetState extends State { + bool get _isLeaf { + return widget.treeNode.children == null; + } + + bool get _isEnabled { + return widget.treeNode.children?.isNotEmpty ?? false; + } + + bool get _isExpanded { + return widget.state.isNodeExpanded(widget.treeNode.key!); + } + + @override + Widget build(BuildContext context) { + var icon = _isLeaf + ? null + : _isExpanded + ? Icons.expand_more + : Icons.chevron_right; + + var onIconPressed = _isLeaf || !_isEnabled + ? null + : () => setState( + () => widget.state.toggleNodeExpanded(widget.treeNode.key!)); + + return IgnorePointer( + ignoring: _isLeaf ? false : !_isEnabled, + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + if (!_isLeaf) + Padding( + padding: const EdgeInsetsDirectional.only(start: 8.0), + child: InkWell( + onTap: onIconPressed, + borderRadius: BorderRadius.circular(100), + child: Padding( + padding: const EdgeInsets.all(4.5), + child: Icon(icon, size: widget.iconSize), + ), + ), + ), + Expanded(child: widget.treeNode.content), + ]), + if (_isExpanded && !_isLeaf) + Padding( + padding: EdgeInsetsDirectional.only(start: widget.indent!), + child: buildNodes(widget.treeNode.children!, widget.indent, + widget.state, widget.iconSize), + ) + ]), + ); + } +} diff --git a/lib/widgets/device_grid/desktop_sidebar.dart b/lib/widgets/device_grid/desktop_sidebar.dart index 2e80431f..d04ff83b 100644 --- a/lib/widgets/device_grid/desktop_sidebar.dart +++ b/lib/widgets/device_grid/desktop_sidebar.dart @@ -55,47 +55,30 @@ class _DesktopSidebarState extends State { itemCount: ServersProvider.instance.servers.length, itemBuilder: (context, i) { final server = ServersProvider.instance.servers[i]; - return FutureBuilder( - future: (() async => server.devices.isEmpty - ? API.instance.getDevices( - await API.instance.checkServerCredentials(server)) - : true)(), - builder: (context, snapshot) { - if (snapshot.hasData) { - return ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: server.devices.length + 1, - itemBuilder: (context, index) { - if (index == 0) { - return SubHeader( - server.name, - subtext: AppLocalizations.of(context).nDevices( - server.devices.length, - ), - ); - } - - index--; - final device = server.devices[index]; - final selected = - view.currentLayout.devices.contains(device); - - return DesktopDeviceSelectorTile( - device: device, - selected: selected, - ); - }, - ); - } else { - return Center( - child: Container( - alignment: AlignmentDirectional.center, - height: 156.0, - child: const CircularProgressIndicator.adaptive(), + final devices = server.devices.sorted(); + return ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: devices.length + 1, + itemBuilder: (context, index) { + if (index == 0) { + return SubHeader( + server.name, + subtext: AppLocalizations.of(context).nDevices( + devices.length, ), ); } + + index--; + final device = devices[index]; + final selected = + view.currentLayout.devices.contains(device); + + return DesktopDeviceSelectorTile( + device: device, + selected: selected, + ); }, ); }, diff --git a/lib/widgets/device_grid/device_grid.dart b/lib/widgets/device_grid/device_grid.dart index 791b2b3d..a639c91f 100644 --- a/lib/widgets/device_grid/device_grid.dart +++ b/lib/widgets/device_grid/device_grid.dart @@ -21,7 +21,6 @@ import 'dart:io'; import 'dart:math'; import 'package:animations/animations.dart'; -import 'package:bluecherry_client/api/api.dart'; import 'package:bluecherry_client/models/device.dart'; import 'package:bluecherry_client/models/layout.dart'; import 'package:bluecherry_client/providers/desktop_view_provider.dart'; diff --git a/lib/widgets/direct_camera.dart b/lib/widgets/direct_camera.dart index 1de52a47..2cf263d4 100644 --- a/lib/widgets/direct_camera.dart +++ b/lib/widgets/direct_camera.dart @@ -75,7 +75,7 @@ class _DirectCameraScreenState extends State { itemCount: ServersProvider.instance.servers.length, itemBuilder: (context, i) { final server = ServersProvider.instance.servers[i]; - return CustomFutureBuilder( + return CustomFutureBuilder( future: () async { if (server.devices.isEmpty) { return API.instance.getDevices( @@ -122,6 +122,8 @@ class _DevicesForServer extends StatelessWidget { ), ); } + + final devices = server.devices.sorted(); return LayoutBuilder(builder: (context, consts) { if (consts.maxWidth >= 800) { return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -129,7 +131,7 @@ class _DevicesForServer extends StatelessWidget { Padding( padding: const EdgeInsets.symmetric(horizontal: 10.0), child: Wrap( - children: server.devices.map((device) { + children: devices.map((device) { final foregroundColor = device.status ? colorFromBrightness( context, @@ -180,7 +182,7 @@ class _DevicesForServer extends StatelessWidget { } return Column(children: [ SubHeader(server.name), - ...server.devices.map((device) { + ...devices.map((device) { return ListTile( enabled: device.status, leading: CircleAvatar( diff --git a/lib/widgets/events/events_screen.dart b/lib/widgets/events/events_screen.dart index 25c43dc2..ee948abd 100644 --- a/lib/widgets/events/events_screen.dart +++ b/lib/widgets/events/events_screen.dart @@ -31,6 +31,7 @@ import 'package:bluecherry_client/providers/server_provider.dart'; import 'package:bluecherry_client/providers/settings_provider.dart'; import 'package:bluecherry_client/utils/extensions.dart'; import 'package:bluecherry_client/utils/methods.dart'; +import 'package:bluecherry_client/utils/tree_view/tree_view.dart'; import 'package:bluecherry_client/widgets/desktop_buttons.dart'; import 'package:bluecherry_client/widgets/downloads_manager.dart'; import 'package:bluecherry_client/widgets/error_warning.dart'; @@ -63,6 +64,9 @@ class _EventsScreenState extends State { final EventsData events = {}; Map invalid = {}; + /// The devices that can't be displayed in the list + List disabledDevices = []; + @override void initState() { super.initState(); @@ -121,43 +125,21 @@ class _EventsScreenState extends State { return LayoutBuilder(builder: (context, consts) { if (consts.maxWidth >= 800) { - final servers = context.watch(); - return Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 220, - child: Material( - color: Theme.of(context).appBarTheme.backgroundColor, + child: Card( + margin: EdgeInsets.zero, + shape: const RoundedRectangleBorder(), + // color: Theme.of(context).appBarTheme.backgroundColor, child: DropdownButtonHideUnderline( child: Column(children: [ SubHeader(AppLocalizations.of(context).servers), - ...servers.servers.map((server) { - return CheckboxListTile( - value: allowedServers.contains(server), - onChanged: (v) { - setState(() { - if (v == null || !v) { - allowedServers.remove(server); - } else { - allowedServers.add(server); - } - }); - }, - controlAffinity: ListTileControlAffinity.leading, - contentPadding: const EdgeInsetsDirectional.only( - start: 8.0, - end: 16.0, - ), - title: Text( - server.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 14.0), - ), - ); - }), - const Spacer(), - // TODO(bdlukaa): THIS IS BLOCKED BY https://github.com/flutter/flutter/pull/115806 + Expanded( + child: SingleChildScrollView( + child: buildTreeView(context), + ), + ), DropdownButton( isExpanded: true, value: timeFilter, @@ -215,6 +197,7 @@ class _EventsScreenState extends State { child: EventsScreenDesktop( events: events, allowedServers: allowedServers, + disabledDevices: disabledDevices, timeFilter: timeFilter, levelFilter: levelFilter, ), @@ -232,6 +215,109 @@ class _EventsScreenState extends State { }(), ); } + + Widget buildTreeView(BuildContext context) { + final servers = context.watch(); + const checkboxScale = 0.8; + return TreeView( + indent: 56, + iconSize: 18.0, + nodes: servers.servers + .where((server) => server.devices.isNotEmpty) + .map((server) { + final isTriState = disabledDevices + .any((d) => server.devices.any((device) => device.streamURL == d)); + return TreeNode( + content: Row(children: [ + Transform.scale( + scale: checkboxScale, + child: Checkbox( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: const VisualDensity( + horizontal: -4, + vertical: -4, + ), + splashRadius: 0.0, + tristate: true, + value: !allowedServers.contains(server) + ? false + : isTriState + ? null + : true, + onChanged: (v) { + setState(() { + if (isTriState) { + disabledDevices.removeWhere((d) => server.devices + .any((device) => device.streamURL == d)); + } else if (v == null || !v) { + allowedServers.remove(server); + } else { + allowedServers.add(server); + } + }); + }, + ), + ), + Expanded( + child: Text( + server.name, + overflow: TextOverflow.ellipsis, + maxLines: 1, + softWrap: false, + ), + ), + Text( + '${server.devices.length}', + style: Theme.of(context).textTheme.labelSmall, + ), + const SizedBox(width: 6.0), + ]), + children: server.devices.sorted().map((device) { + final enabled = !allowedServers.contains(server) + ? false + : !disabledDevices.contains(device.streamURL); + return TreeNode( + content: Row(children: [ + Transform.scale( + scale: checkboxScale, + child: IgnorePointer( + ignoring: !device.status, + child: Checkbox( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: const VisualDensity( + horizontal: -4, + vertical: -4, + ), + splashRadius: 0.0, + isError: !device.status, + value: device.status ? enabled : false, + onChanged: (v) { + if (!device.status) return; + + setState(() { + if (enabled) { + disabledDevices.add(device.streamURL); + } else { + disabledDevices.remove(device.streamURL); + } + }); + }, + ), + ), + ), + Text( + device.name, + overflow: TextOverflow.ellipsis, + maxLines: 1, + softWrap: false, + ), + ]), + ); + }).toList(), + ); + }).toList(), + ); + } } enum EventsTimeFilter { diff --git a/lib/widgets/events/events_screen_desktop.dart b/lib/widgets/events/events_screen_desktop.dart index 7c6f1ffe..9055d353 100644 --- a/lib/widgets/events/events_screen_desktop.dart +++ b/lib/widgets/events/events_screen_desktop.dart @@ -24,6 +24,7 @@ class EventsScreenDesktop extends StatelessWidget { // filters final List allowedServers; + final List disabledDevices; final EventsTimeFilter timeFilter; final EventsMinLevelFilter levelFilter; @@ -31,6 +32,7 @@ class EventsScreenDesktop extends StatelessWidget { Key? key, required this.events, required this.allowedServers, + required this.disabledDevices, required this.timeFilter, required this.levelFilter, }) : super(key: key); @@ -72,6 +74,14 @@ class EventsScreenDesktop extends StatelessWidget { break; } + // This is hacky. Maybe find a way to move this logic to [API.getEvents] + // It'd also be useful to find a way to get the device at Event creation time + final devices = event.server.devices.where((device) => + device.name.toLowerCase() == event.deviceName.toLowerCase()); + if (devices.isNotEmpty) { + if (disabledDevices.contains(devices.first.streamURL)) continue; + } + yield event; } }).toList(); diff --git a/lib/widgets/events_playback/events_playback_desktop.dart b/lib/widgets/events_playback/events_playback_desktop.dart index eb594f6d..0fa725cd 100644 --- a/lib/widgets/events_playback/events_playback_desktop.dart +++ b/lib/widgets/events_playback/events_playback_desktop.dart @@ -370,7 +370,7 @@ class Sidebar extends StatelessWidget { final server = servers.elementAt(i); return FutureBuilder( future: (() async => server.devices.isEmpty - ? API.instance.getDevices( + ? await API.instance.getDevices( await API.instance.checkServerCredentials(server)) : true)(), builder: (context, snapshot) { @@ -384,16 +384,18 @@ class Sidebar extends StatelessWidget { ); } + final devices = server.devices.sorted(); + return ListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), - itemCount: server.devices.length + 1, + itemCount: devices.length + 1, itemBuilder: (context, index) { if (index == 0) { return SubHeader( server.name, subtext: AppLocalizations.of(context).nDevices( - server.devices.length, + devices.length, ), padding: const EdgeInsetsDirectional.only( start: 16.0, @@ -404,7 +406,7 @@ class Sidebar extends StatelessWidget { } index--; - final device = server.devices[index]; + final device = devices[index]; if (!this .events .keys diff --git a/lib/widgets/settings/server_tile.dart b/lib/widgets/settings/server_tile.dart index 5a0db6cc..e5640e86 100644 --- a/lib/widgets/settings/server_tile.dart +++ b/lib/widgets/settings/server_tile.dart @@ -272,6 +272,7 @@ class _ServerCardState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final home = context.watch(); return SizedBox( height: 180, @@ -360,8 +361,7 @@ class _ServerCardState extends State { PopupMenuItem( child: Text(AppLocalizations.of(context).browseEvents), onTap: () { - // TODO(bdlukaa): browse events - // launchUrl(Uri.parse(widget.server.ip)); + home.setTab(UnityTab.eventsScreen.index, context); }, ), PopupMenuItem( diff --git a/pubspec.lock b/pubspec.lock index 5f2f32a1..29a94632 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -70,10 +70,10 @@ packages: dependency: transitive description: name: characters - sha256: e6a326c8af69605aec75ed6c187d06b349707a27fbff8222ca9cc2cff167975c + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.3.0" checked_yaml: dependency: transitive description: @@ -277,6 +277,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.7" + flutter_simple_treeview: + dependency: "direct main" + description: + name: flutter_simple_treeview + sha256: ad4978d2668dd078d3a09966832da111bef9102dd636e572c50c80133b7ff4d9 + url: "https://pub.dev" + source: hosted + version: "3.0.2" flutter_test: dependency: "direct dev" description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index bd90016e..16b2f259 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -28,6 +28,7 @@ dependencies: provider: ^6.0.5 reorderable_grid_view: ^2.2.6-alpha.14 reorderables: ^0.6.0 + flutter_simple_treeview: ^3.0.2 intl: ^0.18.0 firebase_core: ^2.7.0