diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..24476c5d --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/.metadata b/.metadata new file mode 100644 index 00000000..0cffca3b --- /dev/null +++ b/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled. + +version: + revision: 9cd3d0d9ff05768afa249e036acc66e8abe93bff + channel: stable + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 9cd3d0d9ff05768afa249e036acc66e8abe93bff + base_revision: 9cd3d0d9ff05768afa249e036acc66e8abe93bff + - platform: android + create_revision: 9cd3d0d9ff05768afa249e036acc66e8abe93bff + base_revision: 9cd3d0d9ff05768afa249e036acc66e8abe93bff + - platform: ios + create_revision: 9cd3d0d9ff05768afa249e036acc66e8abe93bff + base_revision: 9cd3d0d9ff05768afa249e036acc66e8abe93bff + - platform: linux + create_revision: 9cd3d0d9ff05768afa249e036acc66e8abe93bff + base_revision: 9cd3d0d9ff05768afa249e036acc66e8abe93bff + - platform: macos + create_revision: 9cd3d0d9ff05768afa249e036acc66e8abe93bff + base_revision: 9cd3d0d9ff05768afa249e036acc66e8abe93bff + - platform: web + create_revision: 9cd3d0d9ff05768afa249e036acc66e8abe93bff + base_revision: 9cd3d0d9ff05768afa249e036acc66e8abe93bff + - platform: windows + create_revision: 9cd3d0d9ff05768afa249e036acc66e8abe93bff + base_revision: 9cd3d0d9ff05768afa249e036acc66e8abe93bff + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/README.md b/README.md new file mode 100644 index 00000000..800f06ec --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# ![Elastic Logo](assets/logos/logo_full.png) + +A simple, modern dashboard for FRC teams. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 00000000..f9b30346 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml diff --git a/assets/fields/2023-chargedup.json b/assets/fields/2023-chargedup.json new file mode 100644 index 00000000..ee02576a --- /dev/null +++ b/assets/fields/2023-chargedup.json @@ -0,0 +1,18 @@ +{ + "game": "Charged Up", + "field-image": "assets/fields/2023-field.png", + "field-corners": { + "top-left": [ + 46, + 36 + ], + "bottom-right": [ + 1088, + 544 + ] + }, + "field-size": [ + 16.54, + 8.01 + ] +} diff --git a/assets/fields/2023-field.png b/assets/fields/2023-field.png new file mode 100644 index 00000000..ab3f0ff3 Binary files /dev/null and b/assets/fields/2023-field.png differ diff --git a/assets/first-background.png b/assets/first-background.png new file mode 100644 index 00000000..7406d275 Binary files /dev/null and b/assets/first-background.png differ diff --git a/assets/logos/logo.png b/assets/logos/logo.png new file mode 100644 index 00000000..491a7c5f Binary files /dev/null and b/assets/logos/logo.png differ diff --git a/assets/logos/logo_full.png b/assets/logos/logo_full.png new file mode 100644 index 00000000..1e9162f6 Binary files /dev/null and b/assets/logos/logo_full.png differ diff --git a/flutter_01.png b/flutter_01.png new file mode 100644 index 00000000..dae02272 Binary files /dev/null and b/flutter_01.png differ diff --git a/flutter_02.png b/flutter_02.png new file mode 100644 index 00000000..d69886c0 Binary files /dev/null and b/flutter_02.png differ diff --git a/flutter_03.png b/flutter_03.png new file mode 100644 index 00000000..d69886c0 Binary files /dev/null and b/flutter_03.png differ diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 00000000..ef3290cf --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,48 @@ +import 'package:elastic_dashboard/pages/dashboard_page.dart'; +import 'package:elastic_dashboard/services/field_images.dart'; +import 'package:elastic_dashboard/services/globals.dart'; +import 'package:elastic_dashboard/services/nt4_connection.dart'; +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:window_manager/window_manager.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + final SharedPreferences preferences = await SharedPreferences.getInstance(); + await windowManager.ensureInitialized(); + + Globals.gridSize = preferences.getInt('grid_size') ?? 128; + Globals.snapToGrid = preferences.getBool('snap_to_grid') ?? true; + Globals.showGrid = preferences.getBool('show_grid') ?? false; + + NT4Connection.connect(); + + await FieldImages.loadFields('assets/fields/'); + + await windowManager.setTitleBarStyle(TitleBarStyle.hidden); + + runApp(Elastic(preferences: preferences)); +} + +class Elastic extends StatelessWidget { + final SharedPreferences preferences; + + const Elastic({super.key, required this.preferences}); + + @override + Widget build(BuildContext context) { + ThemeData theme = ThemeData( + useMaterial3: true, + colorSchemeSeed: Colors.blueAccent, + brightness: Brightness.dark, + ); + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'Elastic', + theme: theme, + home: DashboardPage( + preferences: preferences, + ), + ); + } +} diff --git a/lib/pages/dashboard_page.dart b/lib/pages/dashboard_page.dart new file mode 100644 index 00000000..d8c8cbaf --- /dev/null +++ b/lib/pages/dashboard_page.dart @@ -0,0 +1,506 @@ +import 'dart:convert'; + +import 'package:elastic_dashboard/services/globals.dart'; +import 'package:elastic_dashboard/services/nt4_connection.dart'; +import 'package:elastic_dashboard/widgets/custom_appbar.dart'; +import 'package:elastic_dashboard/widgets/dashboard_grid.dart'; +import 'package:elastic_dashboard/widgets/draggable_dialog.dart'; +import 'package:elastic_dashboard/widgets/draggable_widget_container.dart'; +import 'package:elastic_dashboard/widgets/editable_tab_bar.dart'; +import 'package:elastic_dashboard/widgets/network_tree/network_table_tree.dart'; +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class DashboardPage extends StatefulWidget { + final SharedPreferences preferences; + + const DashboardPage({super.key, required this.preferences}); + + @override + State createState() => _DashboardPageState(); +} + +class _DashboardPageState extends State { + late final SharedPreferences _preferences; + + final List grids = []; + + final List tabData = []; + + int currentTabIndex = 0; + + bool addWidgetDialogVisible = false; + + @override + void initState() { + super.initState(); + + _preferences = widget.preferences; + + loadLayout(); + + NT4Connection.addConnectedListener(() { + setState(() { + for (DashboardGrid grid in grids) { + grid.onNTConnect(); + } + }); + }); + + NT4Connection.addDisconnectedListener(() { + setState(() { + for (DashboardGrid grid in grids) { + grid.onNTDisconnect(); + } + }); + }); + } + + Map toJson() { + List> gridData = []; + + for (int i = 0; i < tabData.length; i++) { + TabData data = tabData[i]; + DashboardGrid grid = grids[i]; + + gridData.add({ + 'name': data.name, + 'grid_layout': grid.toJson(), + }); + } + + return { + 'tabs': gridData, + }; + } + + void saveLayout() async { + Map jsonData = toJson(); + + SnackBar savedMessage = SnackBar( + content: const Text('Layout Saved Successfully!'), + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 3), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15.0)), + width: 500, + showCloseIcon: true, + ); + + ScaffoldMessengerState messengerState = ScaffoldMessenger.of(context); + + await _preferences.setString('layout', jsonEncode(jsonData)); + + messengerState.showSnackBar(savedMessage); + } + + void exportLayout() async { + String initialDirectory = (await getApplicationDocumentsDirectory()).path; + + const XTypeGroup typeGroup = XTypeGroup( + label: 'Json File', + extensions: ['json'], + ); + + final FileSaveLocation? saveLocation = await getSaveLocation( + initialDirectory: initialDirectory, + suggestedName: 'elastic-layout.json', + acceptedTypeGroups: [typeGroup], + ); + + if (saveLocation == null) { + return; + } + + Map jsonData = toJson(); + String jsonString = jsonEncode(jsonData); + + final Uint8List fileData = Uint8List.fromList(jsonString.codeUnits); + const String mimeType = 'application/json'; + + final XFile jsonFile = XFile.fromData(fileData, + mimeType: mimeType, name: 'elastic-layout.json'); + + jsonFile.saveTo(saveLocation.path); + } + + void importLayout() async { + const XTypeGroup typeGroup = XTypeGroup( + label: 'Json File', + extensions: ['json'], + ); + + final XFile? file = await openFile(acceptedTypeGroups: [typeGroup]); + + if (file == null) { + return; + } + + String jsonString = await file.readAsString(); + + await _preferences.setString('layout', jsonString); + + setState(() => loadLayoutFromJsonData(jsonString)); + } + + void loadLayout() async { + String? jsonString = _preferences.getString('layout'); + + if (jsonString == null) { + return; + } + + setState(() { + loadLayoutFromJsonData(jsonString); + }); + } + + void loadLayoutFromJsonData(String jsonString) { + tabData.clear(); + grids.clear(); + + Map jsonData = jsonDecode(jsonString); + for (Map data in jsonData['tabs']) { + tabData.add(TabData(name: data['name'])); + + grids.add(DashboardGrid.fromJson( + key: GlobalKey(), + jsonData: data['grid_layout'], + onAddWidgetPressed: displayAddWidgetDialog)); + } + + if (tabData.isEmpty) { + tabData.addAll([ + TabData(name: 'Teleoperated'), + TabData(name: 'Autonomous'), + ]); + + grids.addAll([ + DashboardGrid(key: GlobalKey(), jsonData: const {}), + DashboardGrid(key: GlobalKey(), jsonData: const {}), + ]); + } + } + + void displayAddWidgetDialog() { + setState(() => addWidgetDialogVisible = true); + } + + void displayAboutDialog(BuildContext context) { + IconThemeData iconTheme = IconTheme.of(context); + showAboutDialog( + context: context, + applicationName: 'Elastic', + applicationVersion: Globals.version, + applicationIcon: Image.asset( + 'assets/logos/logo.png', + width: iconTheme.size, + height: iconTheme.size, + ), + children: [ + Container( + constraints: const BoxConstraints(maxWidth: 280), + child: const Text( + 'Elastic was created by Team 353, the POBots in the summer of 2023. The motivation was to provide teams an alternative to WPILib\'s Shuffleboard dashboard.\n', + ), + ), + Container( + constraints: const BoxConstraints(maxWidth: 280), + child: const Text( + 'The goal of Elastic is to have the essential features of Shuffleboard, but with a more elegant and modern display, and offer more customizability and performance.\n', + ), + ), + Container( + constraints: const BoxConstraints(maxWidth: 280), + child: const Text( + 'Elastic is an ongoing project, if you have any ideas, feedback, or found any bugs, feel free to share them on the Github page!\n', + ), + ), + Container( + constraints: const BoxConstraints(maxWidth: 280), + child: const Text( + 'Elastic was built with some inspiration from Michael Jansen\'s projects and his Dart NT4 library, along with significant help from Jason and Peter from WPILib.', + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + TextStyle? menuStyle = Theme.of(context).textTheme.bodySmall; + TextStyle? footerStyle = Theme.of(context).textTheme.bodyMedium; + ButtonStyle buttonStyle = + ButtonStyle(textStyle: MaterialStateProperty.all(menuStyle)); + + MenuBar menuBar = MenuBar( + children: [ + // File + SubmenuButton( + style: buttonStyle, + menuChildren: [ + // Open Layout + MenuItemButton( + style: buttonStyle, + onPressed: () { + importLayout(); + }, + shortcut: + const SingleActivator(LogicalKeyboardKey.keyO, control: true), + child: const Text( + 'Open Layout', + ), + ), + // Save + MenuItemButton( + style: buttonStyle, + onPressed: () { + saveLayout(); + }, + shortcut: + const SingleActivator(LogicalKeyboardKey.keyS, control: true), + child: const Text( + 'Save', + ), + ), + // Export layout + MenuItemButton( + style: buttonStyle, + onPressed: () { + exportLayout(); + }, + shortcut: const SingleActivator(LogicalKeyboardKey.keyS, + shift: true, control: true), + child: const Text( + 'Save As', + ), + ), + ], + child: const Text( + 'File', + ), + ), + // Edit + SubmenuButton( + style: buttonStyle, + menuChildren: [ + // Show Grid + MenuItemButton( + style: buttonStyle, + onPressed: () { + setState(() { + Globals.showGrid = !Globals.showGrid; + + _preferences.setBool('show_grid', Globals.showGrid); + }); + }, + child: const Text('Toggle Grid'), + ), + ], + child: const Text( + 'Edit', + )), + // Help + SubmenuButton( + style: buttonStyle, + menuChildren: [ + MenuItemButton( + style: buttonStyle, + onPressed: () { + displayAboutDialog(context); + }, + child: const Text( + 'About', + ), + ), + ], + child: const Text( + 'Help', + ), + ), + ], + ); + + return Scaffold( + appBar: CustomAppBar(menuBar: menuBar), + floatingActionButton: FloatingActionButton.extended( + onPressed: displayAddWidgetDialog, + label: const Text('Add Widget'), + icon: const Icon(Icons.add), + ), + body: CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.keyO, control: true): + importLayout, + const SingleActivator(LogicalKeyboardKey.keyS, control: true): + saveLayout, + const SingleActivator(LogicalKeyboardKey.keyS, + shift: true, control: true): exportLayout, + for (int i = 1; i <= 9; i++) + SingleActivator(LogicalKeyboardKey(48 + i), control: true): () { + if (i - 1 < tabData.length) { + setState(() => currentTabIndex = i - 1); + } + }, + }, + child: Focus( + autofocus: true, + child: Column( + children: [ + // Main dashboard page + Expanded( + child: Stack( + children: [ + // Image.asset( + // "assets/first-background.png", + // width: MediaQuery.of(context).size.width, + // height: MediaQuery.of(context).size.height, + // fit: BoxFit.cover, + // ), + EditableTabBar( + currentIndex: currentTabIndex, + onTabRename: (index, newData) { + setState(() { + tabData[index] = newData; + }); + }, + onTabCreate: (tab, grid) { + setState(() { + tabData.add(tab); + grids.add(grid); + }); + }, + onTabDestroy: (tab, grid) { + if (currentTabIndex == tabData.length) { + currentTabIndex--; + } + setState(() { + tabData.remove(tab); + grids.remove(grid); + }); + }, + onTabChanged: (index) { + setState(() => currentTabIndex = index); + }, + tabData: tabData, + tabViews: grids, + ), + AddWidgetDialog( + visible: addWidgetDialogVisible, + onDragUpdate: (globalPosition, widget) { + grids[currentTabIndex] + .addDragInWidget(widget, globalPosition); + }, + onDragEnd: (widget) { + grids[currentTabIndex].placeDragInWidget(widget); + }, + onClose: () { + setState(() => addWidgetDialogVisible = false); + }, + ), + ], + ), + ), + // Bottom bar + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10.0), + child: SizedBox( + height: 32, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + StreamBuilder( + stream: NT4Connection.connectionStatus(), + builder: (context, snapshot) { + bool connected = snapshot.data ?? false; + + if (connected) { + return Text('Network Tables: Connected', + style: footerStyle!.copyWith( + color: Colors.green, + )); + } else { + return Text('Network Tables: Disconnected', + style: footerStyle!.copyWith( + color: Colors.red, + )); + } + }, + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class AddWidgetDialog extends StatelessWidget { + final bool visible; + final Function(Offset globalPosition, WidgetContainer widget)? onDragUpdate; + final Function(WidgetContainer widget)? onDragEnd; + + final Function()? onClose; + + const AddWidgetDialog({ + super.key, + required this.visible, + this.onDragUpdate, + this.onDragEnd, + this.onClose, + }); + + @override + Widget build(BuildContext context) { + return Visibility( + visible: visible, + child: DraggableDialog( + dialog: Container( + decoration: const BoxDecoration(boxShadow: [ + BoxShadow( + blurRadius: 20, + spreadRadius: -12.5, + offset: Offset(5.0, 5.0), + color: Colors.black87, + ) + ]), + child: Card( + margin: const EdgeInsets.all(10.0), + child: Column( + children: [ + const Icon(Icons.drag_handle, color: Colors.grey), + const SizedBox(height: 10), + Text('Add Widget', + style: Theme.of(context).textTheme.titleMedium), + const Divider(), + Expanded( + child: NetworkTableTree( + onDragUpdate: onDragUpdate, + onDragEnd: onDragEnd, + ), + ), + Row( + children: [ + const Spacer(), + TextButton( + onPressed: () { + onClose?.call(); + }, + child: const Text('Close'), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/services/field_images.dart b/lib/services/field_images.dart new file mode 100644 index 00000000..08eca6e1 --- /dev/null +++ b/lib/services/field_images.dart @@ -0,0 +1,94 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class FieldImages { + static List fields = []; + + static Field? getFieldFromGame(String game) { + if (fields.isEmpty) { + return null; + } + + return fields.firstWhere((element) => element.game == game); + } + + static Future loadFields(String directory) async { + List filePaths = jsonDecode( + await rootBundle.loadString('AssetManifest.json')) + .keys + .where((String key) => key.contains(directory) && key.contains('.json')) + .toList(); + + for (String file in filePaths) { + await loadField(file); + } + } + + static Future loadField(String filePath) async { + String jsonString = await rootBundle.loadString(filePath); + + Map jsonData = jsonDecode(jsonString); + + fields.add(Field(jsonData: jsonData)); + } +} + +class Field { + final Map jsonData; + + late String? game; + + int? fieldImageWidth; + int? fieldImageHeight; + + late double fieldWidthMeters; + late double fieldHeightMeters; + + late Offset topLeftCorner; + late Offset bottomRightCorner; + + late Offset fieldCenter; + + late Image fieldImage; + + late int pixelsPerMeterHorizontal; + late int pixelsPerMeterVertical; + + Field({required this.jsonData}) { + init(); + } + + void init() { + fieldImage = Image.asset(jsonData['field-image']); + fieldImage.image + .resolve(const ImageConfiguration()) + .addListener(ImageStreamListener((image, synchronousCall) { + fieldImageWidth = image.image.width; + fieldImageHeight = image.image.height; + })); + + // fieldImageWidth = 100; + // fieldImageHeight = 100; + + game = jsonData['game']; + + fieldWidthMeters = jsonData['field-size'][0]; + fieldHeightMeters = jsonData['field-size'][1]; + + topLeftCorner = Offset( + (jsonData['field-corners']['top-left'][0] as int).toDouble(), + (jsonData['field-corners']['top-left'][1] as int).toDouble()); + + bottomRightCorner = Offset( + (jsonData['field-corners']['bottom-right'][0] as int).toDouble(), + (jsonData['field-corners']['bottom-right'][1] as int).toDouble()); + + double fieldWidthPixels = bottomRightCorner.dx - topLeftCorner.dx; + double fieldHeightPixels = bottomRightCorner.dy - topLeftCorner.dy; + + pixelsPerMeterHorizontal = (fieldWidthPixels / fieldWidthMeters).round(); + pixelsPerMeterVertical = (fieldHeightPixels / fieldHeightMeters).round(); + } +} diff --git a/lib/services/globals.dart b/lib/services/globals.dart new file mode 100644 index 00000000..30222c3f --- /dev/null +++ b/lib/services/globals.dart @@ -0,0 +1,10 @@ +class Globals { + static String version = '2023.1.0 Beta'; + static String ipAddress = '127.0.0.1'; + static int teamNumber = 353; + static int gridSize = 128; + static bool snapToGrid = true; + static bool showGrid = false; + + static const double defaultPeriod = 0.033; +} diff --git a/lib/services/nt4.dart b/lib/services/nt4.dart new file mode 100644 index 00000000..adf1beb1 --- /dev/null +++ b/lib/services/nt4.dart @@ -0,0 +1,621 @@ +/// Written by Michael Jansen from Team 3015, Ranger Robotics + +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:messagepack/messagepack.dart'; +import 'package:msgpack_dart/msgpack_dart.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +class NT4Client { + final String serverBaseAddress; + final VoidCallback? onConnect; + final VoidCallback? onDisconnect; + + final Map _subscriptions = {}; + final Set _subscribedTopics = {}; + int _subscriptionUIDCounter = 0; + int _publishUIDCounter = 0; + final Map lastAnnouncedValues = {}; + final Map _clientPublishedTopics = {}; + final Map announcedTopics = {}; + int _clientId = 0; + final String _serverAddr = ''; + bool _serverConnectionActive = false; + int _serverTimeOffsetUS = 0; + + WebSocketChannel? _ws; + + NT4Client({ + required this.serverBaseAddress, + this.onConnect, + this.onDisconnect, + }) { + Timer.periodic(const Duration(milliseconds: 5000), (timer) { + _wsSendTimestamp(); + }); + + _wsConnect(); + } + + NT4Subscription subscribe(String topic, [double period = 0.1]) { + NT4Subscription newSub = NT4Subscription( + topic: topic, + uid: getNewSubUID(), + options: NT4SubscriptionOptions(periodicRateSeconds: period), + ); + + if (_subscribedTopics.contains(newSub)) { + NT4Subscription subscription = _subscribedTopics.lookup(newSub)!; + subscription.useCount++; + + return subscription; + } + + newSub.useCount++; + + _subscriptions[newSub.uid] = newSub; + _subscribedTopics.add(newSub); + _wsSubscribe(newSub); + + if (lastAnnouncedValues.containsKey(topic)) { + newSub.updateValue(lastAnnouncedValues[topic]); + } + + return newSub; + } + + NT4Subscription subscribeAllSamples(String topic) { + NT4Subscription newSub = NT4Subscription( + topic: topic, + uid: getNewSubUID(), + options: const NT4SubscriptionOptions(all: true), + ); + + if (_subscribedTopics.contains(newSub)) { + NT4Subscription subscription = _subscribedTopics.lookup(newSub)!; + subscription.useCount++; + + return subscription; + } + + newSub.useCount++; + + _subscriptions[newSub.uid] = newSub; + _wsSubscribe(newSub); + return newSub; + } + + NT4Subscription subscribeTopicsOnly(String topic) { + NT4Subscription newSub = NT4Subscription( + topic: topic, + uid: getNewSubUID(), + options: const NT4SubscriptionOptions(topicsOnly: true), + ); + + if (_subscribedTopics.contains(newSub)) { + NT4Subscription subscription = _subscribedTopics.lookup(newSub)!; + subscription.useCount++; + + return subscription; + } + + newSub.useCount++; + + _subscriptions[newSub.uid] = newSub; + _wsSubscribe(newSub); + return newSub; + } + + void unSubscribe(NT4Subscription sub) { + sub.useCount--; + + if (sub.useCount <= 0) { + print('${sub.topic}\t${sub.useCount}'); + _subscriptions.remove(sub.uid); + _subscribedTopics.remove(sub); + _wsUnsubscribe(sub); + } + } + + void clearAllSubscriptions() { + for (NT4Subscription sub in _subscriptions.values) { + sub.useCount = 0; + unSubscribe(sub); + } + } + + void setProperties(NT4Topic topic, bool isPersistent, bool isRetained) { + topic.properties['persistent'] = isPersistent; + topic.properties['retained'] = isRetained; + _wsSetProperties(topic); + } + + NT4Topic? getTopicFromName(String topic) { + for (NT4Topic t in announcedTopics.values) { + if (t.name == topic) { + return t; + } + } + if (kDebugMode) { + print('[NT4] Topic not found: $topic'); + } + return null; + } + + NT4Topic publishNewTopic(String name, String type) { + NT4Topic newTopic = NT4Topic(name: name, type: type, properties: {}); + publishTopic(newTopic); + return newTopic; + } + + void publishTopic(NT4Topic topic) { + if (_clientPublishedTopics.containsKey(topic.name)) { + topic.pubUID = _clientPublishedTopics[topic.name]!.pubUID; + return; + } + + topic.pubUID = getNewPubUID(); + _clientPublishedTopics[topic.name] = topic; + _wsPublish(topic); + } + + void unpublishTopic(NT4Topic topic) { + _clientPublishedTopics.remove(topic.name); + _wsUnpublish(topic); + } + + void addSample(NT4Topic topic, dynamic data, [int? timestamp]) { + timestamp ??= _getServerTimeUS(); + + _wsSendBinary( + serialize([topic.pubUID, timestamp, topic.getTypeId(), data])); + + lastAnnouncedValues[topic.name] = data; + for (NT4Subscription sub in _subscriptions.values) { + if (sub.topic == topic.name) { + sub.updateValue(data); + } + } + } + + void addSampleFromName(String topic, dynamic data, [int? timestamp]) { + for (NT4Topic t in announcedTopics.values) { + if (t.name == topic) { + addSample(t, data, timestamp); + return; + } + } + if (kDebugMode) { + print('[NT4] Topic not found: $topic'); + } + } + + int _getClientTimeUS() { + return DateTime.now().microsecondsSinceEpoch; + } + + int _getServerTimeUS() { + return _getClientTimeUS() + _serverTimeOffsetUS; + } + + void _wsSendTimestamp() { + var timeTopic = announcedTopics[-1]; + if (timeTopic != null) { + int timeToSend = _getClientTimeUS(); + addSample(timeTopic, timeToSend, 0); + } + } + + void _wsHandleRecieveTimestamp(int serverTimestamp, int clientTimestamp) { + int rxTime = _getClientTimeUS(); + + int rtt = rxTime - clientTimestamp; + int serverTimeAtRx = (serverTimestamp - rtt / 2.0).round(); + _serverTimeOffsetUS = serverTimeAtRx - rxTime; + } + + void _wsSubscribe(NT4Subscription sub) { + _wsSendJSON('subscribe', sub._toSubscribeJson()); + } + + void _wsUnsubscribe(NT4Subscription sub) { + _wsSendJSON('unsubscribe', sub._toUnsubscribeJson()); + } + + void _wsPublish(NT4Topic topic) { + _wsSendJSON('publish', topic.toPublishJson()); + } + + void _wsUnpublish(NT4Topic topic) { + _wsSendJSON('unpublish', topic.toUnpublishJson()); + } + + void _wsSetProperties(NT4Topic topic) { + _wsSendJSON('setproperties', topic.toPropertiesJson()); + } + + void _wsSendJSON(String method, Map params) { + _ws?.sink.add(jsonEncode([ + { + 'method': method, + 'params': params, + } + ])); + } + + void _wsSendBinary(dynamic data) { + _ws?.sink.add(data); + } + + void _wsConnect() async { + _clientId = Random().nextInt(99999999); + + String serverAddr = 'ws://$serverBaseAddress:5810/nt/elastic'; + + _ws = WebSocketChannel.connect(Uri.parse(serverAddr), + protocols: ['networktables.first.wpi.edu']); + + try { + await _ws!.ready; + } catch (e) { + // Failed to connect... try again + Future.delayed(const Duration(seconds: 1), _wsConnect); + return; + } + + _ws!.stream.listen( + (data) { + if (!_serverConnectionActive) { + _serverConnectionActive = true; + onConnect?.call(); + } + _wsOnMessage(data); + }, + onDone: _wsOnClose, + onError: (err) { + if (kDebugMode) { + print('NT4 ERR: $err'); + } + }, + ); + + NT4Topic timeTopic = NT4Topic( + name: "Time", + type: NT4TypeStr.kInt, + id: -1, + pubUID: -1, + properties: {}); + announcedTopics[timeTopic.id] = timeTopic; + + _wsSendTimestamp(); + + for (NT4Topic topic in _clientPublishedTopics.values) { + _wsPublish(topic); + _wsSetProperties(topic); + } + + for (NT4Subscription sub in _subscriptions.values) { + _wsSubscribe(sub); + } + } + + void _wsOnClose() { + _ws = null; + _serverConnectionActive = false; + + onDisconnect?.call(); + + announcedTopics.clear(); + + lastAnnouncedValues.clear(); + + if (kDebugMode) { + print('[NT4] Connection closed. Attempting to reconnect in 1s'); + } + Future.delayed(const Duration(seconds: 1), _wsConnect); + } + + void _wsOnMessage(data) { + if (data is String) { + var rxArr = jsonDecode(data.toString()); + + if (rxArr is! List) { + if (kDebugMode) { + print('[NT4] Ignoring text message, not an array'); + } + } + + for (var msg in rxArr) { + if (msg is! Map) { + if (kDebugMode) { + print('[NT4] Ignoring text message, not a json object'); + } + continue; + } + + var method = msg['method']; + var params = msg['params']; + + if (method == null || method is! String) { + if (kDebugMode) { + print('[NT4] Ignoring text message, method not string'); + } + continue; + } + + if (params == null || params is! Map) { + if (kDebugMode) { + print('[NT4] Ignoring text message, params not json object'); + } + continue; + } + + if (method == 'announce') { + NT4Topic? currentTopic; + for (NT4Topic topic in _clientPublishedTopics.values) { + if (params['name'] == topic.name) { + currentTopic = topic; + } + } + + NT4Topic newTopic = NT4Topic( + name: params['name'], + type: params['type'], + id: params['id'], + pubUID: params['pubid'] ?? (currentTopic?.pubUID ?? 0), + properties: params['properties']); + announcedTopics[newTopic.id] = newTopic; + } else if (method == 'unannounce') { + NT4Topic? removedTopic = announcedTopics[params['id']]; + if (removedTopic == null) { + if (kDebugMode) { + print( + '[NT4] Ignorining unannounce, topic was not previously announced'); + } + return; + } + announcedTopics.remove(removedTopic.id); + } else if (method == 'properties') { + } else { + if (kDebugMode) { + print('[NT4] Ignoring text message - unknown method $method'); + } + return; + } + } + } else { + var u = Unpacker.fromList(data); + + bool done = false; + while (!done) { + try { + var msg = u.unpackList(); + + int topicID = msg[0] as int; + int timestampUS = msg[1] as int; + // int typeID = msg[2] as int; + var value = msg[3]; + + if (topicID >= 0) { + NT4Topic topic = announcedTopics[topicID]!; + lastAnnouncedValues[topic.name] = value; + for (NT4Subscription sub in _subscriptions.values) { + if (sub.topic == topic.name) { + sub.updateValue(value); + } + } + } else if (topicID == -1) { + _wsHandleRecieveTimestamp(timestampUS, value as int); + } else { + if (kDebugMode) { + print('[NT4] ignoring binary data, invalid topic ID'); + } + } + } catch (err) { + done = true; + } + } + } + } + + int getNewSubUID() { + _subscriptionUIDCounter++; + return _subscriptionUIDCounter + _clientId; + } + + int getNewPubUID() { + _publishUIDCounter++; + return _publishUIDCounter + _clientId; + } +} + +class NT4SubscriptionOptions { + final double periodicRateSeconds; + final bool all; + final bool topicsOnly; + final bool prefix; + + const NT4SubscriptionOptions({ + this.periodicRateSeconds = 0.1, + this.all = false, + this.topicsOnly = false, + this.prefix = true, + }); + + Map toJson() { + return { + 'periodic': periodicRateSeconds, + 'all': all, + 'topicsonly': topicsOnly, + 'prefix': prefix, + }; + } + + @override + bool operator ==(Object other) => + other is NT4SubscriptionOptions && + other.runtimeType == runtimeType && + other.periodicRateSeconds == periodicRateSeconds && + other.all == all && + other.topicsOnly == topicsOnly && + other.prefix == prefix; + + @override + int get hashCode => + Object.hashAllUnordered([periodicRateSeconds, all, topicsOnly, prefix]); +} + +class NT4Topic { + final String name; + final String type; + int id; + int pubUID; + final Map properties; + + NT4Topic({ + required this.name, + required this.type, + this.id = 0, + this.pubUID = 0, + required this.properties, + }); + + Map toPublishJson() { + return { + 'name': name, + 'type': type, + 'pubuid': pubUID, + }; + } + + Map toUnpublishJson() { + return { + 'name': name, + 'pubuid': pubUID, + }; + } + + Map toPropertiesJson() { + return { + 'name': name, + 'update': properties, + }; + } + + int getTypeId() { + return NT4TypeStr.typeMap[type]!; + } +} + +class NT4Subscription { + final String topic; + final NT4SubscriptionOptions options; + final int uid; + + int useCount = 0; + + Object? currentValue; + final List _listeners = []; + + NT4Subscription({ + required this.topic, + this.options = const NT4SubscriptionOptions(), + this.uid = -1, + }); + + void listen(Function(Object?) onChanged) { + _listeners.add(onChanged); + } + + Stream periodicStream() async* { + while (true) { + yield currentValue; + await Future.delayed( + Duration(milliseconds: (options.periodicRateSeconds * 1000).round())); + } + } + + void updateValue(Object? value) { + currentValue = value; + for (var listener in _listeners) { + listener(currentValue); + } + } + + Map _toSubscribeJson() { + return { + 'topics': [topic], + 'options': options.toJson(), + 'subuid': uid, + }; + } + + Map _toUnsubscribeJson() { + return { + 'subuid': uid, + }; + } + + @override + bool operator ==(Object other) => + other is NT4Subscription && + other.runtimeType == runtimeType && + other.topic == topic && + other.options == options; + + @override + int get hashCode => Object.hashAllUnordered([topic, options]); +} + +class NT4ValueReq { + final List topics; + + const NT4ValueReq({ + this.topics = const [], + }); + + Map toGetValsJson() { + return { + 'topics': topics, + }; + } +} + +class NT4TypeStr { + static final Map typeMap = { + 'boolean': 0, + 'double': 1, + 'int': 2, + 'float': 3, + 'string': 4, + 'json': 4, + 'raw': 5, + 'rpc': 5, + 'msgpack': 5, + 'protobuff': 5, + 'boolean[]': 16, + 'double[]': 17, + 'int[]': 18, + 'float[]': 19, + 'string[]': 20, + }; + + static const kBool = 'boolean'; + static const kFloat64 = 'double'; + static const kInt = 'int'; + static const kFloat32 = 'float'; + static const kString = 'string'; + static const kJson = 'json'; + static const kBinaryRaw = 'raw'; + static const kBinaryRPC = 'rpc'; + static const kBinaryMsgpack = 'msgpack'; + static const kBinaryProtobuf = 'protobuf'; + static const kBoolArr = 'boolean[]'; + static const kFloat64Arr = 'double[]'; + static const kIntArr = 'int[]'; + static const kFloat32Arr = 'float[]'; + static const kStringArr = 'string[]'; +} diff --git a/lib/services/nt4_connection.dart b/lib/services/nt4_connection.dart new file mode 100644 index 00000000..9d14759d --- /dev/null +++ b/lib/services/nt4_connection.dart @@ -0,0 +1,95 @@ +import 'dart:ui'; + +import 'package:elastic_dashboard/services/globals.dart'; +import 'package:elastic_dashboard/services/nt4.dart'; + +class NT4Connection { + static late NT4Client nt4Client; + + static late NT4Subscription allTopicsSubscription; + + static List onConnectedListeners = []; + static List onDisconnectedListeners = []; + + static bool connected = false; + + static void connect() async { + nt4Client = NT4Client( + serverBaseAddress: Globals.ipAddress, + onConnect: () { + connected = true; + + for (VoidCallback callback in onConnectedListeners) { + callback.call(); + } + }, + onDisconnect: () { + connected = false; + + for (VoidCallback callback in onDisconnectedListeners) { + callback.call(); + } + }); + + // Allows all published topics to be announced + allTopicsSubscription = nt4Client.subscribe(''); + } + + static void addConnectedListener(VoidCallback callback) { + onConnectedListeners.add(callback); + } + + static void addDisconnectedListener(VoidCallback callback) { + onDisconnectedListeners.add(callback); + } + + static Stream connectionStatus() async* { + yield connected; + bool lastYielded = connected; + + while (true) { + if (connected != lastYielded) { + yield connected; + lastYielded = connected; + } + await Future.delayed(const Duration(seconds: 1)); + } + } + + static NT4Subscription subscribe(String topic, [double period = 0.1]) { + return nt4Client.subscribe(topic, period); + } + + static void unSubscribe(NT4Subscription subscription) { + nt4Client.unSubscribe(subscription); + } + + static NT4Topic? getTopicFromSubscription(NT4Subscription subscription) { + return nt4Client.getTopicFromName(subscription.topic); + } + + static NT4Topic? getTopicFromName(String topic) { + return nt4Client.getTopicFromName(topic); + } + + static Object? getLastAnnouncedValue(String topic) { + if (nt4Client.lastAnnouncedValues.containsKey(topic)) { + return nt4Client.lastAnnouncedValues[topic]; + } + + return null; + } + + static void unpublishTopic(NT4Topic topic) { + nt4Client.unpublishTopic(topic); + } + + static void updateDataFromSubscription( + NT4Subscription subscription, dynamic data) { + nt4Client.addSampleFromName(subscription.topic, data); + } + + static void updateDataFromTopic(NT4Topic topic, dynamic data) { + nt4Client.addSample(topic, data); + } +} diff --git a/lib/widgets/custom_appbar.dart b/lib/widgets/custom_appbar.dart new file mode 100644 index 00000000..0046cc6a --- /dev/null +++ b/lib/widgets/custom_appbar.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:titlebar_buttons/titlebar_buttons.dart'; +import 'package:window_manager/window_manager.dart'; + +class CustomAppBar extends AppBar { + final String titleText; + final MenuBar menuBar; + static const ThemeType buttonType = ThemeType.auto; + + CustomAppBar({super.key, this.titleText = 'Elastic', required this.menuBar}) + : super( + toolbarHeight: 40, + backgroundColor: const Color.fromARGB(255, 35, 35, 35), + leading: menuBar, + leadingWidth: menuBar.children.length * 55, + actions: [ + DecoratedMinimizeButton( + type: buttonType, + onPressed: () async => await windowManager.minimize(), + ), + DecoratedMaximizeButton( + type: buttonType, + onPressed: () async { + if (await windowManager.isMaximized()) { + windowManager.unmaximize(); + } else { + windowManager.maximize(); + } + }, + ), + DecoratedCloseButton( + type: buttonType, + onPressed: () async => await windowManager.close(), + ), + ], + title: _WindowDragArea( + child: Row( + children: [ + Expanded(child: Center(child: Text(titleText))), + ], + ), + ), + centerTitle: true, + ); +} + +class _WindowDragArea extends StatelessWidget { + final Widget? child; + + const _WindowDragArea({this.child}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onPanStart: (details) { + windowManager.startDragging(); + }, + onDoubleTap: () async { + if (await windowManager.isMaximized()) { + windowManager.unmaximize(); + } else { + windowManager.maximize(); + } + }, + child: child ?? Container(), + ); + } +} diff --git a/lib/widgets/dashboard_grid.dart b/lib/widgets/dashboard_grid.dart new file mode 100644 index 00000000..55ce14cd --- /dev/null +++ b/lib/widgets/dashboard_grid.dart @@ -0,0 +1,458 @@ +import 'package:contextmenu/contextmenu.dart'; +import 'package:elastic_dashboard/services/nt4_connection.dart'; +import 'package:elastic_dashboard/widgets/draggable_widget_container.dart'; +import 'package:elastic_dashboard/widgets/nt4_widgets/nt4_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +// Used to refresh the dashboard grid when a widget is added or removed +// This doesn't use a stateless widget since everything has to be rendered at program startup or data will be lost +class DashboardGridModel extends ChangeNotifier { + void onUpdate() { + notifyListeners(); + } +} + +class DashboardGrid extends StatelessWidget { + final Map? jsonData; + + final List _widgetContainers = []; + final List _draggingContainers = []; + + MapEntry? _containerDraggingIn; + + final VoidCallback? onAddWidgetPressed; + + DashboardGridModel? model; + + DashboardGrid({super.key, this.jsonData, this.onAddWidgetPressed}) { + init(); + } + + DashboardGrid.fromJson( + {super.key, required this.jsonData, this.onAddWidgetPressed}) { + init(); + } + + void init() { + if (jsonData != null) { + loadFromJson(jsonData!); + } + } + + void loadFromJson(Map jsonData) { + for (Map containerData in jsonData['containers']) { + _widgetContainers.add(DraggableWidgetContainer.fromJson( + key: UniqueKey(), + enabled: NT4Connection.connected, + validMoveLocation: isValidMoveLocation, + jsonData: containerData, + onUpdate: (widget) { + refresh(); + }, + onDragBegin: (widget) { + _draggingContainers.add(widget); + refresh(); + }, + onDragEnd: (widget) { + _draggingContainers.toSet().lookup(widget)?.child?.dispose(); + _draggingContainers.remove(widget); + refresh(); + }, + onResizeBegin: (widget) { + _draggingContainers.add(widget); + refresh(); + }, + onResizeEnd: (widget) { + _draggingContainers.toSet().lookup(widget)?.child?.dispose(); + _draggingContainers.remove(widget); + refresh(); + }, + )); + } + } + + Map toJson() { + var containers = []; + for (DraggableWidgetContainer container in _widgetContainers) { + containers.add(container.toJson()); + } + + return { + 'containers': containers, + }; + } + + /// Returns weather `widget` is able to be moved to `location` without overlapping anything else. + /// + /// This only applies to widgets that already have a place on the grid + bool isValidMoveLocation(DraggableWidgetContainer widget, Rect location) { + BuildContext? context = (key as GlobalKey).currentContext; + Size? gridSize; + if (context != null) { + gridSize = MediaQuery.of(context).size; + } + + for (DraggableWidgetContainer container in _widgetContainers) { + if (container.displayRect.overlaps(location) && widget != container) { + return false; + } else if (gridSize != null && + (location.right > gridSize.width || + location.bottom > gridSize.height)) { + return false; + } + } + return true; + } + + /// Returns weather `location` will overlap with widgets already on the dashboard + bool isValidLocation(Rect location) { + for (DraggableWidgetContainer container in _widgetContainers) { + if (container.displayRect.overlaps(location)) { + return false; + } + } + return true; + } + + void onNTConnect() { + for (DraggableWidgetContainer container in _widgetContainers) { + container.enabled = true; + + container.refresh(); + } + + refresh(); + } + + void onNTDisconnect() { + for (DraggableWidgetContainer container in _widgetContainers) { + container.enabled = false; + + container.refresh(); + } + + refresh(); + } + + void addDragInWidget(WidgetContainer widget, Offset globalOffset) { + _containerDraggingIn = MapEntry(widget, globalOffset); + refresh(); + } + + void placeDragInWidget(WidgetContainer widget) { + if (_containerDraggingIn == null) { + return; + } + + Offset globalPosition = _containerDraggingIn!.value; + + BuildContext? context = (key as GlobalKey).currentContext; + + if (context == null) { + return; + } + + RenderBox? ancestor = context.findAncestorRenderObjectOfType(); + + Offset localPosition = ancestor!.globalToLocal(globalPosition); + + if (localPosition.dy < 0) { + localPosition = Offset(localPosition.dx, 0); + } + + if (localPosition.dx < 0) { + localPosition = Offset(0, localPosition.dy); + } + + double previewX = DraggableWidgetContainer.snapToGrid(localPosition.dx); + double previewY = DraggableWidgetContainer.snapToGrid(localPosition.dy); + + Rect previewLocation = + Rect.fromLTWH(previewX, previewY, widget.width, widget.height); + + if (!isValidLocation(previewLocation)) { + _containerDraggingIn = null; + + if (widget.child is NT4Widget) { + (widget.child as NT4Widget).dispose(); + } + + refresh(); + return; + } + + addWidget( + widget, + Rect.fromLTWH(previewLocation.left, previewLocation.top, + previewLocation.width, previewLocation.height), + enabled: NT4Connection.connected); + + _containerDraggingIn = null; + + if (widget.child is NT4Widget) { + (widget.child as NT4Widget).dispose(); + } + + refresh(); + } + + void addWidget(WidgetContainer widget, Rect initialPosition, + {bool enabled = true}) { + _widgetContainers.add(DraggableWidgetContainer( + key: UniqueKey(), + title: widget.title, + initialPosition: initialPosition, + validMoveLocation: isValidMoveLocation, + enabled: enabled, + onUpdate: (widget) { + refresh(); + }, + onDragBegin: (widget) { + _draggingContainers.add(widget); + refresh(); + }, + onDragEnd: (widget) { + _draggingContainers.remove(widget); + refresh(); + }, + onResizeBegin: (widget) { + _draggingContainers.add(widget); + refresh(); + }, + onResizeEnd: (widget) { + _draggingContainers.remove(widget); + refresh(); + }, + child: widget.child! as NT4Widget)); + } + + void removeWidget(DashboardGridModel model, DraggableWidgetContainer widget) { + _widgetContainers.remove(widget); + widget.child?.dispose(); + widget.child?.unSubscribe(); + refresh(); + } + + void clearWidgets(DashboardGridModel model) { + _widgetContainers.clear(); + refresh(); + } + + void refresh() { + Future(() async { + model?.onUpdate(); + }); + } + + @override + Widget build(BuildContext context) { + model = context.watch(); + + List dashboardWidgets = []; + List draggingWidgets = []; + List draggingInWidgets = []; + List previewOutlines = []; + + for (DraggableWidgetContainer container in _draggingContainers) { + // Add the widget container above the others + draggingWidgets.add( + Positioned( + left: container.draggablePositionRect.left, + top: container.draggablePositionRect.top, + child: WidgetContainer( + title: container.title, + width: container.draggablePositionRect.width, + height: container.draggablePositionRect.height, + opacity: (container.model?.previewVisible ?? false) ? 0.80 : 1.00, + child: container.child, + ), + ), + ); + + // Display the outline so it doesn't get covered + previewOutlines.add( + Positioned( + left: container.model?.preview.left, + top: container.model?.preview.top, + width: container.model?.preview.width, + height: container.model?.preview.height, + child: Visibility( + visible: container.model?.previewVisible ?? false, + child: Container( + decoration: BoxDecoration( + color: (container.model?.validLocation ?? false) + ? Colors.white.withOpacity(0.25) + : Colors.black.withOpacity(0.1), + borderRadius: BorderRadius.circular(25.0), + border: Border.all( + color: (container.model?.validLocation ?? false) + ? Colors.lightGreenAccent.shade400 + : Colors.red, + width: 5.0), + ), + ), + ), + ), + ); + } + + for (DraggableWidgetContainer container in _widgetContainers) { + dashboardWidgets.add( + ContextMenuArea( + builder: (context) => [ + ListTile( + enabled: false, + dense: true, + visualDensity: + const VisualDensity(horizontal: 0.0, vertical: -4.0), + title: Center(child: Text(container.title ?? '')), + ), + ListTile( + dense: true, + visualDensity: + const VisualDensity(horizontal: 0.0, vertical: -4.0), + leading: const Icon(Icons.edit_outlined), + title: const Text('Edit Properties'), + onTap: () { + Navigator.of(context).pop(); + container.showEditProperties(context); + }, + ), + ListTile( + dense: true, + visualDensity: + const VisualDensity(horizontal: 0.0, vertical: -4.0), + leading: const Icon(Icons.delete_outlined), + title: const Text('Remove'), + onTap: () { + Navigator.of(context).pop(); + removeWidget(model!, container); + }, + ), + ], + child: ChangeNotifierProvider( + create: (context) => WidgetContainerModel(), + child: container, + ), + ), + ); + } + + // Also render any containers that are being dragged into the grid + if (_containerDraggingIn != null) { + WidgetContainer container = _containerDraggingIn!.key; + Offset globalOffset = _containerDraggingIn!.value; + + RenderBox? ancestor = context.findAncestorRenderObjectOfType(); + + Offset localPosition = ancestor!.globalToLocal(globalOffset); + + if (localPosition.dx < 0) { + localPosition = Offset(0, localPosition.dy); + } + + if (localPosition.dy < 0) { + localPosition = Offset(localPosition.dx, 0); + } + + draggingInWidgets.add( + Positioned( + left: localPosition.dx, + top: localPosition.dy, + child: container, + ), + ); + + double previewX = DraggableWidgetContainer.snapToGrid(localPosition.dx); + double previewY = DraggableWidgetContainer.snapToGrid(localPosition.dy); + + Rect previewLocation = + Rect.fromLTWH(previewX, previewY, container.width, container.height); + + previewOutlines.add( + Positioned( + left: previewLocation.left, + top: previewLocation.top, + width: previewLocation.width, + height: previewLocation.height, + child: Container( + decoration: BoxDecoration( + color: (isValidLocation(previewLocation)) + ? Colors.white.withOpacity(0.25) + : Colors.black.withOpacity(0.1), + borderRadius: BorderRadius.circular(25.0), + border: Border.all( + color: (isValidLocation(previewLocation)) + ? Colors.lightGreenAccent.shade400 + : Colors.red, + width: 5.0), + ), + ), + ), + ); + } + + defaultMenuBuilder(context) => [ + ListTile( + dense: true, + visualDensity: const VisualDensity(horizontal: 0.0, vertical: -4.0), + leading: const Icon(Icons.add), + title: const Text('Add Widget'), + onTap: () { + Navigator.of(context).pop(); + onAddWidgetPressed?.call(); + }, + ), + ListTile( + dense: true, + visualDensity: const VisualDensity(horizontal: 0.0, vertical: -4.0), + leading: const Icon(Icons.clear), + title: const Text('Clear Layout'), + onTap: () { + Navigator.of(context).pop(); + clearWidgets(model!); + }, + ), + ]; + + return GestureDetector( + // Needed to prevent 2 context menus from showing at the same time + behavior: HitTestBehavior.translucent, + onSecondaryTapDown: (details) { + if (!isValidLocation(Rect.fromLTWH( + details.localPosition.dx, details.localPosition.dy, 0, 0))) { + return; + } + showContextMenu( + details.globalPosition, + context, + defaultMenuBuilder, + 8.0, + 320.0, + ); + }, + onLongPressStart: (details) { + if (!isValidLocation(Rect.fromLTWH( + details.localPosition.dx, details.localPosition.dy, 0, 0))) { + return; + } + showContextMenu( + details.globalPosition, + context, + defaultMenuBuilder, + 8.0, + 320.0, + ); + }, + child: Stack( + children: [ + ...dashboardWidgets, + ...previewOutlines, + ...draggingWidgets, + ...draggingInWidgets, + ], + ), + ); + } +} diff --git a/lib/widgets/dialog_widgets/dialog_color_picker.dart b/lib/widgets/dialog_widgets/dialog_color_picker.dart new file mode 100644 index 00000000..f67b42ef --- /dev/null +++ b/lib/widgets/dialog_widgets/dialog_color_picker.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_colorpicker/flutter_colorpicker.dart'; + +class DialogColorPicker extends StatefulWidget { + final Function(Color color) onColorPicked; + final String label; + final Color initialColor; + + const DialogColorPicker( + {super.key, + required this.onColorPicked, + required this.label, + required this.initialColor}); + + @override + State createState() => _DialogColorPickerState(); +} + +class _DialogColorPickerState extends State { + late Color initialColor; + Color? selectedColor; + + TextEditingController hexInputController = TextEditingController(); + + @override + void initState() { + super.initState(); + + initialColor = widget.initialColor; + } + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + mainAxisSize: MainAxisSize.min, + children: [ + Text(widget.label), + const SizedBox(width: 10), + ElevatedButton( + onPressed: () { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Select Color'), + content: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SingleChildScrollView( + child: ColorPicker( + pickerColor: selectedColor ?? initialColor, + enableAlpha: false, + hexInputBar: true, + hexInputController: hexInputController, + onColorChanged: (Color color) { + widget.onColorPicked.call(color); + setState(() { + selectedColor = color; + }); + }, + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: hexInputController, + autofocus: false, + decoration: InputDecoration( + constraints: const BoxConstraints( + maxWidth: 150, + ), + contentPadding: + const EdgeInsets.fromLTRB(8, 4, 8, 4), + labelText: 'Hex Code', + prefixText: '#', + counterText: '', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(4)), + ), + maxLength: 6, + inputFormatters: [ + // Any custom input formatter can be passed + // here or use any Form validator you want. + UpperCaseTextFormatter(), + FilteringTextInputFormatter.allow( + RegExp(kValidHexPattern)), + ], + ), + ) + ], + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + widget.onColorPicked.call(initialColor); + + setState(() { + selectedColor = initialColor; + }); + }, + child: const Text('Reset'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + setState(() { + initialColor = selectedColor ?? initialColor; + }); + }, + child: const Text('Save'), + ), + ], + ); + }, + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: selectedColor ?? initialColor, + ), + child: Container(), + ), + ], + ); + } +} diff --git a/lib/widgets/dialog_widgets/dialog_dropdown_chooser.dart b/lib/widgets/dialog_widgets/dialog_dropdown_chooser.dart new file mode 100644 index 00000000..de520ace --- /dev/null +++ b/lib/widgets/dialog_widgets/dialog_dropdown_chooser.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; + +class DialogDropdownChooser extends StatefulWidget { + final List? choices; + final T? initialValue; + final Function(T?) onSelectionChanged; + + const DialogDropdownChooser( + {super.key, + this.choices, + this.initialValue, + required this.onSelectionChanged}); + + @override + State> createState() => + _DialogDropdownChooserState(); +} + +class _DialogDropdownChooserState extends State> { + late T? selectedValue; + + @override + void initState() { + super.initState(); + + selectedValue = widget.initialValue; + } + + @override + Widget build(BuildContext context) { + ThemeData theme = Theme.of(context); + + return Padding( + padding: const EdgeInsets.all(4.0), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + border: Border.all(color: theme.colorScheme.outline), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + child: ExcludeFocus( + child: DropdownButton( + isExpanded: true, + borderRadius: BorderRadius.circular(8.0), + style: const TextStyle(fontWeight: FontWeight.normal), + items: widget.choices?.map((T item) { + return DropdownMenuItem( + value: item, + child: Text(item.toString()), + ); + }).toList(), + value: selectedValue, + onChanged: (value) { + setState(() { + selectedValue = value; + + widget.onSelectionChanged.call(value); + }); + }, + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/dialog_widgets/dialog_text_input.dart b/lib/widgets/dialog_widgets/dialog_text_input.dart new file mode 100644 index 00000000..7900a9d5 --- /dev/null +++ b/lib/widgets/dialog_widgets/dialog_text_input.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class DialogTextInput extends StatelessWidget { + final Function(String value) onSubmit; + final TextInputFormatter? formatter; + final String? label; + final String? initialText; + final bool allowEmptySubmission; + + TextEditingController? textEditingController; + + DialogTextInput( + {super.key, + required this.onSubmit, + this.label, + this.initialText, + this.allowEmptySubmission = false, + this.formatter, + this.textEditingController}) { + textEditingController ??= TextEditingController(text: initialText); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(4.0), + child: Focus( + onFocusChange: (value) { + // Don't consider the text submitted when focus is gained + if (value) { + return; + } + String textValue = textEditingController!.text; + if (textValue.isNotEmpty || allowEmptySubmission) { + onSubmit.call(textValue); + } + }, + child: TextField( + onSubmitted: (value) { + if (value.isNotEmpty) { + onSubmit.call(value); + } + }, + controller: textEditingController, + inputFormatters: (formatter != null) ? [formatter!] : null, + decoration: InputDecoration( + contentPadding: const EdgeInsets.fromLTRB(8, 4, 8, 4), + labelText: label, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(4)), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/draggable_dialog.dart b/lib/widgets/draggable_dialog.dart new file mode 100644 index 00000000..d2406df6 --- /dev/null +++ b/lib/widgets/draggable_dialog.dart @@ -0,0 +1,50 @@ +import 'package:elastic_dashboard/services/globals.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_box_transform/flutter_box_transform.dart'; + +class DraggableDialog extends StatefulWidget { + final Widget dialog; + final Rect initialPosition; + + const DraggableDialog({ + super.key, + required this.dialog, + this.initialPosition = const Rect.fromLTWH(50.0, 50.0, 400, 500), + }); + + @override + State createState() => _DraggableDialogState(); +} + +class _DraggableDialogState extends State { + late Rect position; + + @override + void initState() { + super.initState(); + + position = widget.initialPosition; + } + + @override + Widget build(BuildContext context) { + return TransformableBox( + constraints: BoxConstraints( + minWidth: Globals.gridSize.toDouble(), + minHeight: Globals.gridSize.toDouble(), + maxWidth: double.infinity, + maxHeight: double.infinity), + clampingRect: const Rect.fromLTWH(0, 0, double.infinity, double.infinity), + allowFlippingWhileResizing: false, + visibleHandles: const {}, + resizeModeResolver: () => ResizeMode.freeform, + rect: position, + onChanged: (result, event) { + setState(() => position = result.rect); + }, + contentBuilder: (context, rect, flip) { + return widget.dialog; + }, + ); + } +} diff --git a/lib/widgets/draggable_widget_container.dart b/lib/widgets/draggable_widget_container.dart new file mode 100644 index 00000000..18d853c3 --- /dev/null +++ b/lib/widgets/draggable_widget_container.dart @@ -0,0 +1,685 @@ +import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_dropdown_chooser.dart'; +import 'package:elastic_dashboard/widgets/nt4_widgets/multi-topic/camera_stream.dart'; +import 'package:elastic_dashboard/widgets/nt4_widgets/multi-topic/field_widget.dart'; +import 'package:elastic_dashboard/widgets/nt4_widgets/multi-topic/fms_info.dart'; +import 'package:elastic_dashboard/widgets/nt4_widgets/multi-topic/gyro.dart'; +import 'package:elastic_dashboard/widgets/nt4_widgets/multi-topic/pid_controller.dart'; +import 'package:elastic_dashboard/widgets/nt4_widgets/multi-topic/power_distribution.dart'; +import 'package:elastic_dashboard/widgets/nt4_widgets/multi-topic/combo_box_chooser.dart'; +import 'package:elastic_dashboard/widgets/nt4_widgets/multi-topic/split_button_chooser.dart'; +import 'package:elastic_dashboard/widgets/nt4_widgets/single_topic/match_time.dart'; +import 'package:elastic_dashboard/widgets/nt4_widgets/single_topic/text_display.dart'; +import 'package:elastic_dashboard/widgets/nt4_widgets/single_topic/toggle_button.dart'; +import 'package:elastic_dashboard/widgets/nt4_widgets/single_topic/toggle_switch.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_box_transform/flutter_box_transform.dart'; +import 'package:provider/provider.dart'; + +import '../services/globals.dart'; +import 'dialog_widgets/dialog_text_input.dart'; +import 'nt4_widgets/single_topic/boolean_box.dart'; +import 'nt4_widgets/single_topic/graph.dart'; +import 'nt4_widgets/nt4_widget.dart'; +import 'nt4_widgets/single_topic/number_bar.dart'; + +class WidgetContainerModel extends ChangeNotifier { + Rect rect = Rect.fromLTWH( + 0, 0, Globals.gridSize.toDouble(), Globals.gridSize.toDouble()); + Rect preview = Rect.fromLTWH( + 0, 0, Globals.gridSize.toDouble(), Globals.gridSize.toDouble()); + bool previewVisible = false; + bool validLocation = true; + + void setDraggableRect(Rect newRect) { + rect = newRect; + notifyListeners(); + } + + void setPreview(Rect newPreview) { + preview = newPreview; + notifyListeners(); + } + + void setPreviewVisible(bool visible) { + previewVisible = visible; + notifyListeners(); + } + + void setValidLocation(bool valid) { + validLocation = valid; + notifyListeners(); + } + + void refresh() { + notifyListeners(); + } +} + +class DraggableWidgetContainer extends StatelessWidget { + String? title; + + NT4Widget? child; + + Rect? initialPosition; + + Rect draggablePositionRect = Rect.fromLTWH( + 0, 0, Globals.gridSize.toDouble(), Globals.gridSize.toDouble()); + + Rect displayRect = Rect.fromLTWH( + 0, 0, Globals.gridSize.toDouble(), Globals.gridSize.toDouble()); + + late Rect dragStartLocation; + + bool enabled = false; + bool dragging = false; + + Map? jsonData = {}; + + bool Function(DraggableWidgetContainer widget, Rect location) + validMoveLocation; + Function(DraggableWidgetContainer widget)? onUpdate; + Function(DraggableWidgetContainer widget)? onDragBegin; + Function(DraggableWidgetContainer widget)? onDragEnd; + Function(DraggableWidgetContainer widget)? onResizeBegin; + Function(DraggableWidgetContainer widget)? onResizeEnd; + + WidgetContainerModel? model; + + DraggableWidgetContainer({ + super.key, + required this.title, + required this.child, + required this.validMoveLocation, + this.enabled = false, + this.initialPosition, + this.onUpdate, + this.onDragBegin, + this.onDragEnd, + this.onResizeBegin, + this.onResizeEnd, + }) { + init(); + } + + DraggableWidgetContainer.fromJson({ + super.key, + required this.validMoveLocation, + required this.jsonData, + this.enabled = false, + this.onUpdate, + this.onDragBegin, + this.onDragEnd, + this.onResizeBegin, + this.onResizeEnd, + }) { + init(); + } + + static double snapToGrid(double value) { + if (Globals.snapToGrid) { + return (value / Globals.gridSize).roundToDouble() * Globals.gridSize; + } else { + return value; + } + } + + void changeChildToType(String? type) { + if (type == null) { + return; + } + + if (type == child!.type) { + return; + } + + NT4Widget? newWidget; + + switch (type) { + case 'Boolean Box': + newWidget = BooleanBox( + key: UniqueKey(), topic: child!.topic, period: child!.period); + break; + case 'Toggle Switch': + newWidget = ToggleSwitch( + key: UniqueKey(), topic: child!.topic, period: child!.period); + break; + case 'Toggle Button': + newWidget = ToggleButton( + key: UniqueKey(), topic: child!.topic, period: child!.period); + break; + case 'Graph': + newWidget = GraphWidget( + key: UniqueKey(), topic: child!.topic, period: child!.period); + break; + case 'Number Bar': + newWidget = NumberBar( + key: UniqueKey(), topic: child!.topic, period: child!.period); + break; + case 'Text Display': + newWidget = TextDisplay( + key: UniqueKey(), topic: child!.topic, period: child!.period); + break; + case 'Match Time': + newWidget = MatchTimeWidget( + key: UniqueKey(), topic: child!.topic, period: child!.period); + break; + case 'ComboBox Chooser': + newWidget = ComboBoxChooser( + key: UniqueKey(), topic: child!.topic, period: child!.period); + case 'Split Button Chooser': + newWidget = SplitButtonChooser( + key: UniqueKey(), topic: child!.topic, period: child!.period); + } + + if (newWidget == null) { + return; + } + + child!.dispose(); + child = newWidget; + + refresh(); + } + + void refresh() { + Future(() async { + model?.refresh(); + }); + } + + void showEditProperties(BuildContext context) { + showDialog( + context: context, + builder: (context) { + List? childProperties = child?.getEditProperties(context); + + return AlertDialog( + title: const Text('Edit Properties'), + content: SizedBox( + width: 353, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Settings for the widget container + const Text('Container Settings'), + const SizedBox(height: 5), + DialogTextInput( + onSubmit: (value) { + title = value; + + refresh(); + }, + label: 'Title', + initialText: title, + ), + const SizedBox(height: 5), + Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Center(child: Text('Widget Type')), + DialogDropdownChooser( + choices: child!.getAvailableDisplayTypes(), + initialValue: child!.type, + onSelectionChanged: (String? value) { + Navigator.of(context).pop(); + + changeChildToType(value); + + showEditProperties(context); + }, + ), + ], + ), + const Divider(), + // Settings for the widget inside (only if there are properties) + if (childProperties != null && + childProperties.isNotEmpty) ...[ + Text('${child?.type} Widget Settings'), + const SizedBox(height: 5), + ...childProperties, + const Divider(), + ], + // Settings for the NT4 Connection + const Text('Network Tables Settings (Advanced)'), + const SizedBox(height: 5), + Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + // Topic + Flexible( + child: DialogTextInput( + onSubmit: (value) { + child?.topic = value; + child?.resetSubscription(); + }, + label: 'Topic', + initialText: child?.topic, + ), + ), + const SizedBox(width: 5), + // Period + Flexible( + child: DialogTextInput( + onSubmit: (value) { + double? newPeriod = double.tryParse(value); + if (newPeriod == null) { + return; + } + + child?.period = newPeriod; + child?.resetSubscription(); + }, + formatter: FilteringTextInputFormatter.allow( + RegExp(r"[0-9.]")), + label: 'Period', + initialText: child!.period.toString(), + ), + ), + ], + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + child?.refresh(); + }, + child: const Text('Close'), + ), + ], + ); + }, + ); + } + + Map toJson() { + return { + 'title': title, + 'x': displayRect.left, + 'y': displayRect.top, + 'width': displayRect.width, + 'height': displayRect.height, + 'type': child?.type, + 'properties': getChildJson(), + }; + } + + void init() { + if (title == null) { + fromJson(jsonData!); + } else { + displayRect = initialPosition!; + } + + draggablePositionRect = displayRect; + dragStartLocation = displayRect; + } + + void fromJson(Map jsonData) { + title = jsonData['title']; + + double x = jsonData['x']; + + double y = jsonData['y']; + + double width = jsonData['width']; + + double height = jsonData['height']; + + displayRect = Rect.fromLTWH(x, y, width, height); + + child = createChildFromJson(jsonData); + } + + NT4Widget? createChildFromJson(Map jsonData) { + switch (jsonData['type']) { + case 'Boolean Box': + return BooleanBox.fromJson( + key: UniqueKey(), + jsonData: jsonData['properties'], + ); + case 'Toggle Switch': + return ToggleSwitch.fromJson( + key: UniqueKey(), + jsonData: jsonData['properties'], + ); + case 'Toggle Button': + return ToggleButton.fromJson( + key: UniqueKey(), + jsonData: jsonData['properties'], + ); + case 'Graph': + return GraphWidget.fromJson( + key: UniqueKey(), + jsonData: jsonData['properties'], + ); + case 'Match Time': + return MatchTimeWidget.fromJson( + key: UniqueKey(), + jsonData: jsonData['properties'], + ); + case 'Number Bar': + return NumberBar.fromJson( + key: UniqueKey(), + jsonData: jsonData['properties'], + ); + case 'Text Display': + return TextDisplay.fromJson( + key: UniqueKey(), + jsonData: jsonData['properties'], + ); + case 'Gyro': + return Gyro.fromJson( + key: UniqueKey(), + jsonData: jsonData['properties'], + ); + case 'Field': + return FieldWidget.fromJson( + key: UniqueKey(), + jsonData: jsonData['properties'], + ); + case 'PowerDistribution': + return PowerDistribution.fromJson( + key: UniqueKey(), + jsonData: jsonData['properties'], + ); + case 'PIDController': + return PIDControllerWidget.fromJson( + key: UniqueKey(), + jsonData: jsonData['properties'], + ); + case 'ComboBox Chooser': + return ComboBoxChooser.fromJson( + key: UniqueKey(), + jsonData: jsonData['properties'], + ); + case 'Split Button Chooser': + return SplitButtonChooser.fromJson( + key: UniqueKey(), + jsonData: jsonData['properties'], + ); + case 'FMSInfo': + return FMSInfo.fromJson( + key: UniqueKey(), + jsonData: jsonData['properties'], + ); + case 'Camera Stream': + return CameraStreamWidget.fromJson( + key: UniqueKey(), + jsonData: jsonData['properties'], + ); + } + return null; + } + + Map? getChildJson() { + return child!.toJson(); + } + + @override + Widget build(BuildContext context) { + WidgetContainerModel model = context.watch(); + + this.model = model; + + return Stack( + children: [ + // Positioned( + // left: model.preview.left, + // top: model.preview.top, + // width: model.preview.width, + // height: model.preview.height, + // child: Visibility( + // visible: model.previewVisible, + // child: Container( + // decoration: BoxDecoration( + // color: (model.validLocation) + // ? Colors.white.withOpacity(0.25) + // : Colors.black.withOpacity(0.1), + // borderRadius: BorderRadius.circular(25.0), + // border: Border.all( + // color: (model.validLocation) + // ? Colors.lightGreenAccent.shade400 + // : Colors.red, + // width: 5.0), + // ), + // ), + // ), + // ), + Positioned( + left: displayRect.left, + top: displayRect.top, + child: WidgetContainer( + title: title, + width: displayRect.width, + height: displayRect.height, + opacity: (model.previewVisible) ? 0.25 : 1.00, + child: Opacity( + opacity: (enabled) ? 1.00 : 0.50, + child: AbsorbPointer( + absorbing: !enabled, + child: ChangeNotifierProvider( + create: (context) => NT4WidgetNotifier(), + child: child, + ), + ), + ), + ), + ), + TransformableBox( + handleAlignment: HandleAlignment.inside, + constraints: BoxConstraints( + minWidth: Globals.gridSize.toDouble(), + minHeight: Globals.gridSize.toDouble(), + ), + clampingRect: + const Rect.fromLTWH(0, 0, double.infinity, double.infinity), + rect: draggablePositionRect, + resizeModeResolver: () => ResizeMode.freeform, + allowFlippingWhileResizing: false, + visibleHandles: const {}, + contentBuilder: (BuildContext context, Rect rect, Flip flip) { + return Container(); + }, + onDragStart: (event) { + dragging = true; + dragStartLocation = displayRect; + onDragBegin?.call(this); + }, + onResizeStart: (handle, event) { + dragging = true; + dragStartLocation = displayRect; + onResizeBegin?.call(this); + }, + onChanged: (result, event) { + Rect newRect = result.rect; + + double newX = snapToGrid(newRect.left); + double newY = snapToGrid(newRect.top); + + double newWidth = snapToGrid(newRect.width); + double newHeight = snapToGrid(newRect.height); + + if (newWidth < Globals.gridSize) { + newWidth = Globals.gridSize.toDouble(); + } + + if (newHeight < Globals.gridSize) { + newHeight = Globals.gridSize.toDouble(); + } + + Rect preview = Rect.fromLTWH( + newX, newY, newWidth.toDouble(), newHeight.toDouble()); + draggablePositionRect = result.rect; + + model.setPreview(preview); + model.setDraggableRect(draggablePositionRect); + model.setPreviewVisible(true); + model.setValidLocation(validMoveLocation.call(this, preview)); + + onUpdate?.call(this); + }, + onDragEnd: (event) { + dragging = false; + if (model.validLocation) { + draggablePositionRect = model.preview; + } else { + draggablePositionRect = dragStartLocation; + } + + displayRect = draggablePositionRect; + + model.setPreview(draggablePositionRect); + model.setPreviewVisible(false); + model.setValidLocation(true); + + onDragEnd?.call(this); + }, + onDragCancel: () { + dragging = false; + if (model.validLocation) { + draggablePositionRect = model.preview; + } else { + draggablePositionRect = dragStartLocation; + } + + displayRect = draggablePositionRect; + + model.setPreview(draggablePositionRect); + model.setPreviewVisible(false); + model.setValidLocation(true); + + onDragEnd?.call(this); + }, + onResizeEnd: (handle, event) { + dragging = false; + if (model.validLocation) { + draggablePositionRect = model.preview; + } else { + draggablePositionRect = dragStartLocation; + } + + displayRect = draggablePositionRect; + + model.setPreview(draggablePositionRect); + model.setPreviewVisible(false); + model.setValidLocation(true); + + onResizeEnd?.call(this); + }, + onResizeCancel: (handle) { + dragging = false; + if (model.validLocation) { + draggablePositionRect = model.preview; + } else { + draggablePositionRect = dragStartLocation; + } + + displayRect = draggablePositionRect; + + model.setPreview(draggablePositionRect); + model.setPreviewVisible(false); + model.setValidLocation(true); + + onResizeEnd?.call(this); + }, + ), + ], + ); + } +} + +class WidgetContainer extends StatelessWidget { + const WidgetContainer({ + super.key, + required this.title, + required this.child, + required this.width, + required this.height, + this.opacity = 1.0, + }); + + final double opacity; + final String? title; + final Widget? child; + final double width; + final double height; + + @override + Widget build(BuildContext context) { + ThemeData theme = Theme.of(context); + + return SizedBox( + width: width, + height: height, + child: Padding( + padding: const EdgeInsets.all(5.0), + child: Opacity( + opacity: opacity, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(25.0), + color: const Color.fromARGB(255, 40, 40, 40), + boxShadow: const [ + BoxShadow( + offset: Offset(2, 2), + blurRadius: 10.5, + spreadRadius: 0, + color: Colors.black, + ), + ], + ), + child: Center( + child: Column( + children: [ + // Title + LayoutBuilder(builder: (context, constraints) { + return Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(25.0), + topRight: Radius.circular(25.0), + ), + color: theme.colorScheme.primaryContainer, + ), + width: constraints.maxWidth, + alignment: Alignment.center, + child: Padding( + padding: const EdgeInsets.all(10.0), + child: Text( + title!, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall, + ), + ), + ); + }), + // The child widget + Expanded( + child: Padding( + padding: const EdgeInsets.only( + top: 5.0, left: 10.0, right: 10.0, bottom: 10.0), + child: Container( + alignment: Alignment.center, + child: child, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/editable_tab_bar.dart b/lib/widgets/editable_tab_bar.dart new file mode 100644 index 00000000..ce1a1410 --- /dev/null +++ b/lib/widgets/editable_tab_bar.dart @@ -0,0 +1,278 @@ +import 'package:contextmenu/contextmenu.dart'; +import 'package:elastic_dashboard/services/globals.dart'; +import 'package:elastic_dashboard/widgets/dashboard_grid.dart'; +import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_text_input.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:transitioned_indexed_stack/transitioned_indexed_stack.dart'; + +class TabData { + String name; + + TabData({required this.name}); +} + +class EditableTabBar extends StatelessWidget { + final List tabViews; + final List tabData; + + final Function(TabData tab, DashboardGrid grid) onTabCreate; + final Function(TabData tab, DashboardGrid grid) onTabDestroy; + final Function(int index, TabData newData) onTabRename; + final Function(int index) onTabChanged; + + final int currentIndex; + + const EditableTabBar({ + super.key, + required this.currentIndex, + required this.tabData, + required this.tabViews, + required this.onTabCreate, + required this.onTabDestroy, + required this.onTabRename, + required this.onTabChanged, + }); + + void renameTab(BuildContext context, int index) { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Rename Tab'), + content: DialogTextInput( + onSubmit: (value) { + tabData[index].name = value; + onTabRename.call(index, tabData[index]); + }, + initialText: tabData[index].name, + label: 'Name', + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('Save'), + ), + ], + ); + }, + ); + } + + void createTab() { + String tabName = 'Tab ${tabData.length + 1}'; + TabData data = TabData(name: tabName); + DashboardGrid grid = DashboardGrid(key: GlobalKey()); + + onTabCreate.call(data, grid); + } + + void closeTab(int index) { + if (tabData.length == 1) { + return; + } + + TabData data = tabData[index]; + DashboardGrid grid = tabViews[index]; + + tabData.removeAt(index); + tabViews.removeAt(index); + + // if (currentIndex > 0) { + // currentIndex--; + // } + + onTabDestroy.call(data, grid); + } + + void showTabCloseConfirmation( + BuildContext context, String tabName, Function() onClose) { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + onClose.call(); + }, + child: const Text('OK')), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('Cancel')), + ], + content: Text('Do you want to close the tab "$tabName"?'), + title: const Text('Confirm Tab Close'), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + ThemeData theme = Theme.of(context); + + return Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + // Tab bar + Container( + width: double.infinity, + height: 36, + color: theme.colorScheme.primaryContainer, + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: ListView.builder( + physics: const BouncingScrollPhysics(), + scrollDirection: Axis.horizontal, + shrinkWrap: true, + itemCount: tabData.length, + itemBuilder: (context, index) { + return ContextMenuArea( + builder: (context) => [ + ListTile( + enabled: false, + dense: true, + visualDensity: const VisualDensity( + horizontal: 0.0, vertical: -4.0), + title: Center(child: Text(tabData[index].name)), + ), + ListTile( + dense: true, + visualDensity: const VisualDensity( + horizontal: 0.0, vertical: -4.0), + leading: const Icon(Icons.drive_file_rename_outline_outlined), + title: const Text('Rename'), + onTap: () { + Navigator.of(context).pop(); + renameTab(context, index); + }, + ), + ListTile( + dense: true, + visualDensity: const VisualDensity( + horizontal: 0.0, vertical: -4.0), + leading: const Icon(Icons.close), + title: const Text('Close'), + onTap: () { + Navigator.of(context).pop(); + showTabCloseConfirmation( + context, tabData[index].name, () { + closeTab(index); + }); + }, + ), + ], + child: GestureDetector( + onTap: () { + onTabChanged.call(index); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutExpo, + margin: const EdgeInsets.only( + left: 5.0, right: 5.0, top: 5.0), + padding: const EdgeInsets.symmetric( + horizontal: 10.0, vertical: 5.0), + decoration: BoxDecoration( + color: (currentIndex == index) + ? theme.colorScheme.onPrimaryContainer + : Colors.transparent, + borderRadius: (currentIndex == index) + ? const BorderRadius.only( + topLeft: Radius.circular(10.0), + topRight: Radius.circular(10.0), + ) + : BorderRadius.zero, + ), + child: Center( + child: Row( + children: [ + Text( + tabData[index].name, + style: theme.textTheme.bodyMedium!.copyWith( + color: (currentIndex == index) + ? theme.colorScheme.primaryContainer + : theme.colorScheme.onPrimaryContainer, + ), + ), + const SizedBox(width: 10), + IconButton( + onPressed: () { + showTabCloseConfirmation( + context, tabData[index].name, () { + closeTab(index); + }); + }, + padding: const EdgeInsets.all(0.0), + alignment: Alignment.center, + constraints: const BoxConstraints( + minWidth: 15.0, + minHeight: 15.0, + ), + iconSize: 14, + color: (currentIndex == index) + ? theme.colorScheme.primaryContainer + : theme.colorScheme.onPrimaryContainer, + icon: const Icon(Icons.close), + ), + ], + ), + ), + ), + ), + ); + }, + ), + ), + IconButton( + onPressed: () { + createTab(); + }, + alignment: Alignment.center, + icon: const Icon(Icons.add), + ) + ], + ), + ), + // Dashboard grid area + Flexible( + child: Stack( + children: [ + Visibility( + visible: Globals.showGrid, + child: GridPaper( + color: const Color.fromARGB(50, 195, 232, 243), + interval: Globals.gridSize.toDouble(), + divisions: 1, + subdivisions: 1, + child: Container(), + ), + ), + FadeIndexedStack( + beginOpacity: 0.0, + endOpacity: 1.0, + index: currentIndex, + children: [ + for (DashboardGrid grid in tabViews) + ChangeNotifierProvider( + create: (context) => DashboardGridModel(), + child: grid, + ), + ], + ), + ], + ), + ) + ], + ); + } +} diff --git a/lib/widgets/mjpeg.dart b/lib/widgets/mjpeg.dart new file mode 100644 index 00000000..8d927f90 --- /dev/null +++ b/lib/widgets/mjpeg.dart @@ -0,0 +1,276 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:http/http.dart'; +import 'package:visibility_detector/visibility_detector.dart'; + +class _MjpegStateNotifier extends ChangeNotifier { + bool _mounted = true; + bool _visible = true; + + _MjpegStateNotifier() : super(); + + bool get mounted => _mounted; + + bool get visible => _visible; + + set visible(value) { + _visible = value; + notifyListeners(); + } + + @override + void dispose() { + _mounted = false; + notifyListeners(); + super.dispose(); + } +} + +/// A preprocessor for each JPEG frame from an MJPEG stream. +class MjpegPreprocessor { + List? process(List frame) => frame; +} + +/// An Mjpeg. +class Mjpeg extends HookWidget { + final String stream; + final BoxFit? fit; + final double? width; + final double? height; + final bool isLive; + final Duration timeout; + final WidgetBuilder? loading; + final Client? httpClient; + final Widget Function(BuildContext contet, dynamic error, dynamic stack)? + error; + final Map headers; + final MjpegPreprocessor? preprocessor; + + late _StreamManager _manager; + + Mjpeg({ + this.httpClient, + this.isLive = false, + this.width, + this.timeout = const Duration(seconds: 5), + this.height, + this.fit, + required this.stream, + this.error, + this.loading, + this.headers = const {}, + this.preprocessor, + Key? key, + }) : super(key: key); + + Future cancelSubscription() async { + await _manager.cancelSubscription(); + } + + @override + Widget build(BuildContext context) { + final image = useState(null); + final state = useMemoized(() => _MjpegStateNotifier()); + final visible = useListenable(state); + final errorState = useState?>(null); + final isMounted = useIsMounted(); + final manager = useMemoized( + () => _manager = _StreamManager( + stream, + isLive && visible.visible, + headers, + timeout, + httpClient ?? Client(), + preprocessor ?? MjpegPreprocessor(), + isMounted, + ), + [ + stream, + isLive, + visible.visible, + timeout, + httpClient, + preprocessor, + isMounted + ]); + final key = useMemoized(() => UniqueKey(), [manager]); + + useEffect(() { + errorState.value = null; + manager.updateStream(context, image, errorState); + return manager.dispose; + }, [manager]); + + if (errorState.value != null) { + return SizedBox( + width: width, + height: height, + child: error == null + ? Center( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + '${errorState.value}', + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.red), + ), + ), + ) + : error!(context, errorState.value!.first, errorState.value!.last), + ); + } + + if (image.value == null) { + return SizedBox( + width: width, + height: height, + child: loading == null + ? const Center(child: CircularProgressIndicator()) + : loading!(context)); + } + + return VisibilityDetector( + key: key, + child: Image( + image: image.value!, + width: width, + height: height, + gaplessPlayback: true, + fit: fit, + ), + onVisibilityChanged: (VisibilityInfo info) { + if (visible.mounted) { + visible.visible = info.visibleFraction != 0; + } + }, + ); + } +} + +class _StreamManager { + static const _trigger = 0xFF; + static const _soi = 0xD8; + static const _eoi = 0xD9; + + final String stream; + final bool isLive; + final Duration _timeout; + final Map headers; + final Client _httpClient; + final MjpegPreprocessor _preprocessor; + final bool Function() _mounted; + // ignore: cancel_subscriptions + StreamSubscription? _subscription; + + _StreamManager(this.stream, this.isLive, this.headers, this._timeout, + this._httpClient, this._preprocessor, this._mounted); + + Future cancelSubscription() async { + if (_subscription != null) { + await _subscription!.cancel(); + _subscription = null; + } + } + + Future dispose() async { + // if (_subscription != null) { + // await _subscription!.cancel(); + // _subscription = null; + // } + // _httpClient.close(); + } + + void _sendImage(BuildContext context, ValueNotifier image, + ValueNotifier errorState, List chunks) async { + // pass image through preprocessor sending to [Image] for rendering + final List? imageData = _preprocessor.process(chunks); + if (imageData == null) return; + + final imageMemory = MemoryImage(Uint8List.fromList(imageData)); + if (_mounted()) { + errorState.value = null; + image.value = imageMemory; + } + } + + void updateStream(BuildContext context, ValueNotifier image, + ValueNotifier?> errorState) async { + try { + final request = Request("GET", Uri.parse(stream)); + request.headers.addAll(headers); + final response = await _httpClient.send(request).timeout( + _timeout); //timeout is to prevent process to hang forever in some case + + if (response.statusCode >= 200 && response.statusCode < 300) { + var carry = []; + _subscription = response.stream.listen((chunk) async { + if (carry.isNotEmpty && carry.last == _trigger) { + if (chunk.first == _eoi) { + carry.add(chunk.first); + _sendImage(context, image, errorState, carry); + carry = []; + if (!isLive) { + dispose(); + } + } + } + + for (var i = 0; i < chunk.length - 1; i++) { + final d = chunk[i]; + final d1 = chunk[i + 1]; + + if (d == _trigger && d1 == _soi) { + carry = []; + carry.add(d); + } else if (d == _trigger && d1 == _eoi && carry.isNotEmpty) { + carry.add(d); + carry.add(d1); + + _sendImage(context, image, errorState, carry); + carry = []; + if (!isLive) { + dispose(); + } + } else if (carry.isNotEmpty) { + carry.add(d); + if (i == chunk.length - 2) { + carry.add(d1); + } + } + } + }, onError: (error, stack) { + try { + if (_mounted()) { + errorState.value = [error, stack]; + image.value = null; + } + } catch (ex) {} + dispose(); + }, cancelOnError: true); + } else { + if (_mounted()) { + errorState.value = [ + HttpException('Stream returned ${response.statusCode} status'), + StackTrace.current + ]; + image.value = null; + } + dispose(); + } + } catch (error, stack) { + // we ignore those errors in case play/pause is triggers + if (!error + .toString() + .contains('Connection closed before full header was received')) { + if (_mounted()) { + errorState.value = [error, stack]; + image.value = null; + } + } + } + } +} diff --git a/lib/widgets/network_tree/network_table_tree.dart b/lib/widgets/network_tree/network_table_tree.dart new file mode 100644 index 00000000..2ee8e544 --- /dev/null +++ b/lib/widgets/network_tree/network_table_tree.dart @@ -0,0 +1,201 @@ +import 'dart:ui'; + +import 'package:elastic_dashboard/services/nt4.dart'; +import 'package:elastic_dashboard/services/nt4_connection.dart'; +import 'package:elastic_dashboard/widgets/draggable_widget_container.dart'; +import 'package:elastic_dashboard/widgets/network_tree/tree_row.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_fancy_tree_view/flutter_fancy_tree_view.dart'; + +class NetworkTableTree extends StatefulWidget { + final Function(Offset globalPosition, WidgetContainer widget)? onDragUpdate; + final Function(WidgetContainer widget)? onDragEnd; + + const NetworkTableTree({super.key, this.onDragUpdate, this.onDragEnd}); + + @override + State createState() => _NetworkTableTreeState(); +} + +class _NetworkTableTreeState extends State { + final TreeRow root = TreeRow(topic: '/', rowName: ''); + late final TreeController treeController; + + late final Function(Offset globalPosition, WidgetContainer widget)? + onDragUpdate; + late final Function(WidgetContainer widget)? onDragEnd; + + @override + void initState() { + super.initState(); + + treeController = TreeController( + roots: root.children, childrenProvider: (node) => node.children); + + onDragUpdate = widget.onDragUpdate; + onDragEnd = widget.onDragEnd; + } + + void createRows(NT4Topic nt4Topic) { + String topic = nt4Topic.name; + + List rows = topic.substring(1).split('/'); + TreeRow? current; + String currentTopic = ''; + + for (String row in rows) { + currentTopic += '/$row'; + + bool lastElement = currentTopic == topic; + + if (current != null) { + if (current.hasRow(row)) { + current = current.getRow(row); + } else { + current = current.createNewRow( + topic: currentTopic, + name: row, + nt4Topic: (lastElement) ? nt4Topic : null); + } + } else { + if (root.hasRow(row)) { + current = root.getRow(row); + } else { + current = root.createNewRow( + topic: currentTopic, + name: row, + nt4Topic: (lastElement) ? nt4Topic : null); + } + } + } + } + + @override + Widget build(BuildContext context) { + List topics = []; + + for (NT4Topic topic in NT4Connection.nt4Client.announcedTopics.values) { + if (topic.name == 'Time') { + continue; + } + + topics.add(topic); + } + + for (NT4Topic topic in topics) { + createRows(topic); + } + + root.sort(); + + return TreeView( + treeController: treeController, + nodeBuilder: (BuildContext context, TreeEntry entry) { + return TreeTile( + key: UniqueKey(), + entry: entry, + onDragUpdate: onDragUpdate, + onDragEnd: onDragEnd, + onTap: () { + setState(() => treeController.toggleExpansion(entry.node)); + }, + ); + }, + ); + } +} + +class TreeTile extends StatelessWidget { + TreeTile({ + super.key, + required this.entry, + required this.onTap, + this.onDragUpdate, + this.onDragEnd, + }); + + final TreeEntry entry; + final VoidCallback onTap; + final Function(Offset globalPosition, WidgetContainer widget)? onDragUpdate; + final Function(WidgetContainer widget)? onDragEnd; + + WidgetContainer? draggingWidget; + + @override + Widget build(BuildContext context) { + TextStyle trailingStyle = + Theme.of(context).textTheme.bodySmall!.copyWith(color: Colors.grey); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: onTap, + child: GestureDetector( + onPanStart: (details) { + if (draggingWidget != null) { + return; + } + + // Prevents 2 finger drags from dragging a widget + if (details.kind != null && + details.kind! == PointerDeviceKind.trackpad) { + draggingWidget = null; + return; + } + + draggingWidget = entry.node.toWidgetContainer(); + }, + onPanUpdate: (details) { + if (draggingWidget == null) { + return; + } + + onDragUpdate?.call( + details.globalPosition - + Offset(draggingWidget!.width, draggingWidget!.height) / 2, + draggingWidget!); + }, + onPanEnd: (details) { + if (draggingWidget == null) { + return; + } + + onDragEnd?.call(draggingWidget!); + + draggingWidget = null; + }, + // onPanDown: (details) {}, + // onPanCancel: () {}, + child: Padding( + padding: EdgeInsetsDirectional.only(start: entry.level * 16.0), + child: Column( + children: [ + ListTile( + style: ListTileStyle.drawer, + dense: true, + contentPadding: const EdgeInsets.only(right: 20.0), + leading: (entry.hasChildren) + ? FolderButton( + openedIcon: const Icon(Icons.arrow_drop_down), + closedIcon: const Icon(Icons.arrow_right), + iconSize: 24, + isOpen: entry.hasChildren ? entry.isExpanded : null, + onPressed: entry.hasChildren ? onTap : null, + ) + : const SizedBox(width: 8.0), + title: Text(entry.node.rowName), + trailing: (entry.node.nt4Topic != null) + ? Text(entry.node.nt4Topic!.type, style: trailingStyle) + : null, + // subtitle: + ), + ], + ), + ), + ), + ), + const Divider(), + ], + ); + } +} diff --git a/lib/widgets/network_tree/tree_row.dart b/lib/widgets/network_tree/tree_row.dart new file mode 100644 index 00000000..cb59628d --- /dev/null +++ b/lib/widgets/network_tree/tree_row.dart @@ -0,0 +1,195 @@ +import 'package:elastic_dashboard/services/nt4.dart'; +import 'package:elastic_dashboard/services/nt4_connection.dart'; +import 'package:elastic_dashboard/widgets/draggable_widget_container.dart'; +import 'package:elastic_dashboard/widgets/nt4_widgets/multi-topic/camera_stream.dart'; +import 'package:elastic_dashboard/widgets/nt4_widgets/multi-topic/field_widget.dart'; +import 'package:elastic_dashboard/widgets/nt4_widgets/multi-topic/fms_info.dart'; +import 'package:elastic_dashboard/widgets/nt4_widgets/multi-topic/gyro.dart'; +import 'package:elastic_dashboard/widgets/nt4_widgets/multi-topic/pid_controller.dart'; +import 'package:elastic_dashboard/widgets/nt4_widgets/multi-topic/power_distribution.dart'; +import 'package:elastic_dashboard/widgets/nt4_widgets/multi-topic/combo_box_chooser.dart'; +import 'package:elastic_dashboard/widgets/nt4_widgets/single_topic/boolean_box.dart'; +import 'package:elastic_dashboard/widgets/nt4_widgets/nt4_widget.dart'; +import 'package:elastic_dashboard/widgets/nt4_widgets/single_topic/text_display.dart'; +import 'package:flutter/material.dart'; + +class TreeRow { + final String topic; + final String rowName; + + final NT4Topic? nt4Topic; + + List children = []; + + TreeRow({required this.topic, required this.rowName, this.nt4Topic}); + + bool hasRow(String name) { + for (TreeRow child in children) { + if (child.rowName == name) { + return true; + } + } + return false; + } + + bool hasRows(List names) { + for (String row in names) { + if (!hasRow(row)) { + return false; + } + } + + return true; + } + + void addRow(TreeRow row) { + if (hasRow(row.rowName)) { + return; + } + + children.add(row); + } + + TreeRow getRow(String name) { + for (TreeRow row in children) { + if (row.rowName == name) { + return row; + } + } + + throw Exception("Trying to retrieve a row that doesn't exist"); + } + + TreeRow createNewRow( + {required String topic, required String name, NT4Topic? nt4Topic}) { + TreeRow newRow = TreeRow(topic: topic, rowName: name, nt4Topic: nt4Topic); + addRow(newRow); + + return newRow; + } + + void sort() { + children.sort((a, b) { + if (a.children.isNotEmpty && b.children.isEmpty) { + return -1; + } else if (a.children.isEmpty && b.children.isNotEmpty) { + return 1; + } + + return a.rowName.compareTo(b.rowName); + }); + + for (TreeRow child in children) { + child.sort(); + } + } + + NT4Widget? getPrimaryWidget() { + if (nt4Topic == null) { + if (hasRow('.type')) { + return getTypedWidget('$topic/.type'); + } + + // If it's a camera stream + if (hasRows([ + 'Property', + 'PropertyInfo', + 'RawProperty', + 'RawPropertyInfo', + 'connected', + 'description', + 'mode', + 'modes', + 'source', + 'streams', + ])) { + return CameraStreamWidget(key: UniqueKey(), topic: topic); + } + + return null; + } + + switch (nt4Topic!.type) { + case NT4TypeStr.kFloat64: + case NT4TypeStr.kInt: + case NT4TypeStr.kFloat32: + case NT4TypeStr.kBoolArr: + case NT4TypeStr.kFloat64Arr: + case NT4TypeStr.kFloat32Arr: + case NT4TypeStr.kIntArr: + case NT4TypeStr.kString: + case NT4TypeStr.kStringArr: + return TextDisplay( + key: UniqueKey(), + topic: nt4Topic!.name, + ); + case NT4TypeStr.kBool: + return BooleanBox( + key: UniqueKey(), + topic: nt4Topic!.name, + ); + } + return null; + } + + NT4Widget? getTypedWidget(String typeTopic) { + String? type = NT4Connection.getLastAnnouncedValue(typeTopic) as String?; + + if (type == null) { + return null; + } + + switch (type) { + case 'Gyro': + return Gyro(key: UniqueKey(), topic: topic); + case 'Field2d': + return FieldWidget(key: UniqueKey(), topic: topic); + case 'PowerDistribution': + return PowerDistribution(key: UniqueKey(), topic: topic); + case 'PIDController': + return PIDControllerWidget(key: UniqueKey(), topic: topic); + case 'String Chooser': + return ComboBoxChooser(key: UniqueKey(), topic: topic); + case 'FMSInfo': + return FMSInfo(key: UniqueKey(), topic: topic); + } + + return null; + } + + WidgetContainer? toWidgetContainer() { + NT4Widget? primary = getPrimaryWidget(); + + if (primary == null) { + return null; + } + + double normalGridSize = DraggableWidgetContainer.snapToGrid(128); + + double width = normalGridSize; + double height = normalGridSize; + + if (primary is Gyro) { + width = normalGridSize * 2; + height = normalGridSize * 2; + } else if (primary is FieldWidget) { + width = normalGridSize * 3; + height = normalGridSize * 2; + } else if (primary is PowerDistribution) { + width = normalGridSize * 3; + height = normalGridSize * 4; + } else if (primary is PIDControllerWidget) { + width = normalGridSize * 2; + height = normalGridSize * 3; + } else if (primary is FMSInfo) { + width = normalGridSize * 3; + } + + return WidgetContainer( + title: rowName, + width: width, + height: height, + child: primary, + ); + } +} diff --git a/lib/widgets/nt4_widgets/multi-topic/camera_stream.dart b/lib/widgets/nt4_widgets/multi-topic/camera_stream.dart new file mode 100644 index 00000000..1397c512 --- /dev/null +++ b/lib/widgets/nt4_widgets/multi-topic/camera_stream.dart @@ -0,0 +1,118 @@ +import 'package:elastic_dashboard/services/globals.dart'; +import 'package:elastic_dashboard/services/nt4.dart'; +import 'package:elastic_dashboard/services/nt4_connection.dart'; +import 'package:elastic_dashboard/widgets/mjpeg.dart'; +import 'package:elastic_dashboard/widgets/nt4_widgets/nt4_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart'; +import 'package:provider/provider.dart'; + +class CameraStreamWidget extends StatelessWidget with NT4Widget { + @override + String type = 'Camera Stream'; + + late String streamsTopic; + + Object? rawStreams; + Mjpeg? streamWidget; + + late NT4Subscription streamsSubscription; + late Client httpClient; + bool clientOpen = false; + + CameraStreamWidget({super.key, required topic, period = Globals.defaultPeriod}) { + super.topic = topic; + super.period = period; + + init(); + } + + CameraStreamWidget.fromJson( + {super.key, required Map jsonData}) { + topic = jsonData['topic'] ?? ''; + period = jsonData['period'] ?? Globals.defaultPeriod; + + init(); + } + + @override + void init() { + super.init(); + + streamsTopic = '$topic/streams'; + streamsSubscription = NT4Connection.subscribe(streamsTopic, super.period); + + httpClient = Client(); + clientOpen = true; + } + + @override + void dispose() { + Future(() async { + await streamWidget?.cancelSubscription(); + + httpClient.close(); + clientOpen = false; + }); + + super.dispose(); + } + + @override + void unSubscribe() { + super.unSubscribe(); + + NT4Connection.unSubscribe(streamsSubscription); + } + + @override + Widget build(BuildContext context) { + notifier = context.watch(); + + return StreamBuilder( + stream: streamsSubscription.periodicStream(), + builder: (context, snapshot) { + if (!NT4Connection.connected) { + httpClient.close(); + clientOpen = false; + } + Object? value = snapshot.data; + + bool createNewWidget = streamWidget == null || + rawStreams != value || + (!clientOpen && NT4Connection.connected); + + rawStreams = value; + + List rawStreamsList = rawStreams as List? ?? []; + + List streams = []; + for (Object? stream in rawStreamsList) { + if (stream == null || stream is! String) { + continue; + } + + streams.add(stream.substring(5)); + } + + if (streams.isEmpty) { + return Container(); + } + + if (createNewWidget) { + if (!clientOpen) { + httpClient = Client(); + clientOpen = true; + } + streamWidget = Mjpeg( + httpClient: httpClient, + isLive: true, + stream: streams[0], + ); + } + + return streamWidget!; + }, + ); + } +} diff --git a/lib/widgets/nt4_widgets/multi-topic/combo_box_chooser.dart b/lib/widgets/nt4_widgets/multi-topic/combo_box_chooser.dart new file mode 100644 index 00000000..ac44b3e0 --- /dev/null +++ b/lib/widgets/nt4_widgets/multi-topic/combo_box_chooser.dart @@ -0,0 +1,239 @@ +import 'package:elastic_dashboard/services/nt4.dart'; +import 'package:elastic_dashboard/services/nt4_connection.dart'; +import 'package:elastic_dashboard/widgets/nt4_widgets/nt4_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class ComboBoxChooser extends StatelessWidget with NT4Widget { + @override + String type = 'ComboBox Chooser'; + + late String optionsTopicName; + late String selectedTopicName; + late String activeTopicName; + late String defaultTopicName; + + String? selectedChoice; + + StringChooserData? _previousData; + + NT4Topic? selectedTopic; + NT4Topic? activeTopic; + + ComboBoxChooser({super.key, required topic, period = 0.033}) { + super.topic = topic; + super.period = period; + + init(); + } + + ComboBoxChooser.fromJson({super.key, required Map jsonData}) { + super.topic = jsonData['topic'] ?? ''; + super.period = jsonData['period'] ?? 0.033; + + init(); + } + + @override + void init() { + super.init(); + + optionsTopicName = '$topic/options'; + selectedTopicName = '$topic/selected'; + activeTopicName = '$topic/active'; + defaultTopicName = '$topic/default'; + } + + void publishSelectedValue(String? selected) { + if (selected == null || !NT4Connection.connected) { + return; + } + + selectedTopic ??= NT4Connection.nt4Client + .publishNewTopic(selectedTopicName, NT4TypeStr.kString); + + NT4Connection.updateDataFromTopic(selectedTopic!, selected); + } + + void publishActiveValue(String? active) { + if (active == null || !NT4Connection.connected) { + return; + } + + bool publishTopic = activeTopic == null; + + activeTopic ??= NT4Connection.getTopicFromName(activeTopicName); + + if (activeTopic == null) { + return; + } + + if (publishTopic) { + NT4Connection.nt4Client.publishTopic(activeTopic!); + } + + NT4Connection.updateDataFromTopic(activeTopic!, active); + } + + @override + Widget build(BuildContext context) { + notifier = context.watch(); + + return StreamBuilder( + stream: subscription?.periodicStream(), + builder: (context, snapshot) { + List rawOptions = + NT4Connection.getLastAnnouncedValue(optionsTopicName) + as List? ?? + []; + + List options = []; + + for (Object? option in rawOptions) { + if (option == null || option is! String) { + continue; + } + + options.add(option); + } + + String? active = + NT4Connection.getLastAnnouncedValue(activeTopicName) as String?; + if (active != null && active == '') { + active = null; + } + + String? selected = + NT4Connection.getLastAnnouncedValue(selectedTopicName) as String?; + if (selected != null && selected == '') { + selected = null; + } + + String? defaultOption = + NT4Connection.getLastAnnouncedValue(defaultTopicName) as String?; + + if (defaultOption != null && defaultOption == '') { + defaultOption = null; + } + + if (!NT4Connection.connected) { + active = null; + selected = null; + defaultOption = null; + } + + StringChooserData currentData = StringChooserData( + options: options, + active: active, + defaultOption: defaultOption, + selected: selected); + + // If a choice has been selected previously but the topic on NT has no value, publish it + // This can happen if NT happens to restart + if (currentData.selectedChanged(_previousData)) { + if (selected != null && selectedChoice != selected) { + selectedChoice = selected; + } + } else if (currentData.activeChanged(_previousData) || active == null) { + if (selected == null && selectedChoice != null) { + if (options.contains(selectedChoice!)) { + publishSelectedValue(selectedChoice!); + } else if (options.isNotEmpty) { + selectedChoice = active; + } + } + } + + // If nothing is selected but NT has an active value, set the selected to the NT value + // This happens on program startup + if (active != null && selectedChoice == null) { + selectedChoice = active; + } + + _previousData = currentData; + + bool showWarning = active != selectedChoice; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + _StringChooserDropdown( + selected: selectedChoice, + options: options, + onValueChanged: (String? value) { + publishSelectedValue(value); + + selectedChoice = value; + }, + ), + const SizedBox(width: 5), + (showWarning) + ? const Tooltip( + message: + 'Selected value has not been published to Network Tables.\nRobot code will not be receiving the correct value.', + child: Icon(Icons.priority_high, color: Colors.red), + ) + : const Icon(Icons.check, color: Colors.green), + ], + ); + }, + ); + } +} + +class StringChooserData { + final List options; + final String? active; + final String? defaultOption; + final String? selected; + + const StringChooserData( + {required this.options, + required this.active, + required this.defaultOption, + required this.selected}); + + bool optionsChanged(StringChooserData? other) { + return options != other?.options; + } + + bool activeChanged(StringChooserData? other) { + return active != other?.active; + } + + bool defaultOptionChanged(StringChooserData? other) { + return defaultOption != other?.defaultOption; + } + + bool selectedChanged(StringChooserData? other) { + // print('$selected\t${other?.selected}'); + return selected != other?.selected; + } +} + +class _StringChooserDropdown extends StatelessWidget { + final List options; + final String? selected; + final Function(String? value) onValueChanged; + + const _StringChooserDropdown({ + required this.options, + required this.onValueChanged, + this.selected, + }); + + @override + Widget build(BuildContext context) { + return ExcludeFocus( + child: DropdownButton( + value: selected, + items: options.map((String option) { + return DropdownMenuItem( + value: option, + child: Text(option), + ); + }).toList(), + onChanged: onValueChanged), + ); + } +} diff --git a/lib/widgets/nt4_widgets/multi-topic/field_widget.dart b/lib/widgets/nt4_widgets/multi-topic/field_widget.dart new file mode 100644 index 00000000..105991b2 --- /dev/null +++ b/lib/widgets/nt4_widgets/multi-topic/field_widget.dart @@ -0,0 +1,281 @@ +import 'dart:math'; + +import 'package:elastic_dashboard/services/field_images.dart'; +import 'package:elastic_dashboard/services/globals.dart'; +import 'package:elastic_dashboard/services/nt4_connection.dart'; +import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_dropdown_chooser.dart'; +import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_text_input.dart'; +import 'package:elastic_dashboard/widgets/nt4_widgets/nt4_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:vector_math/vector_math_64.dart' + show Matrix3, Quaternion, Vector3, radians; + +class FieldWidget extends StatelessWidget with NT4Widget { + @override + String type = 'Field'; + + String fieldGame = 'Charged Up'; + late Field? field; + double robotSize = 50.0; + + double robotWidthMeters = 0.82; + double robotLengthMeters = 1.00; + + late String robotTopicName; + + FieldWidget({super.key, required topic, String? fieldName, period = Globals.defaultPeriod}) { + super.topic = topic; + super.period = period; + + fieldGame = fieldName ?? fieldGame; + + init(); + } + + FieldWidget.fromJson({super.key, required Map jsonData}) { + super.topic = jsonData['topic'] ?? ''; + super.period = jsonData['period'] ?? Globals.defaultPeriod; + + fieldGame = jsonData['field_name'] ?? fieldGame; + + robotWidthMeters = jsonData['robot_width'] ?? 0.82; + robotLengthMeters = jsonData['robot_length'] ?? 1.00; + + init(); + } + + @override + void init() { + super.init(); + + robotTopicName = '$topic/Robot'; + + field = FieldImages.getFieldFromGame(fieldGame); + } + + @override + Map toJson() { + return { + 'topic': topic, + 'period': period, + 'field_name': fieldGame, + 'robot_width': robotWidthMeters, + 'robot_length': robotLengthMeters, + }; + } + + @override + List getEditProperties(BuildContext context) { + return [ + const Center(child: Text('Game')), + DialogDropdownChooser( + onSelectionChanged: (value) { + if (value == null) { + return; + } + + Field? newField = FieldImages.getFieldFromGame(value); + + if (newField == null) { + return; + } + + fieldGame = value; + field = newField; + + refresh(); + }, + choices: FieldImages.fields.map((e) => e.game).toList(), + initialValue: field!.game, + ), + const SizedBox(height: 5), + DialogTextInput( + onSubmit: (value) { + double? newWidth = double.tryParse(value); + + if (newWidth == null) { + return; + } + robotWidthMeters = newWidth; + refresh(); + }, + formatter: FilteringTextInputFormatter.allow(RegExp(r"[0-9.]")), + label: 'Robot Width (meters)', + initialText: robotWidthMeters.toString(), + ), + const SizedBox(height: 5), + DialogTextInput( + onSubmit: (value) { + double? newLength = double.tryParse(value); + + if (newLength == null) { + return; + } + robotLengthMeters = newLength; + refresh(); + }, + formatter: FilteringTextInputFormatter.allow(RegExp(r"[0-9.]")), + label: 'Robot Length (meters)', + initialText: robotLengthMeters.toString(), + ), + ]; + } + + double getBackgroundFitWidth(Size size) { + double fitWidth = size.width; + double fitHeight = size.height; + + return min( + fitWidth, + fitHeight / + ((field!.fieldImageHeight ?? 0) / (field!.fieldImageWidth ?? 1))); + } + + double getBackgroundFitHeight(Size size) { + double fitWidth = size.width; + double fitHeight = size.height; + + return min( + fitHeight, + fitWidth * + ((field!.fieldImageHeight ?? 0) / (field!.fieldImageWidth ?? 1))); + } + + @override + Widget build(BuildContext context) { + notifier = context.watch(); + + return StreamBuilder( + stream: subscription?.periodicStream(), + builder: (context, snapshot) { + List robotPositionRaw = + NT4Connection.getLastAnnouncedValue(robotTopicName) + as List? ?? + []; + + List? robotPosition = []; + for (Object? object in robotPositionRaw) { + if (object == null || object is! double) { + robotPosition = null; + break; + } + + robotPosition?.add(object); + } + if (robotPositionRaw.isEmpty) { + robotPosition = null; + } + + RenderBox? renderBox = + context.findAncestorRenderObjectOfType(); + + Size size = (renderBox == null || !renderBox.hasSize) + ? const Size(0, 0) + : renderBox.size; + + Offset center = Offset(size.width / 2, size.height / 2); + Offset fieldCenter = Offset((field!.fieldImageWidth?.toDouble() ?? 0.0), + (field!.fieldImageHeight?.toDouble() ?? 0.0)) / + 2; + + double scaleReduction = + (getBackgroundFitWidth(size)) / (field!.fieldImageWidth ?? 1); + + double xFromCenter = + (robotPosition?[0] ?? 0) * field!.pixelsPerMeterHorizontal - + fieldCenter.dx; + + double yFromCenter = fieldCenter.dy - + (robotPosition?[1] ?? 0) * field!.pixelsPerMeterVertical; + + Offset robotPositionOffset = center + + (Offset(xFromCenter + field!.topLeftCorner.dx, + yFromCenter - field!.topLeftCorner.dy)) * + scaleReduction; + + double robotWidth = + robotWidthMeters * field!.pixelsPerMeterHorizontal * scaleReduction; + double robotLength = + robotLengthMeters * field!.pixelsPerMeterVertical * scaleReduction; + + Matrix4 transform = Matrix4.compose( + Vector3(robotPositionOffset.dx - robotLength / 2, + robotPositionOffset.dy - robotWidth / 2, 0), + Quaternion.fromRotation( + Matrix3.rotationZ(-radians(robotPosition?[2] ?? 0.0))), + Vector3(1, 1, 1), + ); + + Widget robot = Container( + alignment: Alignment.center, + constraints: const BoxConstraints( + minWidth: 4.0, + minHeight: 4.0, + ), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.35), + border: Border.all( + color: Colors.red, + width: 4.0, + ), + ), + width: robotLength, + height: robotWidth, + child: CustomPaint( + size: Size(robotLength * 0.25, robotWidth * 0.25), + painter: TrianglePainter( + strokeColor: const Color.fromARGB(255, 0, 255, 0)), + ), + ); + + return Stack(children: [ + field!.fieldImage, + Transform( + origin: Offset(robotLength, robotWidth) / 2, + transform: transform, + child: robot, + ), + ]); + }, + ); + } +} + +class TrianglePainter extends CustomPainter { + final Color strokeColor; + final PaintingStyle paintingStyle; + final double strokeWidth; + + TrianglePainter( + {this.strokeColor = Colors.white, + this.strokeWidth = 3, + this.paintingStyle = PaintingStyle.stroke}); + + @override + void paint(Canvas canvas, Size size) { + Paint paint = Paint() + ..color = strokeColor + ..strokeWidth = strokeWidth + ..style = paintingStyle; + + canvas.drawPath(getTrianglePath(size.width, size.height), paint); + } + + Path getTrianglePath(double x, double y) { + return Path() + ..moveTo(0, 0) + ..lineTo(x, y / 2) + ..lineTo(0, y) + ..lineTo(0, 0) + ..lineTo(x, y / 2); + } + + @override + bool shouldRepaint(TrianglePainter oldDelegate) { + return oldDelegate.strokeColor != strokeColor || + oldDelegate.paintingStyle != paintingStyle || + oldDelegate.strokeWidth != strokeWidth; + } +} diff --git a/lib/widgets/nt4_widgets/multi-topic/fms_info.dart b/lib/widgets/nt4_widgets/multi-topic/fms_info.dart new file mode 100644 index 00000000..aef0fde5 --- /dev/null +++ b/lib/widgets/nt4_widgets/multi-topic/fms_info.dart @@ -0,0 +1,234 @@ +import 'package:elastic_dashboard/services/globals.dart'; +import 'package:elastic_dashboard/services/nt4_connection.dart'; +import 'package:elastic_dashboard/widgets/nt4_widgets/nt4_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:patterns_canvas/patterns_canvas.dart'; +import 'package:provider/provider.dart'; + +class FMSInfo extends StatelessWidget with NT4Widget { + @override + String type = 'FMSInfo'; + + static const int ENABLED_FLAG = 0x01; + static const int AUTO_FLAG = 0x02; + static const int TEST_FLAG = 0x04; + static const int EMERGENCY_STOP_FLAG = 0x08; + static const int FMS_ATTACHED_FLAG = 0x10; + static const int DS_ATTACHED_FLAG = 0x20; + + late String eventNameTopic; + late String controlDataTopic; + late String allianceTopic; + late String matchNumberTopic; + late String matchTypeTopic; + late String replayNumberTopic; + late String stationNumberTopic; + + FMSInfo({super.key, required topic, period = Globals.defaultPeriod}) { + super.topic = topic; + super.period = period; + + init(); + } + + FMSInfo.fromJson({super.key, required Map jsonData}) { + super.topic = jsonData['topic'] ?? ''; + super.period = jsonData['period'] ?? Globals.defaultPeriod; + + init(); + } + + @override + void init() { + super.init(); + + eventNameTopic = '$topic/EventName'; + controlDataTopic = '$topic/FMSControlData'; + allianceTopic = '$topic/IsRedAlliance'; + matchNumberTopic = '$topic/MatchNumber'; + matchTypeTopic = '$topic/MatchType'; + replayNumberTopic = '$topic/ReplayNumber'; + stationNumberTopic = '$topic/StationNumber'; + } + + String _getMatchTypeString(int matchType) { + switch (matchType) { + case 1: + return 'Practice'; + case 2: + return 'Qualification'; + case 3: + return 'Elimination'; + default: + return 'Unknown'; + } + } + + bool _flagMatches(int word, int flag) { + return (word & flag) != 0; + } + + @override + Widget build(BuildContext context) { + notifier = context.watch(); + + return StreamBuilder( + stream: subscription?.periodicStream(), + builder: (context, snapshot) { + String eventName = + NT4Connection.getLastAnnouncedValue(eventNameTopic) as String? ?? + ''; + int controlData = + NT4Connection.getLastAnnouncedValue(controlDataTopic) as int? ?? 32; + bool redAlliance = + NT4Connection.getLastAnnouncedValue(allianceTopic) as bool? ?? true; + int matchNumber = + NT4Connection.getLastAnnouncedValue(matchNumberTopic) as int? ?? 0; + int matchType = + NT4Connection.getLastAnnouncedValue(matchTypeTopic) as int? ?? 0; + int replayNumber = + NT4Connection.getLastAnnouncedValue(replayNumberTopic) as int? ?? 0; + + String eventNameDisplay = '$eventName${(eventName != '') ? ' ' : ''}'; + String matchTypeString = _getMatchTypeString(matchType); + String replayNumberDisplay = + (replayNumber != 0) ? ' (replay $replayNumber)' : ''; + + bool fmsConnected = _flagMatches(controlData, FMS_ATTACHED_FLAG); + bool dsAttached = _flagMatches(controlData, DS_ATTACHED_FLAG); + + bool emergencyStopped = _flagMatches(controlData, EMERGENCY_STOP_FLAG); + + String robotControlState = 'Disabled'; + if (_flagMatches(controlData, ENABLED_FLAG)) { + if (_flagMatches(controlData, TEST_FLAG)) { + robotControlState = 'Test'; + } else if (_flagMatches(controlData, AUTO_FLAG)) { + robotControlState = 'Autonomous'; + } else { + robotControlState = 'Teleoperated'; + } + } + + String matchDisplayString = + '$eventNameDisplay$matchTypeString match $matchNumber$replayNumberDisplay'; + Widget matchDisplayWidget = Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: 5.0), + decoration: BoxDecoration( + color: (redAlliance) ? Colors.red.shade900 : Colors.blue.shade900, + borderRadius: BorderRadius.circular(5.0), + ), + child: Text(matchDisplayString, + style: Theme.of(context).textTheme.titleSmall), + ), + ], + ); + + String fmsDisplayString = + (fmsConnected) ? 'FMS Connected' : 'FMS Disconnected'; + String dsDisplayString = (dsAttached) + ? 'DriverStation Connected' + : 'DriverStation Disconnected'; + + Icon fmsDisplayIcon = (fmsConnected) + ? const Icon(Icons.check, color: Colors.green, size: 18) + : const Icon( + Icons.clear, + color: Colors.red, + size: 18, + ); + Icon dsDisplayIcon = (dsAttached) + ? const Icon(Icons.check, color: Colors.green, size: 18) + : const Icon(Icons.clear, color: Colors.red, size: 18); + + String robotStateDisplayString = 'Robot State: $robotControlState'; + + late Widget robotStateWidget; + if (emergencyStopped) { + robotStateWidget = Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Expanded( + flex: 25, + child: CustomPaint( + size: const Size(80, 15), + painter: _BlackAndYellowStripes(), + ), + ), + const Spacer(), + const Text( + 'EMERGENCY STOPPED', + style: TextStyle( + color: Colors.red, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + Expanded( + flex: 25, + child: CustomPaint( + size: const Size(80, 15), + painter: _BlackAndYellowStripes(), + ), + ), + ], + ); + } else { + robotStateWidget = Text(robotStateDisplayString); + } + + return Column( + children: [ + matchDisplayWidget, + const Spacer(flex: 2), + // DS and FMS connected + Row( + children: [ + const Spacer(), + Row( + children: [ + fmsDisplayIcon, + const SizedBox(width: 5), + Text(fmsDisplayString), + ], + ), + const Spacer(), + Row( + children: [ + dsDisplayIcon, + const SizedBox(width: 5), + Text(dsDisplayString), + ], + ), + const Spacer(), + ], + ), + const Spacer(), + // Robot State + robotStateWidget, + ], + ); + }, + ); + } +} + +class _BlackAndYellowStripes extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + Rect rect = Rect.fromLTWH(0, 0, size.width, size.height); + + const DiagonalStripesThick( + bgColor: Colors.black, fgColor: Colors.yellow, featuresCount: 10) + .paintOnRect(canvas, size, rect); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) { + return false; + } +} diff --git a/lib/widgets/nt4_widgets/multi-topic/gyro.dart b/lib/widgets/nt4_widgets/multi-topic/gyro.dart new file mode 100644 index 00000000..b1531a1f --- /dev/null +++ b/lib/widgets/nt4_widgets/multi-topic/gyro.dart @@ -0,0 +1,124 @@ +import 'package:elastic_dashboard/services/globals.dart'; +import 'package:elastic_dashboard/services/nt4.dart'; +import 'package:elastic_dashboard/services/nt4_connection.dart'; +import 'package:elastic_dashboard/widgets/nt4_widgets/nt4_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:syncfusion_flutter_gauges/gauges.dart'; + +class Gyro extends StatelessWidget with NT4Widget { + @override + String type = 'Gyro'; + + late String valueTopic; + + late NT4Subscription valueSubscription; + + Gyro({super.key, required topic, valueTopic, period = Globals.defaultPeriod}) { + super.topic = topic; + super.period = period; + + if (valueTopic == null) { + this.valueTopic = topic + '/Value'; + } else { + this.valueTopic = valueTopic!; + } + + init(); + } + + Gyro.fromJson({super.key, required Map jsonData}) { + super.topic = jsonData['topic'] ?? ''; + super.period = jsonData['period'] ?? Globals.defaultPeriod; + valueTopic = jsonData['value_topic'] ?? '${super.topic}/Value'; + + init(); + } + + @override + void init() { + super.init(); + + valueSubscription = NT4Connection.subscribe(valueTopic, super.period); + } + + @override + void unSubscribe() { + super.unSubscribe(); + + NT4Connection.unSubscribe(valueSubscription); + } + + @override + Map toJson() { + return { + 'topic': topic, + 'period': period, + 'value_topic': valueTopic, + }; + } + + double _wrapAngle(double angle) { + if (angle < 0) { + return ((angle % 360) + 360) % 360; + } else { + return angle % 360; + } + } + + @override + Widget build(BuildContext context) { + notifier = context.watch(); + + return StreamBuilder( + stream: valueSubscription.periodicStream(), + builder: (context, snapshot) { + double value = (snapshot.data as double?) ?? 0.0; + + double angle = _wrapAngle(value); + + return Column( + children: [ + Flexible( + child: SfRadialGauge( + axes: [ + RadialAxis( + pointers: [ + NeedlePointer( + value: angle, + needleColor: Colors.red, + needleEndWidth: 5, + needleStartWidth: 1, + needleLength: 0.7, + knobStyle: const KnobStyle( + borderColor: Colors.grey, + borderWidth: 0.025, + ), + ) + ], + axisLineStyle: const AxisLineStyle( + thickness: 5, + ), + axisLabelStyle: const GaugeTextStyle( + fontSize: 14, + ), + ticksPosition: ElementsPosition.outside, + labelsPosition: ElementsPosition.outside, + showTicks: true, + minorTicksPerInterval: 8, + interval: 45, + minimum: 0, + maximum: 360, + startAngle: 270, + endAngle: 270, + ) + ], + ), + ), + Text(value.toStringAsFixed(3), style: Theme.of(context).textTheme.bodyLarge), + ], + ); + }, + ); + } +} diff --git a/lib/widgets/nt4_widgets/multi-topic/pid_controller.dart b/lib/widgets/nt4_widgets/multi-topic/pid_controller.dart new file mode 100644 index 00000000..d4d35de8 --- /dev/null +++ b/lib/widgets/nt4_widgets/multi-topic/pid_controller.dart @@ -0,0 +1,279 @@ +import 'package:elastic_dashboard/services/globals.dart'; +import 'package:elastic_dashboard/services/nt4.dart'; +import 'package:elastic_dashboard/services/nt4_connection.dart'; +import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_text_input.dart'; +import 'package:elastic_dashboard/widgets/nt4_widgets/nt4_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class PIDControllerWidget extends StatelessWidget with NT4Widget { + @override + String type = 'PIDController'; + + late String kpTopicName; + late String kiTopicName; + late String kdTopicName; + late String setpointTopicName; + + NT4Topic? kpTopic; + NT4Topic? kiTopic; + NT4Topic? kdTopic; + NT4Topic? setpointTopic; + + TextEditingController? kpTextController; + TextEditingController? kiTextController; + TextEditingController? kdTextController; + TextEditingController? setpointTextController; + + double kpLastValue = 0.0; + double kiLastValue = 0.0; + double kdLastValue = 0.0; + double setpointLastValue = 0.0; + + PIDControllerWidget( + {super.key, + required topic, + kpTopic, + kiTopic, + kdTopic, + setpointTopic, + period = Globals.defaultPeriod}) { + super.topic = topic; + super.period = period; + + kpTopicName = kpTopic ?? '$topic/p'; + kiTopicName = kiTopic ?? '$topic/i'; + kdTopicName = kdTopic ?? '$topic/d'; + setpointTopicName = setpointTopic ?? '$topic/setpoint'; + + init(); + } + + PIDControllerWidget.fromJson( + {super.key, required Map jsonData}) { + super.topic = jsonData['topic'] ?? ''; + super.period = jsonData['period'] ?? Globals.defaultPeriod; + + kpTopicName = jsonData['kp_topic'] ?? '$topic/p'; + kiTopicName = jsonData['ki_topic'] ?? '$topic/i'; + kdTopicName = jsonData['kd_topic'] ?? '$topic/d'; + setpointTopicName = jsonData['setpoint_topic'] ?? '$topic/setpoint'; + + init(); + } + + @override + Map? toJson() { + return { + 'topic': topic, + 'period': period, + 'kp_topic': kpTopicName, + 'ki_topic': kiTopicName, + 'kd_topic': kdTopicName, + 'setpoint_topic': setpointTopicName, + }; + } + + @override + Widget build(BuildContext context) { + notifier = context.watch(); + + return StreamBuilder( + stream: subscription?.periodicStream(), + builder: (context, snapshot) { + double kP = + NT4Connection.getLastAnnouncedValue(kpTopicName) as double? ?? + 0.0; + double kI = + NT4Connection.getLastAnnouncedValue(kiTopicName) as double? ?? + 0.0; + double kD = + NT4Connection.getLastAnnouncedValue(kdTopicName) as double? ?? + 0.0; + double setpoint = + NT4Connection.getLastAnnouncedValue(setpointTopicName) + as double? ?? + 0.0; + + // Creates the text editing controllers if they are null + kpTextController ??= TextEditingController(text: kP.toString()); + kiTextController ??= TextEditingController(text: kI.toString()); + kdTextController ??= TextEditingController(text: kD.toString()); + setpointTextController ??= + TextEditingController(text: setpoint.toString()); + + // Updates the text of the text editing controller if the kp value has changed + if (kP != kpLastValue) { + kpTextController!.text = kP.toString(); + } + kpLastValue = kP; + + // Updates the text of the text editing controller if the ki value has changed + if (kI != kiLastValue) { + kiTextController!.text = kI.toString(); + } + kiLastValue = kI; + + // Updates the text of the text editing controller if the kd value has changed + if (kD != kdLastValue) { + kdTextController!.text = kD.toString(); + } + kdLastValue = kD; + + // Updates the text of the text editing controller if the setpoint value has changed + if (setpoint != setpointLastValue) { + setpointTextController!.text = setpoint.toString(); + } + setpointLastValue = setpoint; + + TextStyle labelStyle = Theme.of(context).textTheme.bodyLarge!.copyWith( + fontWeight: FontWeight.bold, + ); + + return Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + // kP + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Spacer(), + Text('P', style: labelStyle), + const Spacer(), + Flexible( + flex: 5, + child: DialogTextInput( + textEditingController: kpTextController, + initialText: kpTextController!.text, + label: 'kP', + onSubmit: (value) { + bool publishTopic = kpTopic == null; + + kpTopic ??= NT4Connection.getTopicFromName(kpTopicName); + + double? data = double.tryParse(value); + + if (kpTopic == null || data == null) { + return; + } + + if (publishTopic) { + NT4Connection.nt4Client.publishTopic(kpTopic!); + } + + NT4Connection.updateDataFromTopic(kpTopic!, data); + }, + ), + ), + const Spacer(), + ], + ), + // kI + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Spacer(), + Text('I', style: labelStyle), + const Spacer(), + Flexible( + flex: 5, + child: DialogTextInput( + textEditingController: kiTextController, + initialText: kiTextController!.text, + label: 'kI', + onSubmit: (value) { + bool publishTopic = kiTopic == null; + + kiTopic ??= NT4Connection.getTopicFromName(kiTopicName); + + double? data = double.tryParse(value); + + if (kiTopic == null || data == null) { + return; + } + + if (publishTopic) { + NT4Connection.nt4Client.publishTopic(kiTopic!); + } + + NT4Connection.updateDataFromTopic(kiTopic!, data); + }, + ), + ), + const Spacer(), + ], + ), + // kD + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Spacer(), + Text('D', style: labelStyle), + const Spacer(), + Flexible( + flex: 5, + child: DialogTextInput( + textEditingController: kdTextController, + initialText: kdTextController!.text, + label: 'kD', + onSubmit: (value) { + bool publishTopic = kdTopic == null; + + kdTopic ??= NT4Connection.getTopicFromName(kdTopicName); + + double? data = double.tryParse(value); + + if (kdTopic == null || data == null) { + return; + } + + if (publishTopic) { + NT4Connection.nt4Client.publishTopic(kdTopic!); + } + + NT4Connection.updateDataFromTopic(kdTopic!, data); + }, + ), + ), + const Spacer(), + ], + ), + Row( + children: [ + const Spacer(), + Text('Setpoint', style: labelStyle), + const Spacer(), + Flexible( + flex: 5, + child: DialogTextInput( + textEditingController: setpointTextController, + initialText: setpointTextController!.text, + label: 'Setpoint', + onSubmit: (value) { + bool publishTopic = setpointTopic == null; + + setpointTopic ??= + NT4Connection.getTopicFromName(setpointTopicName); + + double? data = double.tryParse(value); + + if (setpointTopic == null || data == null) { + return; + } + + if (publishTopic) { + NT4Connection.nt4Client.publishTopic(setpointTopic!); + } + + NT4Connection.updateDataFromTopic(setpointTopic!, data); + }, + ), + ), + const Spacer(), + ], + ), + ], + ); + }); + } +} diff --git a/lib/widgets/nt4_widgets/multi-topic/power_distribution.dart b/lib/widgets/nt4_widgets/multi-topic/power_distribution.dart new file mode 100644 index 00000000..fb256a6c --- /dev/null +++ b/lib/widgets/nt4_widgets/multi-topic/power_distribution.dart @@ -0,0 +1,198 @@ +import 'package:elastic_dashboard/services/globals.dart'; +import 'package:elastic_dashboard/services/nt4_connection.dart'; +import 'package:elastic_dashboard/widgets/nt4_widgets/nt4_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class PowerDistribution extends StatelessWidget with NT4Widget { + @override + String type = 'PowerDistribution'; + + static int numberOfChannels = 23; + + List channelTopics = []; + + late String voltageTopic; + late String currentTopic; + + PowerDistribution({super.key, required topic, period = Globals.defaultPeriod}) { + super.topic = topic; + super.period = period; + + init(); + } + + PowerDistribution.fromJson( + {super.key, required Map jsonData}) { + super.topic = jsonData['topic'] ?? ''; + super.period = jsonData['period'] ?? Globals.defaultPeriod; + + init(); + } + + @override + void init() { + super.init(); + + for (int channel = 0; channel <= numberOfChannels; channel++) { + channelTopics.add('$topic/Chan$channel'); + } + + voltageTopic = '$topic/Voltage'; + currentTopic = '$topic/TotalCurrent'; + } + + Widget _getChannelsColumn(BuildContext context, int start, int end) { + List channels = []; + + for (int channel = start; channel <= end; channel++) { + double current = + NT4Connection.getLastAnnouncedValue(channelTopics[channel]) + as double? ?? + 0.0; + + channels.add( + Row( + mainAxisSize: MainAxisSize.max, + children: [ + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(10.0), + ), + padding: + const EdgeInsets.symmetric(horizontal: 32.0, vertical: 4.0), + child: Text('$current A', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface)), + ), + const SizedBox(width: 5), + Text('Ch. $channel'), + ], + ), + ); + } + + return Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [...channels], + ); + } + + Widget _getReversedChannelsColumn(BuildContext context, int start, int end) { + List channels = []; + + for (int channel = start; channel >= end; channel--) { + double current = + NT4Connection.getLastAnnouncedValue(channelTopics[channel]) + as double? ?? + 0.0; + + channels.add( + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('Ch. $channel'), + const SizedBox(width: 5), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(10.0), + ), + padding: + const EdgeInsets.symmetric(horizontal: 32.0, vertical: 4.0), + child: Text('$current A', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface)), + ), + ], + ), + ); + } + + return Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [...channels], + ); + } + + @override + Widget build(BuildContext context) { + notifier = context.watch(); + + return StreamBuilder( + stream: subscription?.periodicStream(), + builder: (context, snapshot) { + double voltage = + NT4Connection.getLastAnnouncedValue(voltageTopic) as double? ?? 0.0; + double totalCurrent = + NT4Connection.getLastAnnouncedValue(currentTopic) as double? ?? 0.0; + + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + // Voltage + Column( + children: [ + const Text('Voltage'), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 64.0, vertical: 4.0), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(10.0), + ), + child: Text( + '$voltage V', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ), + ], + ), + // Current + Column( + children: [ + const Text('Total Current'), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 64.0, vertical: 4.0), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(10.0), + ), + child: Text( + '$totalCurrent A', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ), + ], + ), + ], + ), + const SizedBox(height: 5), + // Channel current + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // First 12 channels + _getChannelsColumn(context, 0, 11), + _getReversedChannelsColumn(context, 23, 12), + ], + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/widgets/nt4_widgets/multi-topic/split_button_chooser.dart b/lib/widgets/nt4_widgets/multi-topic/split_button_chooser.dart new file mode 100644 index 00000000..e5e92067 --- /dev/null +++ b/lib/widgets/nt4_widgets/multi-topic/split_button_chooser.dart @@ -0,0 +1,171 @@ +import 'package:elastic_dashboard/services/globals.dart'; +import 'package:elastic_dashboard/services/nt4.dart'; +import 'package:elastic_dashboard/services/nt4_connection.dart'; +import 'package:elastic_dashboard/widgets/nt4_widgets/multi-topic/combo_box_chooser.dart'; +import 'package:elastic_dashboard/widgets/nt4_widgets/nt4_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class SplitButtonChooser extends StatelessWidget with NT4Widget { + @override + String type = 'Split Button Chooser'; + + late String optionsTopicName; + late String selectedTopicName; + late String activeTopicName; + late String defaultTopicName; + + String? selectedChoice; + + StringChooserData? _previousData; + + NT4Topic? selectedTopic; + + SplitButtonChooser({super.key, required topic, period = Globals.defaultPeriod}) { + super.topic = topic; + super.period = period; + + init(); + } + + SplitButtonChooser.fromJson( + {super.key, required Map jsonData}) { + super.topic = jsonData['topic'] ?? ''; + super.period = jsonData['period'] ?? Globals.defaultPeriod; + + init(); + } + + @override + void init() { + super.init(); + + optionsTopicName = '$topic/options'; + selectedTopicName = '$topic/selected'; + activeTopicName = '$topic/active'; + defaultTopicName = '$topic/default'; + } + + void publishSelectedValue(String? selected) { + if (selected == null || !NT4Connection.connected) { + return; + } + + selectedTopic ??= NT4Connection.nt4Client + .publishNewTopic(selectedTopicName, NT4TypeStr.kString); + + NT4Connection.updateDataFromTopic(selectedTopic!, selected); + } + + @override + Widget build(BuildContext context) { + notifier = context.watch(); + + return StreamBuilder( + stream: subscription?.periodicStream(), + builder: (context, snapshot) { + List rawOptions = + NT4Connection.getLastAnnouncedValue(optionsTopicName) + as List? ?? + []; + + List options = []; + + for (Object? option in rawOptions) { + if (option == null || option is! String) { + continue; + } + + options.add(option); + } + + String? active = + NT4Connection.getLastAnnouncedValue(activeTopicName) as String?; + if (active != null && active == '') { + active = null; + } + + String? selected = + NT4Connection.getLastAnnouncedValue(selectedTopicName) as String?; + if (selected != null && selected == '') { + selected = null; + } + + String? defaultOption = + NT4Connection.getLastAnnouncedValue(defaultTopicName) as String?; + + if (defaultOption != null && defaultOption == '') { + defaultOption = null; + } + + if (!NT4Connection.connected) { + active = null; + selected = null; + defaultOption = null; + } + + StringChooserData currentData = StringChooserData( + options: options, + active: active, + defaultOption: defaultOption, + selected: selected); + + // If a choice has been selected previously but the topic on NT has no value, publish it + // This can happen if NT happens to restart + if (currentData.selectedChanged(_previousData)) { + if (selected != null && selectedChoice != selected) { + selectedChoice = selected; + } + } else if (currentData.activeChanged(_previousData) || active == null) { + if (selected == null && selectedChoice != null) { + if (options.contains(selectedChoice!)) { + publishSelectedValue(selectedChoice!); + } else if (options.isNotEmpty) { + selectedChoice = active; + } + } + } + + // If nothing is selected but NT has an active value, set the selected to the NT value + // This happens on program startup + if (active != null && selectedChoice == null) { + selectedChoice = active; + } + + _previousData = currentData; + + bool showWarning = active != selectedChoice; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + ToggleButtons( + onPressed: (index) { + selectedChoice = options[index]; + + publishSelectedValue(selectedChoice!); + }, + isSelected: options.map((String option) { + if (option == selectedChoice) { + return true; + } + return false; + }).toList(), + children: options.map((String option) { + return Text(option); + }).toList(), + ), + const SizedBox(width: 5), + (showWarning) + ? const Tooltip( + message: + 'Selected value has not been published to Network Tables.\nRobot code will not be receiving the correct value.', + child: Icon(Icons.priority_high, color: Colors.red), + ) + : const Icon(Icons.check, color: Colors.green), + ], + ); + }, + ); + } +} diff --git a/lib/widgets/nt4_widgets/nt4_widget.dart b/lib/widgets/nt4_widgets/nt4_widget.dart new file mode 100644 index 00000000..1a65a971 --- /dev/null +++ b/lib/widgets/nt4_widgets/nt4_widget.dart @@ -0,0 +1,120 @@ +import 'package:elastic_dashboard/services/nt4.dart'; +import 'package:elastic_dashboard/services/nt4_connection.dart'; +import 'package:flutter/material.dart'; + +class NT4WidgetNotifier extends ChangeNotifier { + // No idea why this is needed, but it was throwing errors ¯\_(ツ)_/¯ + bool _disposed = false; + + @override + void dispose() { + super.dispose(); + + _disposed = true; + } + + void refresh() { + if (!_disposed) { + notifyListeners(); + } + } +} + +mixin NT4Widget on StatelessWidget { + String get type; + + late String topic; + late double period; + + NT4Subscription? subscription; + NT4WidgetNotifier? notifier; + NT4Topic? nt4Topic; + + Map? toJson() { + return { + 'topic': topic, + 'period': period, + }; + } + + List getEditProperties(BuildContext context) { + return const []; + } + + List getAvailableDisplayTypes() { + if (type == 'ComboBox Chooser' || type == 'Split Button Chooser') { + return ['ComboBox Chooser', 'Split Button Chooser']; + } + + createTopicIfNull(); + + if (nt4Topic == null) { + return [type]; + } + + switch (nt4Topic!.type) { + case NT4TypeStr.kBool: + return [ + 'Boolean Box', + 'Toggle Switch', + 'Toggle Button', + 'Text Display', + ]; + case NT4TypeStr.kFloat32: + case NT4TypeStr.kFloat64: + case NT4TypeStr.kInt: + return [ + 'Text Display', + 'Number Bar', + 'Graph', + 'Match Time', + ]; + case NT4TypeStr.kString: + return ['Text Display']; + case NT4TypeStr.kBoolArr: + case NT4TypeStr.kFloat32Arr: + case NT4TypeStr.kFloat64Arr: + case NT4TypeStr.kIntArr: + case NT4TypeStr.kStringArr: + return ['Text Display']; + } + + return [type]; + } + + void init() async { + subscription = NT4Connection.subscribe(topic, period); + } + + void createTopicIfNull() { + nt4Topic ??= NT4Connection.getTopicFromName(topic); + } + + void dispose() {} + + void unSubscribe() { + if (subscription != null) { + NT4Connection.unSubscribe(subscription!); + } + } + + void resetSubscription() { + if (subscription == null) { + subscription = NT4Connection.subscribe(topic, period); + return; + } + + NT4Connection.unSubscribe(subscription!); + subscription = NT4Connection.subscribe(topic, period); + + nt4Topic = null; + + refresh(); + } + + void refresh() { + Future(() async { + notifier?.refresh(); + }); + } +} diff --git a/lib/widgets/nt4_widgets/single_topic/boolean_box.dart b/lib/widgets/nt4_widgets/single_topic/boolean_box.dart new file mode 100644 index 00000000..1733de9d --- /dev/null +++ b/lib/widgets/nt4_widgets/single_topic/boolean_box.dart @@ -0,0 +1,95 @@ +import 'package:elastic_dashboard/services/globals.dart'; +import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_color_picker.dart'; +import 'package:elastic_dashboard/widgets/nt4_widgets/nt4_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class BooleanBox extends StatelessWidget with NT4Widget { + @override + String type = 'Boolean Box'; + + Color trueColor; + Color falseColor; + + BooleanBox({ + super.key, + required topic, + this.trueColor = Colors.green, + this.falseColor = Colors.red, + period = Globals.defaultPeriod, + }) { + super.topic = topic; + super.period = period; + + init(); + } + + BooleanBox.fromJson({super.key, required Map jsonData}) + : trueColor = Color(jsonData['true_color'] ?? Colors.green.value), + falseColor = Color(jsonData['false_color'] ?? Colors.red.value) { + topic = jsonData['topic'] ?? ''; + period = jsonData['period'] ?? Globals.defaultPeriod; + + init(); + } + + @override + Map toJson() { + return { + 'topic': topic, + 'period': period, + 'true_color': trueColor.value, + 'false_color': falseColor.value, + }; + } + + @override + List getEditProperties(BuildContext context) { + return [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + mainAxisSize: MainAxisSize.max, + children: [ + DialogColorPicker( + onColorPicked: (Color color) { + trueColor = color; + refresh(); + }, + label: 'True Color', + initialColor: trueColor, + ), + const SizedBox(width: 10), + DialogColorPicker( + onColorPicked: (Color color) { + falseColor = color; + refresh(); + }, + label: 'False Color', + initialColor: falseColor, + ), + ], + ), + ]; + } + + @override + Widget build(BuildContext context) { + notifier = context.watch(); + + return StreamBuilder( + stream: subscription?.periodicStream(), + builder: (context, snapshot) { + Object data = snapshot.data ?? false; + + bool value = (data is bool) ? data : false; + + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15.0), + color: (value) ? trueColor : falseColor, + ), + ); + }, + ); + } +} diff --git a/lib/widgets/nt4_widgets/single_topic/graph.dart b/lib/widgets/nt4_widgets/single_topic/graph.dart new file mode 100644 index 00000000..f3b17caa --- /dev/null +++ b/lib/widgets/nt4_widgets/single_topic/graph.dart @@ -0,0 +1,225 @@ +import 'package:elastic_dashboard/services/globals.dart'; +import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_text_input.dart'; +import 'package:elastic_dashboard/widgets/nt4_widgets/nt4_widget.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +class GraphWidget extends StatelessWidget with NT4Widget { + @override + String type = 'Graph'; + + final Color color; + late double timeDisplayed; + double? minValue; + double? maxValue; + late final List _graphData; + + GraphWidget({ + super.key, + required topic, + period = Globals.defaultPeriod, + this.timeDisplayed = 5.0, + this.minValue, + this.maxValue, + this.color = Colors.lightBlue, + }) { + super.topic = topic; + super.period = period; + + init(); + } + + GraphWidget.fromJson({super.key, required Map jsonData}) + : color = Colors.lightBlue { + topic = jsonData['topic'] ?? ''; + period = jsonData['period'] ?? Globals.defaultPeriod; + timeDisplayed = jsonData['time_displayed'] ?? 5.0; + minValue = jsonData['min_value']; + maxValue = jsonData['max_value']; + + init(); + } + + @override + void init() { + super.init(); + + _graphData = []; + + for (int i = 0; i < 5 ~/ period; i++) { + _graphData.add(0.0); + } + } + + @override + void resetSubscription() { + resetGraphData(); + + super.resetSubscription(); + } + + void resetGraphData() { + _graphData.clear(); + + for (int i = 0; i < timeDisplayed ~/ period; i++) { + _graphData.add(0.0); + } + } + + @override + Map toJson() { + return { + 'topic': topic, + 'period': period, + 'time_displayed': timeDisplayed, + 'min_value': minValue, + 'max_value': maxValue, + }; + } + + @override + List getEditProperties(BuildContext context) { + return [ + DialogTextInput( + onSubmit: (value) { + double? newTime = double.tryParse(value); + + if (newTime == null) { + return; + } + timeDisplayed = newTime; + resetGraphData(); + refresh(); + }, + formatter: FilteringTextInputFormatter.allow(RegExp(r"[0-9.-]")), + label: 'Time Displayed (Seconds)', + initialText: timeDisplayed.toString(), + ), + const SizedBox(height: 5), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + mainAxisSize: MainAxisSize.max, + children: [ + Flexible( + child: DialogTextInput( + onSubmit: (value) { + double? newMinimum = double.tryParse(value); + bool refreshGraph = newMinimum != minValue; + + minValue = newMinimum; + + if (refreshGraph) { + refresh(); + } + }, + formatter: FilteringTextInputFormatter.allow(RegExp(r"[0-9.-]")), + label: 'Minimum', + initialText: minValue?.toString(), + allowEmptySubmission: true, + ), + ), + + Flexible( + child: DialogTextInput( + onSubmit: (value) { + double? newMaximum = double.tryParse(value); + bool refreshGraph = newMaximum != maxValue; + + maxValue = newMaximum; + + if (refreshGraph) { + refresh(); + } + }, + formatter: FilteringTextInputFormatter.allow(RegExp(r"[0-9.]")), + label: 'Maximum', + initialText: maxValue?.toString(), + allowEmptySubmission: true, + ), + ), + ], + ), + ]; + } + + @override + Widget build(BuildContext context) { + notifier = context.watch(); + + return StreamBuilder( + stream: subscription?.periodicStream(), + builder: (context, snapshot) { + if (snapshot.data != null) { + double value = snapshot.data as double; + _graphData.add(value); + _graphData.removeAt(0); + } + + List data = []; + for (int i = 0; i < _graphData.length; i++) { + data.add(FlSpot(period * i, _graphData[i])); + } + + return LineChart( + LineChartData( + gridData: FlGridData( + show: true, + drawVerticalLine: true, + drawHorizontalLine: true, + getDrawingHorizontalLine: (value) { + return FlLine( + color: Colors.grey.withOpacity(0.3), + strokeWidth: 1, + ); + }, + getDrawingVerticalLine: (value) { + return FlLine( + color: Colors.grey.withOpacity(0.3), + strokeWidth: 1, + ); + }, + ), + lineTouchData: const LineTouchData( + enabled: false, + ), + titlesData: const FlTitlesData( + topTitles: AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + bottomTitles: AxisTitles( + axisNameWidget: Text('Time (Seconds)'), + axisNameSize: 20, + drawBelowEverything: true, + sideTitles: SideTitles(showTitles: false, reservedSize: 26), + ), + ), + minY: minValue, + maxY: maxValue, + minX: 0, + lineBarsData: [ + LineChartBarData( + spots: data, + isStrokeCapRound: false, + dotData: const FlDotData(show: false), + color: Colors.cyan, + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient(colors: [ + Colors.blue.withOpacity(0.3), + Colors.lightBlueAccent.withOpacity(0.3) + ]), + ), + ), + ], + ), + duration: const Duration(milliseconds: 0), + ); + }, + ); + } +} diff --git a/lib/widgets/nt4_widgets/single_topic/match_time.dart b/lib/widgets/nt4_widgets/single_topic/match_time.dart new file mode 100644 index 00000000..33433563 --- /dev/null +++ b/lib/widgets/nt4_widgets/single_topic/match_time.dart @@ -0,0 +1,50 @@ +import 'package:elastic_dashboard/services/globals.dart'; +import 'package:elastic_dashboard/widgets/nt4_widgets/nt4_widget.dart'; +import 'package:flutter/material.dart'; + +class MatchTimeWidget extends StatelessWidget with NT4Widget { + @override + String type = 'Match Time'; + + MatchTimeWidget({super.key, required topic, period = Globals.defaultPeriod}) { + super.topic = topic; + super.period = period; + + init(); + } + + MatchTimeWidget.fromJson( + {super.key, required Map jsonData}) { + super.topic = jsonData['topic'] ?? ''; + super.period = jsonData['period'] ?? Globals.defaultPeriod; + + init(); + } + + Color _getTimeColor(double time) { + if (time <= 15.0) { + return Colors.red; + } else if (time <= 30.0) { + return Colors.yellow; + } else if (time <= 60.0) { + return Colors.green; + } + + return Colors.blue; + } + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: subscription?.periodicStream(), + builder: (context, snapshot) { + double time = snapshot.data as double? ?? -1.0; + + return Text('${time.ceil()}', + style: Theme.of(context).textTheme.displaySmall!.copyWith( + color: _getTimeColor(time.ceil().toDouble()), + )); + }, + ); + } +} diff --git a/lib/widgets/nt4_widgets/single_topic/number_bar.dart b/lib/widgets/nt4_widgets/single_topic/number_bar.dart new file mode 100644 index 00000000..1bcc41be --- /dev/null +++ b/lib/widgets/nt4_widgets/single_topic/number_bar.dart @@ -0,0 +1,193 @@ +import 'package:elastic_dashboard/services/globals.dart'; +import 'package:elastic_dashboard/services/nt4_connection.dart'; +import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_text_input.dart'; +import 'package:elastic_dashboard/widgets/nt4_widgets/nt4_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +class NumberBar extends StatelessWidget with NT4Widget { + @override + final String type = 'Number Bar'; + + double minValue; + double maxValue; + int divisions; + + double _currentValue = 0.0; + double _previousValue = 0.0; + + NumberBar({ + super.key, + required topic, + this.minValue = -1.0, + this.maxValue = 1.0, + this.divisions = 5, + period = Globals.defaultPeriod, + }) { + super.topic = topic; + super.period = period; + + init(); + } + + NumberBar.fromJson({super.key, required Map jsonData}) + : minValue = jsonData['min_value'] ?? -1.0, + maxValue = jsonData['max_value'] ?? 1.0, + divisions = jsonData['divisions'] ?? 5 { + topic = jsonData['topic'] ?? ''; + period = jsonData['period'] ?? Globals.defaultPeriod; + + init(); + } + + @override + Map toJson() { + return { + 'topic': topic, + 'period': period, + 'min_value': minValue, + 'max_value': maxValue, + 'divisions': divisions, + }; + } + + @override + List getEditProperties(BuildContext context) { + return [ + // Min and max values + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + mainAxisSize: MainAxisSize.max, + children: [ + Flexible( + child: DialogTextInput( + onSubmit: (value) { + double? newMin = double.tryParse(value); + if (newMin == null) { + return; + } + minValue = newMin; + refresh(); + }, + formatter: FilteringTextInputFormatter.allow(RegExp(r"[0-9.-]")), + label: 'Min Value', + initialText: minValue.toString(), + ), + ), + const SizedBox(width: 5), + Flexible( + child: DialogTextInput( + onSubmit: (value) { + double? newMax = double.tryParse(value); + if (newMax == null) { + return; + } + maxValue = newMax; + refresh(); + }, + formatter: FilteringTextInputFormatter.allow(RegExp(r"[0-9.-]")), + label: 'Max Value', + initialText: maxValue.toString(), + ), + ), + ], + ), + const SizedBox(height: 5), + // Number of divisions + DialogTextInput( + onSubmit: (value) { + int? newDivisions = int.tryParse(value); + if (newDivisions == null || newDivisions < 2) { + return; + } + divisions = newDivisions; + refresh(); + }, + formatter: FilteringTextInputFormatter.digitsOnly, + label: 'Divisions', + initialText: divisions.toString(), + ), + ]; + } + + @override + Widget build(BuildContext context) { + notifier = context.watch(); + + return StreamBuilder( + stream: subscription?.periodicStream(), + builder: (context, snapshot) { + Object data = snapshot.data ?? 0.0; + + double value = (data is double) ? data : 0.0; + + if (value < minValue) { + value = minValue; + } + + if (value > maxValue) { + value = maxValue; + } + + if (value != _previousValue) { + _currentValue = value; + } + + _previousValue = value; + + double divisionSeparation = (maxValue - minValue) / (divisions - 1); + + return Column( + children: [ + Expanded( + child: Text( + _currentValue.toStringAsFixed(4), + style: Theme.of(context).textTheme.bodyLarge, + overflow: TextOverflow.ellipsis, + ), + ), + Flexible( + child: Slider( + value: _currentValue, + min: minValue, + max: maxValue, + focusNode: FocusNode( + canRequestFocus: false, + ), + onChanged: (value) { + _currentValue = value; + }, + onChangeEnd: (value) { + bool publishTopic = nt4Topic == null; + + createTopicIfNull(); + + if (nt4Topic == null) { + return; + } + + if (publishTopic) { + NT4Connection.nt4Client.publishTopic(nt4Topic!); + } + + NT4Connection.updateDataFromTopic(nt4Topic!, value); + + _previousValue = value; + }, + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.max, + children: [ + for (int i = 0; i < divisions; i++) + Text((minValue + divisionSeparation * i).toStringAsFixed(2)), + ], + ), + ], + ); + }, + ); + } +} diff --git a/lib/widgets/nt4_widgets/single_topic/text_display.dart b/lib/widgets/nt4_widgets/single_topic/text_display.dart new file mode 100644 index 00000000..25e67efe --- /dev/null +++ b/lib/widgets/nt4_widgets/single_topic/text_display.dart @@ -0,0 +1,95 @@ +import 'package:elastic_dashboard/services/globals.dart'; +import 'package:elastic_dashboard/services/nt4.dart'; +import 'package:elastic_dashboard/services/nt4_connection.dart'; +import 'package:elastic_dashboard/widgets/nt4_widgets/nt4_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class TextDisplay extends StatelessWidget with NT4Widget { + @override + String type = 'Text Display'; + + final TextEditingController _controller = TextEditingController(); + + Object? _previousValue; + + TextDisplay({super.key, required topic, period = Globals.defaultPeriod}) { + super.topic = topic; + super.period = period; + + init(); + } + + TextDisplay.fromJson({super.key, required Map jsonData}) { + topic = jsonData['topic'] ?? ''; + period = jsonData['period'] ?? Globals.defaultPeriod; + + init(); + } + + @override + Widget build(BuildContext context) { + notifier = context.watch(); + + return StreamBuilder( + stream: subscription?.periodicStream(), + builder: (context, snapshot) { + Object data = snapshot.data ?? ''; + + if (data.toString() != _previousValue.toString()) { + // Needed to prevent errors + Future(() async { + _controller.text = data.toString(); + + _previousValue = data; + }); + } + + return TextField( + controller: _controller, + textAlign: TextAlign.left, + onSubmitted: (value) { + bool publishTopic = nt4Topic == null; + + createTopicIfNull(); + + if (nt4Topic == null) { + return; + } + + late Object? formattedData; + + String dataType = nt4Topic!.type; + switch (dataType) { + case NT4TypeStr.kBool: + formattedData = bool.tryParse(value); + break; + case NT4TypeStr.kFloat32: + case NT4TypeStr.kFloat64: + formattedData = double.tryParse(value); + break; + case NT4TypeStr.kInt: + formattedData = int.tryParse(value); + break; + case NT4TypeStr.kString: + formattedData = value; + break; + default: + break; + } + + if (publishTopic) { + NT4Connection.nt4Client.publishTopic(nt4Topic!); + } + + if (formattedData != null) { + NT4Connection.updateDataFromTopic(nt4Topic!, formattedData); + } + + _previousValue = value; + }, + ); + }, + ); + } +} diff --git a/lib/widgets/nt4_widgets/single_topic/toggle_button.dart b/lib/widgets/nt4_widgets/single_topic/toggle_button.dart new file mode 100644 index 00000000..a58501d7 --- /dev/null +++ b/lib/widgets/nt4_widgets/single_topic/toggle_button.dart @@ -0,0 +1,88 @@ +import 'package:elastic_dashboard/services/globals.dart'; +import 'package:elastic_dashboard/services/nt4_connection.dart'; +import 'package:elastic_dashboard/widgets/nt4_widgets/nt4_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class ToggleButton extends StatelessWidget with NT4Widget { + @override + String type = 'Toggle Button'; + + ToggleButton({super.key, required topic, period = Globals.defaultPeriod}) { + super.topic = topic; + super.period = period; + + init(); + } + + ToggleButton.fromJson({super.key, required Map jsonData}) { + super.topic = jsonData['topic'] ?? ''; + super.period = jsonData['period'] ?? Globals.defaultPeriod; + + init(); + } + + @override + Widget build(BuildContext context) { + notifier = context.watch(); + + return StreamBuilder( + stream: subscription?.periodicStream(), + builder: (context, snapshot) { + Object data = snapshot.data ?? false; + + bool value = (data is bool) ? data : false; + + String buttonText = topic.substring(topic.lastIndexOf('/') + 1); + + Size buttonSize = MediaQuery.of(context).size; + + ThemeData theme = Theme.of(context); + + return GestureDetector( + onTapDown: (_) { + bool publishTopic = nt4Topic == null; + + createTopicIfNull(); + + if (nt4Topic == null) { + return; + } + + if (publishTopic) { + NT4Connection.nt4Client.publishTopic(nt4Topic!); + } + + NT4Connection.updateDataFromTopic(nt4Topic!, !value); + }, + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: buttonSize.width * 0.01, + vertical: buttonSize.height * 0.01), + child: AnimatedContainer( + duration: const Duration(milliseconds: 10), + width: buttonSize.width, + height: buttonSize.height, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8.0), + boxShadow: const [ + BoxShadow( + offset: Offset(2, 2), + blurRadius: 10.0, + spreadRadius: -5, + color: Colors.black, + ), + ], + color: (value) + ? theme.colorScheme.primaryContainer + : const Color.fromARGB(255, 50, 50, 50), + ), + child: Center( + child: + Text(buttonText, style: theme.textTheme.titleMedium)), + ), + ), + ); + }); + } +} diff --git a/lib/widgets/nt4_widgets/single_topic/toggle_switch.dart b/lib/widgets/nt4_widgets/single_topic/toggle_switch.dart new file mode 100644 index 00000000..3cfde8e8 --- /dev/null +++ b/lib/widgets/nt4_widgets/single_topic/toggle_switch.dart @@ -0,0 +1,57 @@ +import 'package:elastic_dashboard/services/globals.dart'; +import 'package:elastic_dashboard/services/nt4_connection.dart'; +import 'package:elastic_dashboard/widgets/nt4_widgets/nt4_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class ToggleSwitch extends StatelessWidget with NT4Widget { + @override + String type = 'Toggle Switch'; + + ToggleSwitch({super.key, required topic, period = Globals.defaultPeriod}) { + super.topic = topic; + super.period = period; + + init(); + } + + ToggleSwitch.fromJson({super.key, required Map jsonData}) { + topic = jsonData['topic'] ?? ''; + period = jsonData['period'] ?? Globals.defaultPeriod; + + init(); + } + + @override + Widget build(BuildContext context) { + notifier = context.watch(); + + return StreamBuilder( + stream: subscription?.periodicStream(), + builder: (context, snapshot) { + Object data = snapshot.data ?? false; + + bool value = (data is bool) ? data : false; + + return Switch( + value: value, + onChanged: (bool value) { + bool publishTopic = nt4Topic == null; + + createTopicIfNull(); + + if (nt4Topic == null) { + return; + } + + if (publishTopic) { + NT4Connection.nt4Client.publishTopic(nt4Topic!); + } + + NT4Connection.updateDataFromTopic(nt4Topic!, value); + }, + ); + }, + ); + } +} diff --git a/linux/.gitignore b/linux/.gitignore new file mode 100644 index 00000000..d3896c98 --- /dev/null +++ b/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt new file mode 100644 index 00000000..ca3d0df8 --- /dev/null +++ b/linux/CMakeLists.txt @@ -0,0 +1,139 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.10) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "elastic_dashboard") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.elastic_dashboard") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Define the application target. To change its name, change BINARY_NAME above, +# not the value here, or `flutter run` will no longer work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/linux/flutter/CMakeLists.txt b/linux/flutter/CMakeLists.txt new file mode 100644 index 00000000..d5bd0164 --- /dev/null +++ b/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 00000000..ae34b3b0 --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,23 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) screen_retriever_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverPlugin"); + screen_retriever_plugin_register_with_registrar(screen_retriever_registrar); + g_autoptr(FlPluginRegistrar) window_manager_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); + window_manager_plugin_register_with_registrar(window_manager_registrar); +} diff --git a/linux/flutter/generated_plugin_registrant.h b/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 00000000..e0f0a47b --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake new file mode 100644 index 00000000..081edc46 --- /dev/null +++ b/linux/flutter/generated_plugins.cmake @@ -0,0 +1,26 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux + screen_retriever + window_manager +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/linux/main.cc b/linux/main.cc new file mode 100644 index 00000000..e7c5c543 --- /dev/null +++ b/linux/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/linux/my_application.cc b/linux/my_application.cc new file mode 100644 index 00000000..ba564781 --- /dev/null +++ b/linux/my_application.cc @@ -0,0 +1,104 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "elastic_dashboard"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "elastic_dashboard"); + } + + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/linux/my_application.h b/linux/my_application.h new file mode 100644 index 00000000..72271d5e --- /dev/null +++ b/linux/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/macos/.gitignore b/macos/.gitignore new file mode 100644 index 00000000..746adbb6 --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 00000000..c2efd0b6 --- /dev/null +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 00000000..c2efd0b6 --- /dev/null +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 00000000..ec68178a --- /dev/null +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,20 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import file_selector_macos +import path_provider_foundation +import screen_retriever +import shared_preferences_foundation +import window_manager + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + ScreenRetrieverPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) +} diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 00000000..9e24aa10 --- /dev/null +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,695 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* elastic_dashboard.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "elastic_dashboard.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* elastic_dashboard.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* elastic_dashboard.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1300; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.elasticDashboard.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/elastic_dashboard.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/elastic_dashboard"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.elasticDashboard.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/elastic_dashboard.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/elastic_dashboard"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.elasticDashboard.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/elastic_dashboard.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/elastic_dashboard"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 00000000..8aba3539 --- /dev/null +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..1d526a16 --- /dev/null +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift new file mode 100644 index 00000000..d53ef643 --- /dev/null +++ b/macos/Runner/AppDelegate.swift @@ -0,0 +1,9 @@ +import Cocoa +import FlutterMacOS + +@NSApplicationMain +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..a2ec33f1 --- /dev/null +++ b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 00000000..82b6f9d9 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 00000000..13b35eba Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 00000000..0a3f5fa4 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 00000000..bdb57226 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 00000000..f083318e Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 00000000..326c0e72 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 00000000..2f1632cf Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/macos/Runner/Base.lproj/MainMenu.xib b/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 00000000..80e867a4 --- /dev/null +++ b/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 00000000..e2749b14 --- /dev/null +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = elastic_dashboard + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.elasticDashboard + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2023 com.example. All rights reserved. diff --git a/macos/Runner/Configs/Debug.xcconfig b/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 00000000..36b0fd94 --- /dev/null +++ b/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Release.xcconfig b/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 00000000..dff4f495 --- /dev/null +++ b/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Warnings.xcconfig b/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 00000000..42bcbf47 --- /dev/null +++ b/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements new file mode 100644 index 00000000..dddb8a30 --- /dev/null +++ b/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist new file mode 100644 index 00000000..4789daa6 --- /dev/null +++ b/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 00000000..3cc05eb2 --- /dev/null +++ b/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements new file mode 100644 index 00000000..852fa1a4 --- /dev/null +++ b/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 00000000..5418c9f5 --- /dev/null +++ b/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import FlutterMacOS +import Cocoa +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 00000000..d37b3059 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,802 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + after_layout: + dependency: transitive + description: + name: after_layout + sha256: "95a1cb2ca1464f44f14769329fbf15987d20ab6c88f8fc5d359bd362be625f29" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + animations: + dependency: "direct main" + description: + name: animations + sha256: fe8a6bdca435f718bb1dc8a11661b2c22504c6da40ef934cee8327ed77934164 + url: "https://pub.dev" + source: hosted + version: "2.0.7" + archive: + dependency: transitive + description: + name: archive + sha256: "0c8368c9b3f0abbc193b9d6133649a614204b528982bebc7026372d61677ce3a" + url: "https://pub.dev" + source: hosted + version: "3.3.7" + args: + dependency: transitive + description: + name: args + sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + box_transform: + dependency: transitive + description: + name: box_transform + sha256: ec813f125e6ddc7d386dde229aff8601f39ac2dba5a7ff733e1daa8f184b39d7 + url: "https://pub.dev" + source: hosted + version: "0.4.1" + change_case: + dependency: transitive + description: + name: change_case + sha256: f4e08feaa845e75e4f5ad2b0e15f24813d7ea6c27e7b78252f0c17f752cf1157 + url: "https://pub.dev" + source: hosted + version: "1.1.0" + characters: + dependency: transitive + description: + name: characters + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: b8db3080e59b2503ca9e7922c3df2072cf13992354d5e944074ffa836fba43b7 + url: "https://pub.dev" + source: hosted + version: "0.4.0" + clock: + dependency: transitive + description: + name: clock + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + url: "https://pub.dev" + source: hosted + version: "1.1.1" + collection: + dependency: transitive + description: + name: collection + sha256: "4a07be6cb69c84d677a6c3096fcf960cc3285a8330b4603e0d463d15d9bd934c" + url: "https://pub.dev" + source: hosted + version: "1.17.1" + console: + dependency: transitive + description: + name: console + sha256: e04e7824384c5b39389acdd6dc7d33f3efe6b232f6f16d7626f194f6a01ad69a + url: "https://pub.dev" + source: hosted + version: "4.1.0" + contextmenu: + dependency: "direct main" + description: + name: contextmenu + sha256: e0c7d60e2fc9f316f5b03f5fe2c0f977d65125345d1a1f77eea02be612e32d0c + url: "https://pub.dev" + source: hosted + version: "3.0.0" + convert: + dependency: transitive + description: + name: convert + sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "0b0036e8cccbfbe0555fd83c1d31a6f30b77a96b598b35a5d36dd41f718695e9" + url: "https://pub.dev" + source: hosted + version: "0.3.3+4" + crypto: + dependency: transitive + description: + name: crypto + sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + url: "https://pub.dev" + source: hosted + version: "3.0.3" + dbus: + dependency: transitive + description: + name: dbus + sha256: "6f07cba3f7b3448d42d015bfd3d53fe12e5b36da2423f23838efc1d5fb31a263" + url: "https://pub.dev" + source: hosted + version: "0.7.8" + equatable: + dependency: transitive + description: + name: equatable + sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2 + url: "https://pub.dev" + source: hosted + version: "2.0.5" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: ed5337a5660c506388a9f012be0288fb38b49020ce2b45fe1f8b8323fe429f99 + url: "https://pub.dev" + source: hosted + version: "2.0.2" + file: + dependency: transitive + description: + name: file + sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d" + url: "https://pub.dev" + source: hosted + version: "6.1.4" + file_selector: + dependency: "direct main" + description: + name: file_selector + sha256: "1d2fde93dddf634a9c3c0faa748169d7ac0d83757135555707e52f02c017ad4f" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_android: + dependency: transitive + description: + name: file_selector_android + sha256: "43e5c719f671b9181bef1bf2851135c3ad993a9a6c804a4ccb07579cfee84e34" + url: "https://pub.dev" + source: hosted + version: "0.5.0+2" + file_selector_ios: + dependency: transitive + description: + name: file_selector_ios + sha256: "54542b6b35e3ced6246df5fae13cf0b879d14669d0fdff1a53a098f16e23328b" + url: "https://pub.dev" + source: hosted + version: "0.5.1+4" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "770eb1ab057b5ae4326d1c24cc57710758b9a46026349d021d6311bd27580046" + url: "https://pub.dev" + source: hosted + version: "0.9.2" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "4ada532862917bf16e3adb3891fe3a5917a58bae03293e497082203a80909412" + url: "https://pub.dev" + source: hosted + version: "0.9.3+1" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "412705a646a0ae90f33f37acfae6a0f7cbc02222d6cd34e479421c3e74d3853c" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + file_selector_web: + dependency: transitive + description: + name: file_selector_web + sha256: e292740c469df0aeeaba0895bf622bea351a05e87d22864c826bf21c4780e1d7 + url: "https://pub.dev" + source: hosted + version: "0.9.2" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "1372760c6b389842b77156203308940558a2817360154084368608413835fc26" + url: "https://pub.dev" + source: hosted + version: "0.9.3" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: c1e26c7e48496be85104c16c040950b0436674cdf0737f3f6e95511b2529b592 + url: "https://pub.dev" + source: hosted + version: "0.63.0" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_box_transform: + dependency: "direct main" + description: + name: flutter_box_transform + sha256: "78e00c1c244d04ab25ed891bb6c289044e208c7096ac8f68192be885b15def81" + url: "https://pub.dev" + source: hosted + version: "0.4.2" + flutter_colorpicker: + dependency: "direct main" + description: + name: flutter_colorpicker + sha256: "458a6ed8ea480eb16ff892aedb4b7092b2804affd7e046591fb03127e8d8ef8b" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + flutter_fancy_tree_view: + dependency: "direct main" + description: + name: flutter_fancy_tree_view + sha256: "8c1f9f2c5b56ecdc87599435a6babe2d5e0506564ba8cd2dbd884e4054450cb7" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter_hooks: + dependency: "direct main" + description: + name: flutter_hooks + sha256: "9eab8fd7aa752c3c1c0a364f9825851d410eb935243411682f4b1b0a4c569d71" + url: "https://pub.dev" + source: hosted + version: "0.20.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "2118df84ef0c3ca93f96123a616ae8540879991b8b57af2f81b76a7ada49b2a4" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + flutter_mjpeg: + dependency: "direct main" + description: + name: flutter_mjpeg + sha256: "46ea3f1a49838deb8e2b01a70b897313c7f985affecfaf3b3bae6f4e15405b6b" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + flutter_svg: + dependency: transitive + description: + name: flutter_svg + sha256: "6ff9fa12892ae074092de2fa6a9938fb21dbabfdaa2ff57dc697ff912fc8d4b2" + url: "https://pub.dev" + source: hosted + version: "1.1.6" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + get_it: + dependency: transitive + description: + name: get_it + sha256: "529de303c739fca98cd7ece5fca500d8ff89649f1bb4b4e94fb20954abcd7468" + url: "https://pub.dev" + source: hosted + version: "7.6.0" + gsettings: + dependency: transitive + description: + name: gsettings + sha256: "1b0ce661f5436d2db1e51f3c4295a49849f03d304003a7ba177d01e3a858249c" + url: "https://pub.dev" + source: hosted + version: "0.2.8" + http: + dependency: "direct main" + description: + name: http + sha256: "5895291c13fa8a3bd82e76d5627f69e0d85ca6a30dcac95c4ea19a5d555879c2" + url: "https://pub.dev" + source: hosted + version: "0.13.6" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + image: + dependency: transitive + description: + name: image + sha256: a72242c9a0ffb65d03de1b7113bc4e189686fc07c7147b8b41811d0dd0e0d9bf + url: "https://pub.dev" + source: hosted + version: "4.0.17" + intl: + dependency: transitive + description: + name: intl + sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" + url: "https://pub.dev" + source: hosted + version: "0.18.1" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + lints: + dependency: transitive + description: + name: lints + sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + matcher: + dependency: transitive + description: + name: matcher + sha256: "6501fbd55da300384b768785b83e5ce66991266cec21af89ab9ae7f5ce1c4cbb" + url: "https://pub.dev" + source: hosted + version: "0.12.15" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: d92141dc6fe1dad30722f9aa826c7fbc896d021d792f80678280601aff8cf724 + url: "https://pub.dev" + source: hosted + version: "0.2.0" + messagepack: + dependency: "direct main" + description: + name: messagepack + sha256: "11e69dd79ba84ba901261558881b42ac8b24155a7846a344875a32c8b0866d71" + url: "https://pub.dev" + source: hosted + version: "0.2.1" + meta: + dependency: transitive + description: + name: meta + sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + msgpack_dart: + dependency: "direct main" + description: + name: msgpack_dart + sha256: c2d235ed01f364719b5296aecf43ac330f0d7bc865fa134d0d7910a40454dffb + url: "https://pub.dev" + source: hosted + version: "1.0.1" + msix: + dependency: "direct dev" + description: + name: msix + sha256: "76c87b8207323803169626a55afd78bbb8413c984df349a76598b9fbf9224677" + url: "https://pub.dev" + source: hosted + version: "3.16.1" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + path: + dependency: "direct main" + description: + name: path + sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" + url: "https://pub.dev" + source: hosted + version: "1.8.3" + path_drawing: + dependency: transitive + description: + name: path_drawing + sha256: bbb1934c0cbb03091af082a6389ca2080345291ef07a5fa6d6e078ba8682f977 + url: "https://pub.dev" + source: hosted + version: "1.0.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: e3e67b1629e6f7e8100b367d3db6ba6af4b1f0bb80f64db18ef1fbabd2fa9ccf + url: "https://pub.dev" + source: hosted + version: "1.0.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "3087813781ab814e4157b172f1a11c46be20179fcc9bea043e0fba36bc0acaa2" + url: "https://pub.dev" + source: hosted + version: "2.0.15" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "2cec049d282c7f13c594b4a73976b0b4f2d7a1838a6dd5aaf7bd9719196bee86" + url: "https://pub.dev" + source: hosted + version: "2.0.27" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "916731ccbdce44d545414dd9961f26ba5fbaa74bcbb55237d8e65a623a8c7297" + url: "https://pub.dev" + source: hosted + version: "2.2.4" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: ffbb8cc9ed2c9ec0e4b7a541e56fd79b138e8f47d2fb86815f15358a349b3b57 + url: "https://pub.dev" + source: hosted + version: "2.1.11" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "57585299a729335f1298b43245842678cb9f43a6310351b18fb577d6e33165ec" + url: "https://pub.dev" + source: hosted + version: "2.0.6" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: "1cb68ba4cd3a795033de62ba1b7b4564dace301f952de6bfb3cd91b202b6ee96" + url: "https://pub.dev" + source: hosted + version: "2.1.7" + patterns_canvas: + dependency: "direct main" + description: + name: patterns_canvas + sha256: "13d88f8abe044a22e22b55ae52c30e8417c7a7748a21a6282048ff32b1acc2f6" + url: "https://pub.dev" + source: hosted + version: "0.4.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: cb3798bef7fc021ac45b308f4b51208a152792445cce0448c9a4ba5879dd8750 + url: "https://pub.dev" + source: hosted + version: "5.4.0" + platform: + dependency: transitive + description: + name: platform + sha256: "4a451831508d7d6ca779f7ac6e212b4023dd5a7d08a27a63da33756410e32b76" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "43798d895c929056255600343db8f049921cbec94d31ec87f1dc5c16c01935dd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "7c1e5f0d23c9016c5bbd8b1473d0d3fb3fc851b876046039509e18e0c7485f2c" + url: "https://pub.dev" + source: hosted + version: "3.7.3" + provider: + dependency: "direct main" + description: + name: provider + sha256: cdbe7530b12ecd9eb455bdaa2fcb8d4dad22e80b8afb4798b41479d5ce26847f + url: "https://pub.dev" + source: hosted + version: "6.0.5" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + screen_retriever: + dependency: transitive + description: + name: screen_retriever + sha256: "4931f226ca158123ccd765325e9fbf360bfed0af9b460a10f960f9bb13d58323" + url: "https://pub.dev" + source: hosted + version: "0.1.6" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "0344316c947ffeb3a529eac929e1978fcd37c26be4e8468628bac399365a3ca1" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: fe8401ec5b6dcd739a0fe9588802069e608c3fdbfd3c3c93e546cf2f90438076 + url: "https://pub.dev" + source: hosted + version: "2.2.0" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: f39696b83e844923b642ce9dd4bd31736c17e697f6731a5adf445b1274cf3cd4 + url: "https://pub.dev" + source: hosted + version: "2.3.2" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "71d6806d1449b0a9d4e85e0c7a917771e672a3d5dc61149cc9fac871115018e1" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "23b052f17a25b90ff2b61aad4cc962154da76fb62848a9ce088efe30d7c50ab1" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: "7347b194fb0bbeb4058e6a4e87ee70350b6b2b90f8ac5f8bd5b3a01548f6d33a" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: f95e6a43162bce43c9c3405f3eb6f39e5b5d11f65fab19196cf8225e2777624d + url: "https://pub.dev" + source: hosted + version: "2.3.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + source_span: + dependency: transitive + description: + name: source_span + sha256: dd904f795d4b4f3b870833847c461801f6750a9fa8e61ea5ac53f9422b31f250 + url: "https://pub.dev" + source: hosted + version: "1.9.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5 + url: "https://pub.dev" + source: hosted + version: "1.11.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + syncfusion_flutter_core: + dependency: transitive + description: + name: syncfusion_flutter_core + sha256: ee86a4531180216d0229ac83db2b3b8428ad4e2fd5f4aff28b89a5aed149bb55 + url: "https://pub.dev" + source: hosted + version: "22.2.7" + syncfusion_flutter_gauges: + dependency: "direct main" + description: + name: syncfusion_flutter_gauges + sha256: f10e2bbf4ed9a012f757a11d06f9813763b50850270caf817e255166a300ef25 + url: "https://pub.dev" + source: hosted + version: "22.2.7" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: eb6ac1540b26de412b3403a163d919ba86f6a973fe6cc50ae3541b80092fdcfb + url: "https://pub.dev" + source: hosted + version: "0.5.1" + titlebar_buttons: + dependency: "direct main" + description: + name: titlebar_buttons + sha256: babf62b48b80f290b9ef8b0135df7d9e9bebcb9c27e8380a53df9bb72f3fb03c + url: "https://pub.dev" + source: hosted + version: "1.0.0" + transitioned_indexed_stack: + dependency: "direct main" + description: + name: transitioned_indexed_stack + sha256: "8023abb5efe72e6d40cc3775fb03d7504c32ac918ec2ce7f9ba6804753820259" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + url: "https://pub.dev" + source: hosted + version: "1.3.2" + vector_math: + dependency: "direct main" + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + visibility_detector: + dependency: "direct main" + description: + name: visibility_detector + sha256: dd5cc11e13494f432d15939c3aa8ae76844c42b723398643ce9addb88a5ed420 + url: "https://pub.dev" + source: hosted + version: "0.4.0+2" + web_socket_channel: + dependency: "direct main" + description: + name: web_socket_channel + sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b + url: "https://pub.dev" + source: hosted + version: "2.4.0" + win32: + dependency: transitive + description: + name: win32 + sha256: f2add6fa510d3ae152903412227bda57d0d5a8da61d2c39c1fb022c9429a41c0 + url: "https://pub.dev" + source: hosted + version: "5.0.6" + window_manager: + dependency: "direct main" + description: + name: window_manager + sha256: "9eef00e393e7f9308309ce9a8b2398c9ee3ca78b50c96e8b4f9873945693ac88" + url: "https://pub.dev" + source: hosted + version: "0.3.5" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: e0b1147eec179d3911f1f19b59206448f78195ca1d20514134e10641b7d7fbff + url: "https://pub.dev" + source: hosted + version: "1.0.1" + xml: + dependency: transitive + description: + name: xml + sha256: "5bc72e1e45e941d825fd7468b9b4cc3b9327942649aeb6fc5cdbf135f0a86e84" + url: "https://pub.dev" + source: hosted + version: "6.3.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + url: "https://pub.dev" + source: hosted + version: "3.1.2" +sdks: + dart: ">=3.0.2 <4.0.0" + flutter: ">=3.10.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 00000000..e402bc8c --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,55 @@ +name: elastic_dashboard +description: A modern customizable dashboard for FRC teams. +publish_to: 'none' +version: 2023.1.0 + +environment: + sdk: '>=3.0.2 <4.0.0' + +dependencies: + animations: ^2.0.7 + contextmenu: ^3.0.0 + file_selector: ^0.9.3 + fl_chart: ^0.63.0 + flutter: + sdk: flutter + flutter_box_transform: ^0.4.2 + flutter_colorpicker: ^1.0.3 + flutter_fancy_tree_view: ^1.1.1 + flutter_hooks: ^0.20.0 + flutter_mjpeg: ^2.0.4 + http: ^0.13.6 + messagepack: ^0.2.1 + msgpack_dart: ^1.0.1 + path: ^1.8.3 + path_provider: ^2.0.15 + patterns_canvas: ^0.4.0 + provider: ^6.0.5 + shared_preferences: ^2.1.2 + syncfusion_flutter_gauges: ^22.2.7 + titlebar_buttons: ^1.0.0 + transitioned_indexed_stack: ^1.0.2 + vector_math: ^2.1.4 + visibility_detector: ^0.4.0+2 + web_socket_channel: ^2.4.0 + window_manager: ^0.3.5 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + msix: ^3.16.1 + +flutter: + uses-material-design: true + + assets: + - assets/logos/ + - assets/fields/ + +msix_config: + display_name: Elastic + publisher_display_name: Gold872 + logo_path: windows/logo.png + icons_background_color: transparent + languages: en-us \ No newline at end of file diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 00000000..d492d0d9 --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt new file mode 100644 index 00000000..8df2baf7 --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,102 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(elastic_dashboard LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "elastic_dashboard") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt new file mode 100644 index 00000000..930d2071 --- /dev/null +++ b/windows/flutter/CMakeLists.txt @@ -0,0 +1,104 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + windows-x64 $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 00000000..fd960fed --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,20 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); + ScreenRetrieverPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ScreenRetrieverPlugin")); + WindowManagerPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("WindowManagerPlugin")); +} diff --git a/windows/flutter/generated_plugin_registrant.h b/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 00000000..dc139d85 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake new file mode 100644 index 00000000..c69a4727 --- /dev/null +++ b/windows/flutter/generated_plugins.cmake @@ -0,0 +1,26 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + file_selector_windows + screen_retriever + window_manager +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/windows/logo.png b/windows/logo.png new file mode 100644 index 00000000..491a7c5f Binary files /dev/null and b/windows/logo.png differ diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt new file mode 100644 index 00000000..394917c0 --- /dev/null +++ b/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc new file mode 100644 index 00000000..be16a5dc --- /dev/null +++ b/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.gold872" "\0" + VALUE "FileDescription", "elastic" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "elastic_dashboard" "\0" + VALUE "LegalCopyright", "Copyright (C) 2023 Gold872. All rights reserved." "\0" + VALUE "OriginalFilename", "elastic_dashboard.exe" "\0" + VALUE "ProductName", "elastic_dashboard" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp new file mode 100644 index 00000000..b25e363e --- /dev/null +++ b/windows/runner/flutter_window.cpp @@ -0,0 +1,66 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h new file mode 100644 index 00000000..6da0652f --- /dev/null +++ b/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp new file mode 100644 index 00000000..648834f0 --- /dev/null +++ b/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"elastic_dashboard", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/windows/runner/resource.h b/windows/runner/resource.h new file mode 100644 index 00000000..66a65d1e --- /dev/null +++ b/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico new file mode 100644 index 00000000..ceb320fc Binary files /dev/null and b/windows/runner/resources/app_icon.ico differ diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest new file mode 100644 index 00000000..a42ea768 --- /dev/null +++ b/windows/runner/runner.exe.manifest @@ -0,0 +1,20 @@ + + + + + PerMonitorV2 + + + + + + + + + + + + + + + diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp new file mode 100644 index 00000000..b2b08734 --- /dev/null +++ b/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length <= 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/windows/runner/utils.h b/windows/runner/utils.h new file mode 100644 index 00000000..3879d547 --- /dev/null +++ b/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp new file mode 100644 index 00000000..60608d0f --- /dev/null +++ b/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h new file mode 100644 index 00000000..e901dde6 --- /dev/null +++ b/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_