diff --git a/.github/workflows/elastic-ci.yml b/.github/workflows/elastic-ci.yml index 2cc8aa1f..29378b47 100644 --- a/.github/workflows/elastic-ci.yml +++ b/.github/workflows/elastic-ci.yml @@ -13,12 +13,12 @@ on: workflow_dispatch: env: - FLUTTER_VERSION: 3.22.0 + FLUTTER_VERSION: 3.22.3 jobs: formatting-analysis: name: "Check Formatting & Analyze" - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - name: Checkout repo @@ -44,10 +44,10 @@ jobs: run: dart run import_sorter:main --exit-if-changed - name: Analyze project source - run: flutter analyze --no-fatal-infos --no-fatal-warnings + run: flutter analyze --no-fatal-infos test: name: "Run Tests" - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - name: Checkout repo @@ -63,16 +63,30 @@ jobs: - name: Install dependencies run: flutter pub get + - name: Install junit reporter + run: dart pub global activate junitreport + - name: Generate mocks run: dart run build_runner build - name: Run tests - run: flutter test --coverage + run: flutter test --coverage --file-reporter json:reports/test-report.json - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v4 - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + uses: codecov/codecov-action@v5 + with: + files: coverage/lcov.info + token: ${{ secrets.CODECOV_TOKEN }} + + - name: Generate junit report xml + run: dart pub global run junitreport:tojunit --input reports/test-report.json --output reports/junit-report.xml + + - name: Upload test reports to Codecov + if: ${{ !cancelled() }} + uses: codecov/test-results-action@v1 + with: + files: reports/junit-report.xml + token: ${{ secrets.CODECOV_TOKEN }} build: strategy: fail-fast: false @@ -86,12 +100,10 @@ jobs: build-option: "macos" artifact-path: "build/macos/Build/Products/Release/Elastic-macOS.zip" artifact-name: Elastic-macOS - executable-type: portable - - os: ubuntu-latest + - os: ubuntu-22.04 build-option: "linux" artifact-path: "build/linux/x64/release/bundle" artifact-name: Elastic-Linux - executable-type: portable name: "Build - ${{ matrix.artifact-name }}" needs: [formatting-analysis, test] @@ -101,11 +113,11 @@ jobs: - name: Checkout repo uses: actions/checkout@v4 - - name: Install flutter dependencies + - name: Install flutter build dependencies if: ${{ matrix.build-option == 'linux' }} run: | sudo apt-get update -y - sudo apt-get install -y ninja-build libgtk-3-dev + sudo apt-get install -y libglu1-mesa ninja-build libgtk-3-dev liblzma-dev - name: Setup flutter uses: subosito/flutter-action@v2 @@ -123,18 +135,11 @@ jobs: - name: Build app run: flutter build ${{ matrix.build-option }} - - name: Create installer - if: ${{ matrix.build-option == 'windows' }} - uses: Minionguyjpro/Inno-Setup-Action@v1.2.2 - with: - path: installer_setup_script.iss - options: /O+ - - name: Zip release if: ${{ matrix.build-option == 'macos' }} run: | cd build/macos/Build/Products/Release - zip -r Elastic-macOS.zip elastic_dashboard.app --symlinks + zip -r ${{ matrix.artifact-name }}.zip elastic_dashboard.app --symlinks - name: Upload artifact if: ${{ matrix.build-option != 'windows' }} @@ -144,7 +149,7 @@ jobs: path: ${{ matrix.artifact-path }} if-no-files-found: error - - name: Upload artifact (Windows portable) + - name: Upload windows portable if: ${{ matrix.build-option == 'windows' }} uses: actions/upload-artifact@v4 with: @@ -152,10 +157,93 @@ jobs: path: ${{ matrix.artifact-path }} if-no-files-found: error - - name: Upload artifact (Windows installer) + - name: Create macOS installer + if: ${{ matrix.build-option == 'macos' }} + uses: L-Super/create-dmg-actions@v1.0.3 + with: + dmg_name: build/macos/Build/Products/Release/elastic-setup + src_dir: build/macos/Build/Products/Release/elastic_dashboard.app + + - name: Upload macOS installer + if: ${{ matrix.build-option == 'macos' }} + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact-name }}_installer + path: build/macos/Build/Products/Release/elastic-setup.dmg + if-no-files-found: error + + - name: Create windows installer + if: ${{ matrix.build-option == 'windows' }} + uses: Minionguyjpro/Inno-Setup-Action@v1.2.5 + with: + path: installer_setup_script.iss + options: /O+ + + - name: Upload windows installer if: ${{ matrix.build-option == 'windows' }} uses: actions/upload-artifact@v4 with: name: ${{ matrix.artifact-name }}_installer path: "build/windows/x64/installer" - if-no-files-found: error \ No newline at end of file + if-no-files-found: error + + build-wpilib: + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + build-option: "windows" + artifact-path: "build/windows/x64/runner/Release" + artifact-name: Elastic-WPILib-Windows + - os: macos-latest + build-option: "macos" + artifact-path: "build/macos/Build/Products/Release/Elastic-WPILib-macOS.tar.gz" + artifact-name: Elastic-WPILib-macOS + - os: ubuntu-22.04 + build-option: "linux" + artifact-path: "build/linux/x64/release/bundle" + artifact-name: Elastic-WPILib-Linux + + name: "Build - ${{ matrix.artifact-name }}" + needs: [formatting-analysis, test] + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Install flutter build dependencies + if: ${{ matrix.build-option == 'linux' }} + run: | + sudo apt-get update -y + sudo apt-get install -y libglu1-mesa ninja-build libgtk-3-dev liblzma-dev + + - name: Setup flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + cache: true + cache-path: ${{ runner.tool_cache }}/flutter/${{ matrix.build-option }} + + - name: Install dependencies + run: flutter pub get + + - name: Generate icons + run: dart run flutter_launcher_icons -f wpilib_icon_config.yaml + + - name: Build app + run: flutter build ${{ matrix.build-option }} --dart-define=ELASTIC_WPILIB=true + + - name: Zip release + if: ${{ matrix.build-option == 'macos' }} + run: | + cd build/macos/Build/Products/Release + tar -zcvf ${{ matrix.artifact-name }}.tar.gz elastic_dashboard.app + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact-name }} + path: ${{ matrix.artifact-path }} + if-no-files-found: error diff --git a/.github/workflows/elasticlib-ci.yaml b/.github/workflows/elasticlib-ci.yaml new file mode 100644 index 00000000..adfeb4db --- /dev/null +++ b/.github/workflows/elasticlib-ci.yaml @@ -0,0 +1,32 @@ +name: ElasticLib + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + workflow_dispatch: + +jobs: + wpiformat-analyze: + name: "Verify Formatting" + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Fetch all history and metadata + run: | + git checkout -b pr + git branch -f main origin/main + - name: Set up Python 3.8 + uses: actions/setup-python@v5 + with: + python-version: 3.8 + - name: Install wpiformat + run: pip3 install wpiformat==2024.50 + - name: Run wpiformat + run: wpiformat -f Elastic.java elasticlib.h elasticlib.cpp elasticlib.py + working-directory: ./elasticlib + - name: Check output + run: git --no-pager diff --exit-code HEAD diff --git a/.gitignore b/.gitignore index eba293ad..266efad2 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ migrate_working_dir/ coverage/ *.mocks.dart +reports/ # IntelliJ related *.iml @@ -21,7 +22,7 @@ coverage/ # 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/ +.vscode/ # Flutter/Dart/Pub related **/doc/api/ diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 00000000..980184f1 --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,21 @@ +# Privacy Policy + +Elastic is a volunteer-run project that is owned and published by Nadav from FIRST Robotics Competition Team 353 ("Elastic"), based in the United States. + +## Information We Collect + +Elastic does not collect data from users of the application. + +The Elastic application will create and store logs of application errors and user interactions on the user's system, but these logs are not shared unless it's done manually by the user. + +## Third-Party Data Collection + +The Elastic repository and documentation site are hosted by GitHub, which publishes a [privacy policy](https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement) that applies when interacting with these services. + +The Elastic application may connect to GitHub services to check for updates. These interactions are covered by the GitHub privacy policy. + +The documentation for Elastic is hosted on GitBook, which has a [privacy policy](https://policies.gitbook.com/privacy-and-security/statement) that applies when interacting with their services. + +## Contact + +Questions about this policy can be directed to frc-elastic@outlook.com diff --git a/README.md b/README.md index bd2cc4f0..ea3e45f0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ![Elastic Logo](assets/logos/logo_full.png) -[![Elastic](https://github.com/Gold872/elastic-dashboard/actions/workflows/elastic-ci.yml/badge.svg)](https://github.com/Gold872/elastic-dashboard/actions/workflows/elastic-ci.yml) +[![Elastic](https://github.com/Gold872/elastic-dashboard/actions/workflows/elastic-ci.yml/badge.svg)](https://github.com/Gold872/elastic-dashboard/actions/workflows/elastic-ci.yml) [![codecov](https://codecov.io/gh/Gold872/elastic-dashboard/graph/badge.svg?token=4MQYW8SMQI)](https://codecov.io/gh/Gold872/elastic-dashboard) A simple and modern dashboard for FRC. @@ -23,7 +23,7 @@ Elastic is a simple and modern Shuffleboard alternative made by Team 353. It is ![Example Layout](/screenshots/example_layout.png) ## Documentation -View the online documentation [here](https://github.com/Gold872/elastic-dashboard/wiki) +View the online documentation [here](https://frc-elastic.gitbook.io/docs) ## Special Thanks @@ -41,4 +41,4 @@ This dashboard wouldn't have been made without the help and inspiration from the * [Rapid React](https://www.chiefdelphi.com/t/2022-top-down-field-renders/399031) * [Charged Up](https://www.chiefdelphi.com/t/2023-top-down-field-renders/421365) * [Crescendo](https://www.chiefdelphi.com/t/2024-crescendo-top-down-field-renders/447764) -* All mentors and advisors of Team 353, the POBots \ No newline at end of file +* All mentors and advisors of Team 353, the POBots diff --git a/assets/fields/2018-field.png b/assets/fields/2018-field.png index 6f966598..ea795097 100644 Binary files a/assets/fields/2018-field.png and b/assets/fields/2018-field.png differ diff --git a/assets/fields/2019-field.png b/assets/fields/2019-field.png index c0ae5121..16f7c2ec 100644 Binary files a/assets/fields/2019-field.png and b/assets/fields/2019-field.png differ diff --git a/assets/fields/2020-field.png b/assets/fields/2020-field.png index 3c7f6fb3..9df5427d 100644 Binary files a/assets/fields/2020-field.png and b/assets/fields/2020-field.png differ diff --git a/assets/fields/2022-field.png b/assets/fields/2022-field.png index e48d716e..5b3fcd02 100644 Binary files a/assets/fields/2022-field.png and b/assets/fields/2022-field.png differ diff --git a/assets/fields/2023-field.png b/assets/fields/2023-field.png index 9a1b156c..af87eda5 100644 Binary files a/assets/fields/2023-field.png and b/assets/fields/2023-field.png differ diff --git a/assets/fields/2024-field.png b/assets/fields/2024-field.png index 85b8d002..c153bd7e 100644 Binary files a/assets/fields/2024-field.png and b/assets/fields/2024-field.png differ diff --git a/assets/logos/wpilib_logo.png b/assets/logos/wpilib_logo.png new file mode 100644 index 00000000..fa691a7e Binary files /dev/null and b/assets/logos/wpilib_logo.png differ diff --git a/assets/wpilib-icon.ico b/assets/wpilib-icon.ico new file mode 100644 index 00000000..1575304d Binary files /dev/null and b/assets/wpilib-icon.ico differ diff --git a/elasticlib/.clang-format b/elasticlib/.clang-format new file mode 100644 index 00000000..d424ff94 --- /dev/null +++ b/elasticlib/.clang-format @@ -0,0 +1,271 @@ +--- +Language: Cpp +BasedOnStyle: Google +AccessModifierOffset: -1 +AlignAfterOpenBracket: Align +AlignArrayOfStructures: None +AlignConsecutiveAssignments: + Enabled: false + AcrossEmptyLines: false + AcrossComments: false + AlignCompound: false + PadOperators: true +AlignConsecutiveBitFields: + Enabled: false + AcrossEmptyLines: false + AcrossComments: false + AlignCompound: false + PadOperators: false +AlignConsecutiveDeclarations: + Enabled: false + AcrossEmptyLines: false + AcrossComments: false + AlignCompound: false + PadOperators: false +AlignConsecutiveMacros: + Enabled: false + AcrossEmptyLines: false + AcrossComments: false + AlignCompound: false + PadOperators: false +AlignConsecutiveShortCaseStatements: + Enabled: false + AcrossEmptyLines: false + AcrossComments: false + AlignCaseColons: false +AlignEscapedNewlines: Left +AlignOperands: Align +AlignTrailingComments: + Kind: Always + OverEmptyLines: 0 +AllowAllArgumentsOnNextLine: true +AllowAllParametersOfDeclarationOnNextLine: true +AllowShortBlocksOnASingleLine: Never +AllowShortCaseLabelsOnASingleLine: false +AllowShortEnumsOnASingleLine: true +AllowShortFunctionsOnASingleLine: Inline +AllowShortIfStatementsOnASingleLine: Never +AllowShortLambdasOnASingleLine: All +AllowShortLoopsOnASingleLine: false +AlwaysBreakAfterDefinitionReturnType: None +AlwaysBreakAfterReturnType: None +AlwaysBreakBeforeMultilineStrings: true +AlwaysBreakTemplateDeclarations: Yes +AttributeMacros: + - __capability +BinPackArguments: true +BinPackParameters: true +BitFieldColonSpacing: Both +BraceWrapping: + AfterCaseLabel: false + AfterClass: false + AfterControlStatement: Never + AfterEnum: false + AfterExternBlock: false + AfterFunction: false + AfterNamespace: false + AfterObjCDeclaration: false + AfterStruct: false + AfterUnion: false + BeforeCatch: false + BeforeElse: false + BeforeLambdaBody: false + BeforeWhile: false + IndentBraces: false + SplitEmptyFunction: true + SplitEmptyRecord: true + SplitEmptyNamespace: true +BreakAfterAttributes: Always +BreakAfterJavaFieldAnnotations: false +BreakArrays: true +BreakBeforeBinaryOperators: None +BreakBeforeConceptDeclarations: Always +BreakBeforeBraces: Attach +BreakBeforeInlineASMColon: OnlyMultiline +BreakBeforeTernaryOperators: true +BreakConstructorInitializers: BeforeColon +BreakInheritanceList: BeforeColon +BreakStringLiterals: true +ColumnLimit: 80 +CommentPragmas: '^ IWYU pragma:' +CompactNamespaces: false +ConstructorInitializerIndentWidth: 4 +ContinuationIndentWidth: 4 +Cpp11BracedListStyle: true +DerivePointerAlignment: false +DisableFormat: false +EmptyLineAfterAccessModifier: Never +EmptyLineBeforeAccessModifier: LogicalBlock +ExperimentalAutoDetectBinPacking: false +FixNamespaceComments: true +ForEachMacros: + - foreach + - Q_FOREACH + - BOOST_FOREACH +IfMacros: + - KJ_IF_MAYBE +IncludeBlocks: Regroup +IncludeCategories: + - Regex: '^' + Priority: 2 + SortPriority: 0 + CaseSensitive: false + - Regex: '^<.*\.h>' + Priority: 1 + SortPriority: 0 + CaseSensitive: false + - Regex: '^<.*' + Priority: 2 + SortPriority: 0 + CaseSensitive: false + - Regex: '.*' + Priority: 3 + SortPriority: 0 + CaseSensitive: false +IncludeIsMainRegex: '([-_](test|unittest))?$' +IncludeIsMainSourceRegex: '' +IndentAccessModifiers: false +IndentCaseBlocks: false +IndentCaseLabels: true +IndentExternBlock: AfterExternBlock +IndentGotoLabels: true +IndentPPDirectives: None +IndentRequiresClause: true +IndentWidth: 2 +IndentWrappedFunctionNames: false +InsertBraces: false +InsertNewlineAtEOF: false +InsertTrailingCommas: None +IntegerLiteralSeparator: + Binary: 0 + BinaryMinDigits: 0 + Decimal: 0 + DecimalMinDigits: 0 + Hex: 0 + HexMinDigits: 0 +JavaScriptQuotes: Leave +JavaScriptWrapImports: true +KeepEmptyLinesAtTheStartOfBlocks: false +KeepEmptyLinesAtEOF: false +LambdaBodyIndentation: Signature +LineEnding: DeriveLF +MacroBlockBegin: '' +MacroBlockEnd: '' +Macros: + - 'HAL_ENUM(name)=enum name' +MaxEmptyLinesToKeep: 1 +NamespaceIndentation: None +ObjCBinPackProtocolList: Never +ObjCBlockIndentWidth: 2 +ObjCBreakBeforeNestedBlockParam: true +ObjCSpaceAfterProperty: false +ObjCSpaceBeforeProtocolList: true +PackConstructorInitializers: NextLine +PenaltyBreakAssignment: 2 +PenaltyBreakBeforeFirstCallParameter: 1 +PenaltyBreakComment: 300 +PenaltyBreakFirstLessLess: 120 +PenaltyBreakOpenParenthesis: 0 +PenaltyBreakString: 1000 +PenaltyBreakTemplateDeclaration: 10 +PenaltyExcessCharacter: 1000000 +PenaltyIndentedWhitespace: 0 +PenaltyReturnTypeOnItsOwnLine: 200 +PointerAlignment: Left +PPIndentWidth: -1 +QualifierAlignment: Leave +RawStringFormats: + - Language: Cpp + Delimiters: + - cc + - CC + - cpp + - Cpp + - CPP + - 'c++' + - 'C++' + CanonicalDelimiter: '' + BasedOnStyle: google + - Language: TextProto + Delimiters: + - pb + - PB + - proto + - PROTO + EnclosingFunctions: + - EqualsProto + - EquivToProto + - PARSE_PARTIAL_TEXT_PROTO + - PARSE_TEST_PROTO + - PARSE_TEXT_PROTO + - ParseTextOrDie + - ParseTextProtoOrDie + - ParseTestProto + - ParsePartialTestProto + CanonicalDelimiter: pb + BasedOnStyle: google +ReferenceAlignment: Pointer +ReflowComments: true +RemoveBracesLLVM: false +RemoveParentheses: Leave +RemoveSemicolon: false +RequiresClausePosition: OwnLine +RequiresExpressionIndentation: OuterScope +SeparateDefinitionBlocks: Leave +ShortNamespaceLines: 1 +SortIncludes: false +SortJavaStaticImport: Before +SortUsingDeclarations: LexicographicNumeric +SpaceAfterCStyleCast: false +SpaceAfterLogicalNot: false +SpaceAfterTemplateKeyword: true +SpaceAroundPointerQualifiers: Default +SpaceBeforeAssignmentOperators: true +SpaceBeforeCaseColon: false +SpaceBeforeCpp11BracedList: false +SpaceBeforeCtorInitializerColon: true +SpaceBeforeInheritanceColon: true +SpaceBeforeJsonColon: false +SpaceBeforeParens: ControlStatements +SpaceBeforeParensOptions: + AfterControlStatements: true + AfterForeachMacros: true + AfterFunctionDefinitionName: false + AfterFunctionDeclarationName: false + AfterIfMacros: true + AfterOverloadedOperator: false + AfterRequiresInClause: false + AfterRequiresInExpression: false + BeforeNonEmptyParentheses: false +SpaceBeforeRangeBasedForLoopColon: true +SpaceBeforeSquareBrackets: false +SpaceInEmptyBlock: false +SpacesBeforeTrailingComments: 2 +SpacesInAngles: Never +SpacesInContainerLiterals: true +SpacesInLineCommentPrefix: + Minimum: 1 + Maximum: -1 +SpacesInParens: Never +SpacesInParensOptions: + InCStyleCasts: false + InConditionalStatements: false + InEmptyParentheses: false + Other: false +SpacesInSquareBrackets: false +Standard: c++20 +StatementAttributeLikeMacros: + - Q_EMIT +StatementMacros: + - Q_UNUSED + - QT_REQUIRE_VERSION +TabWidth: 8 +UseTab: Never +VerilogBreakBetweenInstancePorts: true +WhitespaceSensitiveMacros: + - BOOST_PP_STRINGIZE + - CF_SWIFT_NAME + - NS_SWIFT_NAME + - PP_STRINGIZE + - STRINGIZE +... diff --git a/elasticlib/.styleguide b/elasticlib/.styleguide new file mode 100644 index 00000000..2301a18e --- /dev/null +++ b/elasticlib/.styleguide @@ -0,0 +1,30 @@ +cppHeaderFileInclude { + \.h$ + \.hpp$ + \.inc$ + \.inl$ +} + +cppSrcFileInclude { + \.cpp$ +} + +modifiableFileExclude { + gradle/ + assets/ + build/ + linux/ + macos/ + windows/ + test_resources/ + screenshots/ + installer_setup_script.iss + README.md +} + +includeOtherLibs { + ^fmt/ + ^networktables/ + ^units/ + ^wpi/ +} diff --git a/elasticlib/.styleguide-license b/elasticlib/.styleguide-license new file mode 100644 index 00000000..9575e60c --- /dev/null +++ b/elasticlib/.styleguide-license @@ -0,0 +1,4 @@ +// Copyright (c) 2023-2024 Gold87 and other Elastic contributors +// This software can be modified and/or shared under the terms +// defined by the Elastic license: +// https://github.com/Gold872/elastic-dashboard/blob/main/LICENSE diff --git a/elasticlib/Elastic.java b/elasticlib/Elastic.java index 76c952d2..b124759a 100644 --- a/elasticlib/Elastic.java +++ b/elasticlib/Elastic.java @@ -1,3 +1,8 @@ +// Copyright (c) 2023-2024 Gold87 and other Elastic contributors +// This software can be modified and/or shared under the terms +// defined by the Elastic license: +// https://github.com/Gold872/elastic-dashboard/blob/main/LICENSE + package frc.robot.util; import com.fasterxml.jackson.annotation.JsonProperty; @@ -15,15 +20,26 @@ public final class Elastic { topic.publish(PubSubOption.sendAll(true), PubSubOption.keepDuplicates(true)); private static final ObjectMapper objectMapper = new ObjectMapper(); - public static void sendAlert(ElasticNotification alert) { + /** + * Sends an notification to the Elastic dashboard. The notification is serialized as a JSON string + * before being published. + * + * @param notification the {@link Notification} object containing notification details + */ + public static void sendNotification(Notification notification) { try { - publisher.set(objectMapper.writeValueAsString(alert)); + publisher.set(objectMapper.writeValueAsString(notification)); } catch (JsonProcessingException e) { e.printStackTrace(); } } - public static class ElasticNotification { + /** + * Represents an notification object to be sent to the Elastic dashboard. This object holds + * properties such as level, title, description, display time, and dimensions to control how the + * notification is displayed on the dashboard. + */ + public static class Notification { @JsonProperty("level") private NotificationLevel level; @@ -33,28 +49,128 @@ public static class ElasticNotification { @JsonProperty("description") private String description; - public ElasticNotification(NotificationLevel level, String title, String description) { + @JsonProperty("displayTime") + private int displayTimeMillis; + + @JsonProperty("width") + private double width; + + @JsonProperty("height") + private double height; + + /** + * Creates a new Notification with all default parameters. This constructor is intended + * to be used with the chainable decorator methods + * + *

Title and description fields are empty. + */ + public Notification() { + this(NotificationLevel.INFO, "", ""); + } + + /** + * Creates a new Notification with all properties specified. + * + * @param level the level of the notification (e.g., INFO, WARNING, ERROR) + * @param title the title text of the notification + * @param description the descriptive text of the notification + * @param displayTimeMillis the time in milliseconds for which the notification is displayed + * @param width the width of the notification display area + * @param height the height of the notification display area, inferred if below zero + */ + public Notification( + NotificationLevel level, + String title, + String description, + int displayTimeMillis, + double width, + double height) { this.level = level; this.title = title; + this.displayTimeMillis = displayTimeMillis; this.description = description; + this.height = height; + this.width = width; + } + + /** + * Creates a new Notification with default display time and dimensions. + * + * @param level the level of the notification + * @param title the title text of the notification + * @param description the descriptive text of the notification + */ + public Notification(NotificationLevel level, String title, String description) { + this(level, title, description, 3000, 350, -1); + } + + /** + * Creates a new Notification with a specified display time and default dimensions. + * + * @param level the level of the notification + * @param title the title text of the notification + * @param description the descriptive text of the notification + * @param displayTimeMillis the display time in milliseconds + */ + public Notification( + NotificationLevel level, String title, String description, int displayTimeMillis) { + this(level, title, description, displayTimeMillis, 350, -1); + } + + /** + * Creates a new Notification with specified dimensions and default display time. If the height + * is below zero, it is automatically inferred based on screen size. + * + * @param level the level of the notification + * @param title the title text of the notification + * @param description the descriptive text of the notification + * @param width the width of the notification display area + * @param height the height of the notification display area, inferred if below zero + */ + public Notification( + NotificationLevel level, String title, String description, double width, double height) { + this(level, title, description, 3000, width, height); } + /** + * Updates the level of this notification + * + * @param level the level to set the notification to + */ public void setLevel(NotificationLevel level) { this.level = level; } + /** + * @return the level of this notification + */ public NotificationLevel getLevel() { return level; } + /** + * Updates the title of this notification + * + * @param title the title to set the notification to + */ public void setTitle(String title) { this.title = title; } + /** + * Gets the title of this notification + * + * @return the title of this notification + */ public String getTitle() { return title; } + /** + * Updates the description of this notification + * + * @param description the description to set the notification to + */ public void setDescription(String description) { this.description = description; } @@ -63,9 +179,184 @@ public String getDescription() { return description; } + /** + * Updates the display time of the notification + * + * @param seconds the number of seconds to display the notification for + */ + public void setDisplayTimeSeconds(double seconds) { + setDisplayTimeMillis((int) Math.round(seconds * 1000)); + } + + /** + * Updates the display time of the notification in milliseconds + * + * @param displayTimeMillis the number of milliseconds to display the notification for + */ + public void setDisplayTimeMillis(int displayTimeMillis) { + this.displayTimeMillis = displayTimeMillis; + } + + /** + * Gets the display time of the notification in milliseconds + * + * @return the number of milliseconds the notification is displayed for + */ + public int getDisplayTimeMillis() { + return displayTimeMillis; + } + + /** + * Updates the width of the notification + * + * @param width the width to set the notification to + */ + public void setWidth(double width) { + this.width = width; + } + + /** + * Gets the width of the notification + * + * @return the width of the notification + */ + public double getWidth() { + return width; + } + + /** + * Updates the height of the notification + * + *

If the height is set to -1, the height will be determined automatically by the dashboard + * + * @param height the height to set the notification to + */ + public void setHeight(double height) { + this.height = height; + } + + /** + * Gets the height of the notification + * + * @return the height of the notification + */ + public double getHeight() { + return height; + } + + /** + * Modifies the notification's level and returns itself to allow for method chaining + * + * @param level the level to set the notification to + * @return the current notification + */ + public Notification withLevel(NotificationLevel level) { + this.level = level; + return this; + } + + /** + * Modifies the notification's title and returns itself to allow for method chaining + * + * @param title the title to set the notification to + * @return the current notification + */ + public Notification withTitle(String title) { + setTitle(title); + return this; + } + + /** + * Modifies the notification's description and returns itself to allow for method chaining + * + * @param description the description to set the notification to + * @return the current notification + */ + public Notification withDescription(String description) { + setDescription(description); + return this; + } + + /** + * Modifies the notification's display time and returns itself to allow for method chaining + * + * @param seconds the number of seconds to display the notification for + * @return the current notification + */ + public Notification withDisplaySeconds(double seconds) { + return withDisplayMilliseconds((int) Math.round(seconds * 1000)); + } + + /** + * Modifies the notification's display time and returns itself to allow for method chaining + * + * @param displayTimeMillis the number of milliseconds to display the notification for + * @return the current notification + */ + public Notification withDisplayMilliseconds(int displayTimeMillis) { + setDisplayTimeMillis(displayTimeMillis); + return this; + } + + /** + * Modifies the notification's width and returns itself to allow for method chaining + * + * @param width the width to set the notification to + * @return the current notification + */ + public Notification withWidth(double width) { + setWidth(width); + return this; + } + + /** + * Modifies the notification's height and returns itself to allow for method chaining + * + * @param height the height to set the notification to + * @return the current notification + */ + public Notification withHeight(double height) { + setHeight(height); + return this; + } + + /** + * Modifies the notification's height and returns itself to allow for method chaining + * + *

This will set the height to -1 to have it automatically determined by the dashboard + * + * @return the current notification + */ + public Notification withAutomaticHeight() { + setHeight(-1); + return this; + } + + /** + * Modifies the notification to disable the auto dismiss behavior + * + *

This sets the display time to 0 milliseconds + * + *

The auto dismiss behavior can be re-enabled by setting the display time to a number + * greater than 0 + * + * @return the current notification + */ + public Notification withNoAutoDismiss() { + setDisplayTimeMillis(0); + return this; + } + + /** + * Represents the possible levels of notifications for the Elastic dashboard. These levels are + * used to indicate the severity or type of notification. + */ public enum NotificationLevel { + /** Informational Message */ INFO, + /** Warning message */ WARNING, + /** Error message */ ERROR } } diff --git a/elasticlib/elasticlib.cpp b/elasticlib/elasticlib.cpp new file mode 100644 index 00000000..065322a3 --- /dev/null +++ b/elasticlib/elasticlib.cpp @@ -0,0 +1,53 @@ +// Copyright (c) 2023-2024 Gold87 and other Elastic contributors +// This software can be modified and/or shared under the terms +// defined by the Elastic license: +// https://github.com/Gold872/elastic-dashboard/blob/main/LICENSE + +#include "elasticlib.h" + +#include + +#include +#include +#include +#include + +namespace elastic { + +void SendNotification(const Notification& notification) { + static nt::StringTopic topic = + nt::NetworkTableInstance::GetDefault().GetStringTopic( + "/Elastic/RobotNotifications"); + static nt::StringPublisher publisher = + topic.Publish({.sendAll = true, .keepDuplicates = true}); + + try { + // Convert Notification to JSON string + wpi::json jsonData; + + if (notification.level == NotificationLevel::INFO) { + jsonData["level"] = "INFO"; + } else if (notification.level == NotificationLevel::WARNING) { + jsonData["level"] = "WARNING"; + } else if (notification.level == NotificationLevel::ERROR) { + jsonData["level"] = "ERROR"; + } else { + jsonData["level"] = "UNKNOWN"; + } + + jsonData["title"] = notification.title; + jsonData["description"] = notification.description; + jsonData["displayTime"] = notification.displayTime.value(); + jsonData["width"] = notification.width; + jsonData["height"] = notification.height; + + // Publish the JSON string + publisher.Set(jsonData.dump()); + } catch (const std::exception& e) { + fmt::println(stderr, "Error processing JSON: {}", e.what()); + } catch (...) { + fmt::println(stderr, "Unknown error occurred while processing JSON."); + } +} + +} // namespace elastic diff --git a/elasticlib/elasticlib.h b/elasticlib/elasticlib.h new file mode 100644 index 00000000..d3697230 --- /dev/null +++ b/elasticlib/elasticlib.h @@ -0,0 +1,56 @@ +// Copyright (c) 2023-2024 Gold87 and other Elastic contributors +// This software can be modified and/or shared under the terms +// defined by the Elastic license: +// https://github.com/Gold872/elastic-dashboard/blob/main/LICENSE + +#pragma once + +#include + +#include + +namespace elastic { + +/** + * Defines severity levels for notifications. + */ +enum class NotificationLevel { INFO, WARNING, ERROR }; + +/** + * Represents an notification with various display properties. + */ +struct Notification { + /// Set the display time to this value to disable the auto-dismiss behavior. + static constexpr units::millisecond_t NO_AUTO_DISMISS{0_s}; + + // Set the height to this value to have the dashboard automatically determine + // the height. + static constexpr int AUTOMATIC_HEIGHT = -1; + + /// Notification severity level. + NotificationLevel level = NotificationLevel::INFO; + + /// Title of the notification. + std::string title; + + /// Description of the notification. + std::string description; + + /// Display time. + units::millisecond_t displayTime{3_s}; + + /// Display width in pixels. + int width = 350; + + /// Display height in pixels. + int height = AUTOMATIC_HEIGHT; +}; + +/** + * Publishes an notification as a JSON string to the NetworkTables topic. + * + * @param notification The notification to send. + */ +void SendNotification(const Notification& notification); + +} // namespace elastic diff --git a/elasticlib/elasticlib.py b/elasticlib/elasticlib.py new file mode 100644 index 00000000..566941c5 --- /dev/null +++ b/elasticlib/elasticlib.py @@ -0,0 +1,83 @@ +import json +from enum import Enum + +from ntcore import NetworkTableInstance, PubSubOptions + + +class NotificationLevel(Enum): + INFO = "INFO" + WARNING = "WARNING" + ERROR = "ERROR" + + +class Notification: + """Represents an notification with various display properties.""" + + def __init__( + self, + level=NotificationLevel.INFO, + title: str = "", + description: str = "", + display_time: int = 3000, + width: float = 350, + height: float = -1, + ): + """ + Initializes an ElasticNotification object. + + Args: + level (str): The severity level of the notification. Default is 'INFO'. + title (str): The title of the notification. Default is an empty string. + description (str): The description of the notification. Default is an empty string. + display_time (int): Time in milliseconds for which the notification should be displayed. Default is 3000 ms. + width (float): Width of the notification display area. Default is 350. + height (float): Height of the notification display area. Default is -1 (automatic height). + """ + self.level = level + self.title = title + self.description = description + self.display_time = display_time + self.width = width + self.height = height + + +__topic = None +__publisher = None + + +def send_notification(notification: Notification): + """ + Sends an notification notification to the Elastic dashboard. + The notification is serialized as a JSON string before being published. + + Args: + notification (ElasticNotification): The notification object containing the notification details. + + Raises: + Exception: If there is an error during serialization or publishing the notification. + """ + global __topic + global __publisher + + if not __topic: + __topic = NetworkTableInstance.getDefault().getStringTopic( + "/Elastic/RobotNotifications" + ) + if not __publisher: + __publisher = __topic.publish(PubSubOptions(sendAll=True, keepDuplicates=True)) + + try: + __publisher.set( + json.dumps( + { + "level": notification.level, + "title": notification.title, + "description": notification.description, + "displayTime": notification.display_time, + "width": notification.width, + "height": notification.height, + } + ) + ) + except Exception as e: + print(f"Error serializing notification: {e}") diff --git a/installer_setup_script.iss b/installer_setup_script.iss index 4b546bcd..4da0429e 100644 --- a/installer_setup_script.iss +++ b/installer_setup_script.iss @@ -3,7 +3,8 @@ #define MyAppPublisher "Gold87" #define MyAppURL "https://github.com/gold872/elastic-dashboard/" #define MyAppExeName "elastic_dashboard.exe" -#define ApplicationVersion GetStringFileInfo('build\windows\x64\runner\Release\elastic_dashboard.exe', 'ProductVersion') +#define AppVersionName GetStringFileInfo('build\windows\x64\runner\Release\elastic_dashboard.exe', 'ProductVersion') +#define ApplicationVersion GetVersionNumbersString('build\windows\x64\runner\Release\elastic_dashboard.exe') [Setup] ; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications. @@ -11,7 +12,7 @@ AppId={{2746922E-A2AC-4987-AF02-714F429C7C77} AppName={#MyAppName} AppVersion={#ApplicationVersion} -AppVerName={#MyAppName} {#ApplicationVersion} +AppVerName={#MyAppName} {#AppVersionName} VersionInfoVersion={#ApplicationVersion} AppPublisher={#MyAppPublisher} AppPublisherURL={#MyAppURL} diff --git a/lib/main.dart b/lib/main.dart index 8d968a70..6c1cec15 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -14,11 +14,13 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:window_manager/window_manager.dart'; import 'package:elastic_dashboard/pages/dashboard_page.dart'; +import 'package:elastic_dashboard/services/app_distributor.dart'; import 'package:elastic_dashboard/services/field_images.dart'; import 'package:elastic_dashboard/services/log.dart'; import 'package:elastic_dashboard/services/nt_connection.dart'; import 'package:elastic_dashboard/services/nt_widget_builder.dart'; import 'package:elastic_dashboard/services/settings.dart'; +import 'package:elastic_dashboard/services/update_checker.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -65,11 +67,12 @@ void main() async { await FieldImages.loadFields('assets/fields/'); Display primaryDisplay = await screenRetriever.getPrimaryDisplay(); - Size screenSize = (primaryDisplay.visibleSize ?? primaryDisplay.size) * - (primaryDisplay.scaleFactor?.toDouble() ?? 1.0); + double scaleFactor = (primaryDisplay.scaleFactor?.toDouble() ?? 1.0); + Size screenSize = + (primaryDisplay.visibleSize ?? primaryDisplay.size) * scaleFactor; - double minimumWidth = min(screenSize.width * 0.60, 1280.0); - double minimumHeight = min(screenSize.height * 0.60, 720.0); + double minimumWidth = min(screenSize.width * 0.77 / scaleFactor, 1280.0); + double minimumHeight = min(screenSize.height * 0.7 / scaleFactor, 720.0); Size minimumSize = Size(minimumWidth, minimumHeight); @@ -208,12 +211,13 @@ class _ElasticState extends State { ); return MaterialApp( debugShowCheckedModeBanner: false, - title: 'Elastic', + title: appTitle, theme: theme, home: DashboardPage( ntConnection: widget.ntConnection, preferences: widget.preferences, version: widget.version, + updateChecker: UpdateChecker(currentVersion: widget.version), onColorChanged: (color) => setState(() { teamColor = color; widget.preferences.setInt(PrefKeys.teamColor, color.value); diff --git a/lib/pages/dashboard_page.dart b/lib/pages/dashboard_page.dart index 18dfb656..2b8c4d31 100644 --- a/lib/pages/dashboard_page.dart +++ b/lib/pages/dashboard_page.dart @@ -12,12 +12,16 @@ import 'package:elegant_notification/elegant_notification.dart'; import 'package:elegant_notification/resources/stacked_options.dart'; import 'package:file_selector/file_selector.dart'; import 'package:flex_seed_scheme/flex_seed_scheme.dart'; +import 'package:http/http.dart'; +import 'package:path/path.dart' as path; import 'package:popover/popover.dart'; import 'package:screen_retriever/screen_retriever.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:window_manager/window_manager.dart'; +import 'package:elastic_dashboard/services/app_distributor.dart'; +import 'package:elastic_dashboard/services/elastic_layout_downloader.dart'; import 'package:elastic_dashboard/services/hotkey_manager.dart'; import 'package:elastic_dashboard/services/ip_address_util.dart'; import 'package:elastic_dashboard/services/log.dart'; @@ -28,6 +32,8 @@ import 'package:elastic_dashboard/services/shuffleboard_nt_listener.dart'; import 'package:elastic_dashboard/services/update_checker.dart'; import 'package:elastic_dashboard/util/tab_data.dart'; import 'package:elastic_dashboard/widgets/custom_appbar.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/dialog_widgets/dialog_toggle_switch.dart'; import 'package:elastic_dashboard/widgets/dialog_widgets/layout_drag_tile.dart'; import 'package:elastic_dashboard/widgets/draggable_containers/models/widget_container_model.dart'; @@ -42,6 +48,8 @@ class DashboardPage extends StatefulWidget { final String version; final NTConnection ntConnection; final SharedPreferences preferences; + final UpdateChecker updateChecker; + final ElasticLayoutDownloader? layoutDownloader; final Function(Color color)? onColorChanged; final Function(FlexSchemeVariant variant)? onThemeVariantChanged; @@ -50,6 +58,8 @@ class DashboardPage extends StatefulWidget { required this.ntConnection, required this.preferences, required this.version, + required this.updateChecker, + this.layoutDownloader, this.onColorChanged, this.onThemeVariantChanged, }); @@ -59,9 +69,11 @@ class DashboardPage extends StatefulWidget { } class _DashboardPageState extends State with WindowListener { - late final SharedPreferences preferences = widget.preferences; - late final UpdateChecker _updateChecker; + SharedPreferences get preferences => widget.preferences; late final RobotNotificationsListener _robotNotificationListener; + late final ElasticLayoutDownloader _layoutDownloader; + + bool _seenShuffleboardWarning = false; final List _tabData = []; @@ -70,6 +82,9 @@ class _DashboardPageState extends State with WindowListener { late int _gridSize = preferences.getInt(PrefKeys.gridSize) ?? Defaults.gridSize; + UpdateCheckerResponse lastUpdateResponse = + UpdateCheckerResponse(updateAvailable: false, error: false); + int _currentTabIndex = 0; bool _addWidgetDialogVisible = false; @@ -77,7 +92,6 @@ class _DashboardPageState extends State with WindowListener { @override void initState() { super.initState(); - _updateChecker = UpdateChecker(currentVersion: widget.version); windowManager.addListener(this); if (!Platform.environment.containsKey('FLUTTER_TEST')) { @@ -163,6 +177,7 @@ class _DashboardPageState extends State with WindowListener { }); }, onTabCreated: (tab) { + _showShuffleboardWarningMessage(); if (preferences.getBool(PrefKeys.layoutLocked) ?? Defaults.layoutLocked) { return; @@ -184,43 +199,47 @@ class _DashboardPageState extends State with WindowListener { )); }, onWidgetAdded: (widgetData) { + _showShuffleboardWarningMessage(); if (preferences.getBool(PrefKeys.layoutLocked) ?? Defaults.layoutLocked) { return; } - // Needs to be done in case if widget data gets erased by the listener - Map widgetDataCopy = {}; - - widgetData.forEach( - (key, value) => widgetDataCopy.putIfAbsent(key, () => value)); + // Needs to be converted into the tab json format + Map tabJson = {}; - List tabNamesList = _tabData.map((data) => data.name).toList(); + String tabName = widgetData['tab']; + tabJson.addAll({'containers': >[]}); + tabJson.addAll({'layouts': >[]}); - String tabName = widgetDataCopy['tab']; + if (!(widgetData.containsKey('layout') && widgetData['layout'])) { + tabJson['containers']!.add(widgetData); + } else { + tabJson['layouts']!.add(widgetData); + } - if (!tabNamesList.contains(tabName)) { + if (!_tabData.any((tab) => tab.name == tabName)) { _tabData.add( TabData( name: tabName, - tabGrid: TabGridModel( + tabGrid: TabGridModel.fromJson( ntConnection: widget.ntConnection, preferences: widget.preferences, + jsonData: tabJson, + onJsonLoadingWarning: _showJsonLoadingWarning, onAddWidgetPressed: _displayAddWidgetDialog, ), ), ); - - tabNamesList.add(tabName); - } - - int tabIndex = tabNamesList.indexOf(tabName); - - if (tabIndex == -1) { - return; + } else { + _tabData + .firstWhere((tab) => tab.name == tabName) + .tabGrid + .mergeFromJson( + jsonData: tabJson, + onJsonLoadingWarning: _showJsonLoadingWarning, + ); } - _tabData[tabIndex].tabGrid.addWidgetFromTabJson(widgetDataCopy); - setState(() {}); }, ); @@ -230,19 +249,23 @@ class _DashboardPageState extends State with WindowListener { apiListener.initializeListeners(); }); - Future(() => _checkForUpdates(notifyIfLatest: false, notifyIfError: false)); + if (!isWPILib) { + Future( + () => _checkForUpdates(notifyIfLatest: false, notifyIfError: false)); + } _robotNotificationListener = RobotNotificationsListener( ntConnection: widget.ntConnection, - onNotification: (title, description, icon) { + onNotification: (title, description, icon, time, width, height) { setState(() { ColorScheme colorScheme = Theme.of(context).colorScheme; TextTheme textTheme = Theme.of(context).textTheme; var widget = ElegantNotification( - autoDismiss: true, - showProgressIndicator: true, + autoDismiss: time.inMilliseconds > 0, + showProgressIndicator: time.inMilliseconds > 0, background: colorScheme.surface, - width: 350, + width: width, + height: height, position: Alignment.bottomRight, title: Text( title, @@ -250,7 +273,7 @@ class _DashboardPageState extends State with WindowListener { fontWeight: FontWeight.bold, ), ), - toastDuration: const Duration(seconds: 3), + toastDuration: time, icon: icon, description: Text(description), stackedOptions: StackedOptions( @@ -263,6 +286,9 @@ class _DashboardPageState extends State with WindowListener { }); }); _robotNotificationListener.listen(); + + _layoutDownloader = + widget.layoutDownloader ?? ElasticLayoutDownloader(Client()); } @override @@ -314,51 +340,24 @@ class _DashboardPageState extends State with WindowListener { Future _saveLayout() async { Map jsonData = _toJson(); - ColorScheme colorScheme = Theme.of(context).colorScheme; - TextTheme textTheme = Theme.of(context).textTheme; - bool successful = await preferences.setString(PrefKeys.layout, jsonEncode(jsonData)); await _saveWindowPosition(); if (successful) { - logger.info('Layout saved successfully!'); - ElegantNotification notification = ElegantNotification( - background: colorScheme.surface, - progressIndicatorBackground: colorScheme.surface, - progressIndicatorColor: const Color(0xff01CB67), + logger.info('Layout saved successfully'); + _showInfoNotification( + title: 'Saved', + message: 'Layout saved successfully', width: 300, - position: Alignment.bottomRight, - toastDuration: const Duration(seconds: 3, milliseconds: 500), - icon: const Icon(Icons.check_circle, color: Color(0xff01CB67)), - title: Text('Saved', - style: textTheme.bodyMedium!.copyWith( - fontWeight: FontWeight.bold, - )), - description: const Text('Layout saved successfully!'), ); - if (mounted) { - notification.show(context); - } } else { logger.error('Could not save layout'); - ElegantNotification notification = ElegantNotification( - background: colorScheme.surface, - progressIndicatorBackground: colorScheme.surface, - progressIndicatorColor: const Color(0xffFE355C), + _showInfoNotification( + title: 'Error While Saving Layout', + message: 'Failed to save layout, please try again', width: 300, - position: Alignment.bottomRight, - toastDuration: const Duration(seconds: 3, milliseconds: 500), - icon: const Icon(Icons.error, color: Color(0xffFE355C)), - title: Text('Error', - style: textTheme.bodyMedium!.copyWith( - fontWeight: FontWeight.bold, - )), - description: const Text('Failed to save layout, please try again!'), ); - if (mounted) { - notification.show(context); - } } } @@ -377,14 +376,20 @@ class _DashboardPageState extends State with WindowListener { await preferences.setString(PrefKeys.windowPosition, positionString); } - void _checkForUpdates( - {bool notifyIfLatest = true, bool notifyIfError = true}) async { + void _checkForUpdates({ + bool notifyIfLatest = true, + bool notifyIfError = true, + }) async { ColorScheme colorScheme = Theme.of(context).colorScheme; TextTheme textTheme = Theme.of(context).textTheme; ButtonThemeData buttonTheme = ButtonTheme.of(context); UpdateCheckerResponse updateResponse = - await _updateChecker.isUpdateAvailable(); + await widget.updateChecker.isUpdateAvailable(); + + if (mounted) { + setState(() => lastUpdateResponse = updateResponse); + } if (updateResponse.error && notifyIfError) { ElegantNotification notification = ElegantNotification( @@ -427,45 +432,33 @@ class _DashboardPageState extends State with WindowListener { ), icon: const Icon(Icons.info, color: Color(0xff0066FF)), description: const Text('A new update is available!'), - action: Text( - 'Update', - style: textTheme.bodyMedium!.copyWith( - color: buttonTheme.colorScheme?.primary, - fontWeight: FontWeight.bold, + action: TextButton( + onPressed: () async { + Uri url = Uri.parse(Settings.releasesLink); + + if (await canLaunchUrl(url)) { + await launchUrl(url); + } + }, + child: Text( + 'Update', + style: textTheme.bodyMedium!.copyWith( + color: buttonTheme.colorScheme?.primary, + fontWeight: FontWeight.bold, + ), ), ), - onNotificationPressed: () async { - Uri url = Uri.parse(Settings.releasesLink); - - if (await canLaunchUrl(url)) { - await launchUrl(url); - } - }, ); if (mounted) { notification.show(context); } } else if (updateResponse.onLatestVersion && notifyIfLatest) { - ElegantNotification notification = ElegantNotification( - background: colorScheme.surface, - progressIndicatorBackground: colorScheme.surface, - progressIndicatorColor: const Color(0xff01CB67), + _showInfoNotification( + title: 'No Updates Available', + message: 'You are running on the latest version of Elastic', width: 350, - position: Alignment.bottomRight, - toastDuration: const Duration(seconds: 3, milliseconds: 500), - icon: const Icon(Icons.check_circle, color: Color(0xff01CB67)), - title: Text('No Updates Available', - style: textTheme.bodyMedium!.copyWith( - fontWeight: FontWeight.bold, - )), - description: - const Text('You are running on the latest version of Elastic'), ); - - if (mounted) { - notification.show(context); - } } } @@ -505,6 +498,11 @@ class _DashboardPageState extends State with WindowListener { logger.info('Saving layout data to ${saveLocation.path}'); await jsonFile.saveTo(saveLocation.path); + _showInfoNotification( + title: 'Exported Layout', + message: 'Successfully exported layout to\n${saveLocation.path}', + width: 500, + ); } void _importLayout() async { @@ -570,23 +568,43 @@ class _DashboardPageState extends State with WindowListener { }); } - void _loadLayoutFromJsonData(String jsonString) { - logger.info('Loading layout from json'); - Map? jsonData = tryCast(jsonDecode(jsonString)); - + bool _validateJsonData(Map? jsonData) { if (jsonData == null) { _showJsonLoadingError('Invalid JSON format, aborting.'); - _createDefaultTabs(); - return; + return false; } if (!jsonData.containsKey('tabs')) { _showJsonLoadingError('JSON does not contain necessary data, aborting.'); + return false; + } + + for (Map data in jsonData['tabs']) { + if (tryCast(data['name']) == null) { + _showJsonLoadingError('Tab name not specified'); + return false; + } + + if (tryCast(data['grid_layout']) == null) { + _showJsonLoadingError( + 'Grid layout not specified for tab \'${data['name']}\''); + return false; + } + } + + return true; + } + + void _loadLayoutFromJsonData(String jsonString) { + logger.info('Loading layout from json'); + Map? jsonData = tryCast(jsonDecode(jsonString)); + + if (!_validateJsonData(jsonData)) { _createDefaultTabs(); return; } - if (jsonData.containsKey('grid_size')) { + if (jsonData!.containsKey('grid_size')) { _gridSize = tryCast(jsonData['grid_size']) ?? _gridSize; preferences.setInt(PrefKeys.gridSize, _gridSize); } @@ -594,17 +612,6 @@ class _DashboardPageState extends State with WindowListener { _tabData.clear(); for (Map data in jsonData['tabs']) { - if (tryCast(data['name']) == null) { - _showJsonLoadingWarning('Tab name not specified, ignoring tab data.'); - continue; - } - - if (tryCast(data['grid_layout']) == null) { - _showJsonLoadingWarning( - 'Grid layout not specified for tab \'${data['name']}\', ignoring tab data.'); - continue; - } - _tabData.add( TabData( name: data['name'], @@ -626,6 +633,150 @@ class _DashboardPageState extends State with WindowListener { } } + bool _mergeLayoutFromJsonData(String jsonString) { + logger.info('Merging layout from json'); + + Map? jsonData = tryCast(jsonDecode(jsonString)); + + if (!_validateJsonData(jsonData)) { + return false; + } + + for (Map tabJson in jsonData!['tabs']) { + String tabName = tabJson['name']; + if (!_tabData.any((tab) => tab.name == tabName)) { + _tabData.add( + TabData( + name: tabName, + tabGrid: TabGridModel.fromJson( + ntConnection: widget.ntConnection, + preferences: widget.preferences, + jsonData: tabJson['grid_layout'], + onAddWidgetPressed: _displayAddWidgetDialog, + onJsonLoadingWarning: _showJsonLoadingWarning, + ), + ), + ); + } else { + TabGridModel existingTab = + _tabData.firstWhere((tab) => tab.name == tabName).tabGrid; + existingTab.mergeFromJson( + jsonData: tabJson['grid_layout'], + onJsonLoadingWarning: _showJsonLoadingWarning, + ); + } + } + + _showInfoNotification( + title: 'Successfully Downloaded Layout', + message: 'Remote layout has been successfully downloaded and merged!', + width: 350, + ); + + setState(() {}); + + return true; + } + + Future _showRemoteLayoutSelection(List fileNames) async { + if (!mounted) { + return null; + } + ValueNotifier currentSelection = ValueNotifier(null); + return await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Select Layout'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ValueListenableBuilder( + valueListenable: currentSelection, + builder: (_, value, child) => DialogDropdownChooser( + choices: fileNames, + initialValue: value, + onSelectionChanged: (selection) => + currentSelection.value = selection, + ), + ) + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(null), + child: const Text('Cancel'), + ), + ValueListenableBuilder( + valueListenable: currentSelection, + builder: (_, value, child) => TextButton( + onPressed: (value != null) + ? () => Navigator.of(context).pop(value) + : null, + child: const Text('Download'), + ), + ), + ], + ), + ); + } + + void _loadLayoutFromRobot() async { + if (preferences.getBool(PrefKeys.layoutLocked) ?? Defaults.layoutLocked) { + return; + } + + LayoutDownloadResponse> layoutsResponse = + await _layoutDownloader.getAvailableLayouts( + ntConnection: widget.ntConnection, + preferences: preferences, + ); + + if (!layoutsResponse.successful) { + _showErrorNotification( + title: 'Failed to Retrieve Layout List', + message: layoutsResponse.data.firstOrNull ?? + 'Unable to retrieve list of available layouts', + width: 400, + ); + return; + } + + if (layoutsResponse.data.isEmpty) { + _showErrorNotification( + title: 'Failed to Retrieve Layout List', + message: + 'No layouts were found, ensure a valid layout json file is placed in the root directory of your deploy directory.', + width: 400, + ); + return; + } + + String? selectedLayout = await _showRemoteLayoutSelection( + layoutsResponse.data.sorted((a, b) => a.compareTo(b)), + ); + + if (selectedLayout == null) { + return; + } + + LayoutDownloadResponse response = await _layoutDownloader.downloadLayout( + ntConnection: widget.ntConnection, + preferences: preferences, + layoutName: selectedLayout, + ); + + if (!response.successful) { + _showErrorNotification( + title: 'Failed to Download Layout', + message: response.data, + width: 400, + ); + return; + } + + _mergeLayoutFromJsonData(response.data); + } + void _createDefaultTabs() { if (_tabData.isEmpty) { logger.info('Creating default Teleoperated and Autonomous tabs'); @@ -652,58 +803,172 @@ class _DashboardPageState extends State with WindowListener { } } + void _showShuffleboardWarningMessage() { + if (_seenShuffleboardWarning) { + return; + } + ColorScheme colorScheme = Theme.of(context).colorScheme; + TextTheme textTheme = Theme.of(context).textTheme; + ButtonThemeData buttonTheme = ButtonTheme.of(context); + + ElegantNotification notification = ElegantNotification( + autoDismiss: false, + background: colorScheme.surface, + showProgressIndicator: false, + width: 450, + height: 160, + position: Alignment.bottomRight, + icon: const Icon(Icons.warning, color: Colors.yellow), + action: TextButton( + onPressed: () async { + Uri url = Uri.parse( + 'https://frc-elastic.gitbook.io/docs/additional-features-and-references/remote-layout-downloading#shuffleboard-api-migration-guide'); + + if (await canLaunchUrl(url)) { + await launchUrl(url); + } + }, + child: Text( + 'Documentation', + style: textTheme.bodyMedium!.copyWith( + color: buttonTheme.colorScheme?.primary, + fontWeight: FontWeight.w600, + ), + ), + ), + title: Text( + 'Shuffleboard API Deprecation', + style: textTheme.bodyMedium!.copyWith( + fontWeight: FontWeight.bold, + ), + ), + description: const Text( + 'Support for the Shuffleboard API is deprecated in favor of remote layout downloading and will be removed after the 2025 season. See the documentation for more details about migration.', + overflow: TextOverflow.ellipsis, + maxLines: 4, + ), + ); + + if (mounted) { + notification.show(context); + } + _seenShuffleboardWarning = true; + } + void _showJsonLoadingError(String errorMessage) { logger.error(errorMessage); Future(() { - ColorScheme colorScheme = Theme.of(context).colorScheme; - TextTheme textTheme = Theme.of(context).textTheme; - int lines = '\n'.allMatches(errorMessage).length + 1; - ElegantNotification( - background: colorScheme.surface, - progressIndicatorBackground: colorScheme.surface, - progressIndicatorColor: const Color(0xffFE355C), + _showErrorNotification( + title: 'Error while loading JSON data', + message: errorMessage, width: 350, height: 100 + (lines - 1) * 10, - position: Alignment.bottomRight, - toastDuration: const Duration(seconds: 3, milliseconds: 500), - icon: const Icon(Icons.error, color: Color(0xffFE355C)), - title: Text('Error while loading JSON data', - style: textTheme.bodyMedium!.copyWith( - fontWeight: FontWeight.bold, - )), - description: Flexible(child: Text(errorMessage)), - ).show(context); + ); }); } void _showJsonLoadingWarning(String warningMessage) { logger.warning(warningMessage); SchedulerBinding.instance.addPostFrameCallback((_) { - ColorScheme colorScheme = Theme.of(context).colorScheme; - TextTheme textTheme = Theme.of(context).textTheme; - int lines = '\n'.allMatches(warningMessage).length + 1; - ElegantNotification( - background: colorScheme.surface, - progressIndicatorBackground: colorScheme.surface, - progressIndicatorColor: Colors.yellow, + _showWarningNotification( + title: 'Warning while loading JSON data', + message: warningMessage, width: 350, height: 100 + (lines - 1) * 10, - position: Alignment.bottomRight, - toastDuration: const Duration(seconds: 3, milliseconds: 500), - icon: const Icon(Icons.warning, color: Colors.yellow), - title: Text('Warning while loading JSON data', - style: textTheme.bodyMedium!.copyWith( - fontWeight: FontWeight.bold, - )), - description: Flexible(child: Text(warningMessage)), - ).show(context); + ); }); } + void _showInfoNotification({ + required String title, + required String message, + Duration toastDuration = const Duration(seconds: 3, milliseconds: 500), + double? width, + double? height, + }) => + _showNotification( + title: title, + message: message, + color: const Color(0xff01CB67), + icon: const Icon(Icons.error, color: Color(0xff01CB67)), + toastDuration: toastDuration, + width: width, + height: height, + ); + + void _showWarningNotification({ + required String title, + required String message, + Duration toastDuration = const Duration(seconds: 3, milliseconds: 500), + double? width, + double? height, + }) => + _showNotification( + title: title, + message: message, + color: Colors.yellow, + icon: const Icon(Icons.warning, color: Colors.yellow), + toastDuration: toastDuration, + width: width, + height: height, + ); + + void _showErrorNotification({ + required String title, + required String message, + Duration toastDuration = const Duration(seconds: 3, milliseconds: 500), + double? width, + double? height, + }) => + _showNotification( + title: title, + message: message, + color: const Color(0xffFE355C), + icon: const Icon(Icons.error, color: Color(0xffFE355C)), + toastDuration: toastDuration, + width: width, + height: height, + ); + + void _showNotification({ + required String title, + required String message, + required Color color, + required Widget icon, + Duration toastDuration = const Duration(seconds: 3, milliseconds: 500), + double? width, + double? height, + }) { + ColorScheme colorScheme = Theme.of(context).colorScheme; + TextTheme textTheme = Theme.of(context).textTheme; + + ElegantNotification notification = ElegantNotification( + background: colorScheme.surface, + progressIndicatorBackground: colorScheme.surface, + progressIndicatorColor: color, + width: width, + height: height, + position: Alignment.bottomRight, + toastDuration: toastDuration, + icon: icon, + title: Text( + title, + style: textTheme.bodyMedium!.copyWith( + fontWeight: FontWeight.bold, + ), + ), + description: Flexible(child: Text(message)), + ); + + if (mounted) { + notification.show(context); + } + } + void _setupShortcuts() { logger.info('Setting up shortcuts'); // Import Layout (Ctrl + O) @@ -730,6 +995,21 @@ class _DashboardPageState extends State with WindowListener { ), callback: _exportLayout, ); + // Download from robot (Ctrl + D) + hotKeyManager.register( + HotKey( + LogicalKeyboardKey.keyD, + modifiers: [KeyModifier.control], + ), + callback: () { + if (preferences.getBool(PrefKeys.layoutLocked) ?? + Defaults.layoutLocked) { + return; + } + + _loadLayoutFromRobot(); + }, + ); // Switch to Tab (Ctrl + Tab #) for (int i = 1; i <= 9; i++) { hotKeyManager.register( @@ -859,6 +1139,51 @@ class _DashboardPageState extends State with WindowListener { }); }, ); + // Open settings dialog (Ctrl + ,) + hotKeyManager.register( + HotKey( + LogicalKeyboardKey.comma, + modifiers: [KeyModifier.control], + ), + callback: () { + if ((ModalRoute.of(context)?.isCurrent ?? false) && mounted) { + _displaySettingsDialog(context); + } + }, + ); + // Connect to robot (Ctrl + K) + hotKeyManager.register( + HotKey( + LogicalKeyboardKey.keyK, + modifiers: [KeyModifier.control], + ), + callback: () { + if (preferences.getInt(PrefKeys.ipAddressMode) == + IPAddressMode.driverStation.index) { + return; + } + _updateIPAddress(IPAddressUtil.teamNumberToIP( + preferences.getInt(PrefKeys.teamNumber) ?? Defaults.teamNumber)); + _changeIPAddressMode(IPAddressMode.driverStation); + }, + ); + // Connect to sim (Ctrl + Shift + K) + hotKeyManager.register( + HotKey( + LogicalKeyboardKey.keyK, + modifiers: [ + KeyModifier.control, + KeyModifier.shift, + ], + ), + callback: () { + if (preferences.getInt(PrefKeys.ipAddressMode) == + IPAddressMode.localhost.index) { + return; + } + _changeIPAddressMode(IPAddressMode.localhost); + }, + ); } void _lockLayout() async { @@ -886,10 +1211,10 @@ class _DashboardPageState extends State with WindowListener { showAboutDialog( context: context, - applicationName: 'Elastic', + applicationName: appTitle, applicationVersion: widget.version, applicationIcon: Image.asset( - 'assets/logos/logo.png', + logoPath, width: iconTheme.size, height: iconTheme.size, ), @@ -975,36 +1300,8 @@ class _DashboardPageState extends State with WindowListener { if (mode.index == preferences.getInt(PrefKeys.ipAddressMode)) { return; } - await preferences.setInt(PrefKeys.ipAddressMode, mode.index); - switch (mode) { - case IPAddressMode.driverStation: - String? lastAnnouncedIP = - widget.ntConnection.dsClient.lastAnnouncedIP; - - if (lastAnnouncedIP == null) { - break; - } - - _updateIPAddress(lastAnnouncedIP); - break; - case IPAddressMode.roboRIOmDNS: - _updateIPAddress(IPAddressUtil.teamNumberToRIOmDNS( - preferences.getInt(PrefKeys.teamNumber) ?? - Defaults.teamNumber)); - break; - case IPAddressMode.teamNumber: - _updateIPAddress(IPAddressUtil.teamNumberToIP( - preferences.getInt(PrefKeys.teamNumber) ?? - Defaults.teamNumber)); - break; - case IPAddressMode.localhost: - _updateIPAddress('localhost'); - break; - default: - setState(() {}); - break; - } + _changeIPAddressMode(mode); }, onIPAddressChanged: (String? data) async { if (data == null || @@ -1160,10 +1457,46 @@ class _DashboardPageState extends State with WindowListener { }, onColorChanged: widget.onColorChanged, onThemeVariantChanged: widget.onThemeVariantChanged, + onOpenAssetsFolderPressed: () async { + Uri uri = Uri.file( + '${path.dirname(Platform.resolvedExecutable)}/data/flutter_assets/assets/'); + if (await canLaunchUrl(uri)) { + launchUrl(uri); + } + }, ), ); } + void _changeIPAddressMode(IPAddressMode mode) async { + await preferences.setInt(PrefKeys.ipAddressMode, mode.index); + switch (mode) { + case IPAddressMode.driverStation: + String? lastAnnouncedIP = widget.ntConnection.dsClient.lastAnnouncedIP; + + if (lastAnnouncedIP == null) { + break; + } + + _updateIPAddress(lastAnnouncedIP); + break; + case IPAddressMode.roboRIOmDNS: + _updateIPAddress(IPAddressUtil.teamNumberToRIOmDNS( + preferences.getInt(PrefKeys.teamNumber) ?? Defaults.teamNumber)); + break; + case IPAddressMode.teamNumber: + _updateIPAddress(IPAddressUtil.teamNumberToIP( + preferences.getInt(PrefKeys.teamNumber) ?? Defaults.teamNumber)); + break; + case IPAddressMode.localhost: + _updateIPAddress('localhost'); + break; + default: + setState(() {}); + break; + } + } + void _updateIPAddress(String newIPAddress) async { if (newIPAddress == preferences.getString(PrefKeys.ipAddress)) { return; @@ -1361,7 +1694,7 @@ class _DashboardPageState extends State with WindowListener { children: [ Center( child: Image.asset( - 'assets/logos/logo.png', + logoPath, width: 24.0, height: 24.0, ), @@ -1376,7 +1709,7 @@ class _DashboardPageState extends State with WindowListener { style: menuButtonStyle, onPressed: !(preferences.getBool(PrefKeys.layoutLocked) ?? Defaults.layoutLocked) - ? () => _importLayout() + ? _importLayout : null, shortcut: const SingleActivator(LogicalKeyboardKey.keyO, control: true), @@ -1392,9 +1725,7 @@ class _DashboardPageState extends State with WindowListener { // Save MenuItemButton( style: menuButtonStyle, - onPressed: () { - _saveLayout(); - }, + onPressed: _saveLayout, shortcut: const SingleActivator(LogicalKeyboardKey.keyS, control: true), child: const Row( @@ -1406,23 +1737,44 @@ class _DashboardPageState extends State with WindowListener { ], ), ), - // Export layout MenuItemButton( - style: menuButtonStyle, - onPressed: () { - _exportLayout(); - }, - shortcut: const SingleActivator(LogicalKeyboardKey.keyS, - shift: true, control: true), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.save_as_outlined), - SizedBox(width: 8), - Text('Save As'), - ], - )), + style: menuButtonStyle, + onPressed: _exportLayout, + shortcut: const SingleActivator( + LogicalKeyboardKey.keyS, + shift: true, + control: true, + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.save_as_outlined), + SizedBox(width: 8), + Text('Save As'), + ], + ), + ), + // Download layout + MenuItemButton( + style: menuButtonStyle, + onPressed: !(preferences.getBool(PrefKeys.layoutLocked) ?? + Defaults.layoutLocked) + ? _loadLayoutFromRobot + : null, + shortcut: const SingleActivator( + LogicalKeyboardKey.keyD, + control: true, + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.download), + SizedBox(width: 8), + Text('Download From Robot'), + ], + ), + ), ], child: const Text( 'File', @@ -1441,7 +1793,7 @@ class _DashboardPageState extends State with WindowListener { setState(() { _tabData[_currentTabIndex] .tabGrid - .clearWidgets(context); + .confirmClearWidgets(context); }); } : null, @@ -1491,21 +1843,22 @@ class _DashboardPageState extends State with WindowListener { ], ), ), - // Check for Updates - MenuItemButton( - style: menuButtonStyle, - onPressed: () { - _checkForUpdates(); - }, - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.update_outlined), - SizedBox(width: 8), - Text('Check for updates'), - ], + // Check for Updates (not for WPILib distribution) + if (!isWPILib) + MenuItemButton( + style: menuButtonStyle, + onPressed: () { + _checkForUpdates(); + }, + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.update_outlined), + SizedBox(width: 8), + Text('Check for Updates'), + ], + ), ), - ), ], child: const Text( 'Help', @@ -1556,10 +1909,38 @@ class _DashboardPageState extends State with WindowListener { ], ); + final List trailing = [ + if (lastUpdateResponse.updateAvailable) ...[ + const VerticalDivider(), + Tooltip( + message: 'Download version ${lastUpdateResponse.latestVersion}', + child: MenuItemButton( + style: menuButtonStyle.copyWith( + minimumSize: + const WidgetStatePropertyAll(Size(36.0, double.infinity)), + maximumSize: + const WidgetStatePropertyAll(Size(36.0, double.infinity)), + ), + onPressed: () async { + Uri url = Uri.parse(Settings.releasesLink); + + if (await canLaunchUrl(url)) { + await launchUrl(url); + } + }, + child: const Icon(Icons.update, color: Colors.orange), + ), + ), + const VerticalDivider(), + ], + ]; + return Scaffold( appBar: CustomAppBar( + titleText: appTitle, onWindowClose: onWindowClose, - menuBar: menuBar, + leading: menuBar, + trailing: trailing, ), body: Focus( autofocus: true, @@ -1770,6 +2151,8 @@ class _AddWidgetDialog extends StatefulWidget { class _AddWidgetDialogState extends State<_AddWidgetDialog> { bool _hideMetadata = true; + String _searchQuery = ''; + @override Widget build(BuildContext context) { return Visibility( @@ -1807,6 +2190,7 @@ class _AddWidgetDialogState extends State<_AddWidgetDialog> { NetworkTableTree( ntConnection: widget.ntConnection, preferences: widget.preferences, + searchQuery: _searchQuery, listLayoutBuilder: ( {required title, required children}) { return widget._grid().createListLayout( @@ -1864,7 +2248,19 @@ class _AddWidgetDialogState extends State<_AddWidgetDialog> { }, ); }), - const Spacer(), + Expanded( + child: SizedBox( + height: 40.0, + child: DialogTextInput( + onSubmit: (value) => + setState(() => _searchQuery = value), + initialText: _searchQuery, + allowEmptySubmission: true, + updateOnChanged: true, + label: 'Search', + ), + ), + ), TextButton( onPressed: () { widget._onClose?.call(); diff --git a/lib/services/app_distributor.dart b/lib/services/app_distributor.dart new file mode 100644 index 00000000..65a331e9 --- /dev/null +++ b/lib/services/app_distributor.dart @@ -0,0 +1,5 @@ +bool get isWPILib => const bool.fromEnvironment('ELASTIC_WPILIB'); + +String logoPath = 'assets/logos/logo.png'; + +String get appTitle => (!isWPILib) ? 'Elastic' : 'Elastic (WPILib)'; diff --git a/lib/services/elastic_layout_downloader.dart b/lib/services/elastic_layout_downloader.dart new file mode 100644 index 00000000..7d2db8a2 --- /dev/null +++ b/lib/services/elastic_layout_downloader.dart @@ -0,0 +1,98 @@ +import 'dart:convert'; + +import 'package:dot_cast/dot_cast.dart'; +import 'package:http/http.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:elastic_dashboard/services/nt_connection.dart'; +import 'package:elastic_dashboard/services/settings.dart'; + +typedef LayoutDownloadResponse = ({bool successful, T data}); + +class ElasticLayoutDownloader { + final Client client; + + ElasticLayoutDownloader(this.client); + + Future> downloadLayout({ + required NTConnection ntConnection, + required SharedPreferences preferences, + required String layoutName, + }) async { + if (!ntConnection.isNT4Connected) { + return ( + successful: false, + data: + 'Cannot download a remote layout while disconnected from the robot.' + ); + } + String robotIP = + preferences.getString(PrefKeys.ipAddress) ?? Defaults.ipAddress; + String escapedName = Uri.encodeComponent('$layoutName.json'); + Uri robotUri = Uri.parse( + 'http://$robotIP:5800/$escapedName', + ); + Response response; + try { + response = await client.get(robotUri); + } on ClientException catch (e) { + return (successful: false, data: e.message); + } + if (response.statusCode < 200 || response.statusCode >= 300) { + String errorMessage = switch (response.statusCode) { + 404 => 'File "$layoutName.json" was not found', + _ => 'Request returned status code ${response.statusCode}', + }; + + return (successful: false, data: errorMessage); + } + return (successful: true, data: response.body); + } + + Future>> getAvailableLayouts({ + required NTConnection ntConnection, + required SharedPreferences preferences, + }) async { + if (!ntConnection.isNT4Connected) { + return ( + successful: false, + data: [ + 'Cannot fetch remote layouts while disconnected from the robot' + ] + ); + } + String robotIP = + preferences.getString(PrefKeys.ipAddress) ?? Defaults.ipAddress; + Uri robotUri = Uri.parse( + 'http://$robotIP:5800/?format=json', + ); + Response response; + try { + response = await client.get(robotUri); + } on ClientException catch (e) { + return (successful: false, data: [e.message]); + } + Map? responseJson = tryCast(jsonDecode(response.body)); + if (responseJson == null) { + return (successful: false, data: ['Response was not a json object']); + } + if (!responseJson.containsKey('files')) { + return ( + successful: false, + data: ['Response json does not contain files list'] + ); + } + + List fileNames = []; + for (Map fileData in responseJson['files']) { + String? name = fileData['name']; + if (name == null) { + continue; + } + if (name.endsWith('json')) { + fileNames.add(name.substring(0, name.length - '.json'.length)); + } + } + return (successful: true, data: fileNames); + } +} diff --git a/lib/services/field_images.dart b/lib/services/field_images.dart index f6dac855..a0685305 100644 --- a/lib/services/field_images.dart +++ b/lib/services/field_images.dart @@ -106,7 +106,7 @@ class Field { void loadFieldImage() { fieldImage = Image.asset(jsonData['field-image']); fieldImage.image - .resolve(const ImageConfiguration()) + .resolve(ImageConfiguration.empty) .addListener(ImageStreamListener((image, synchronousCall) { fieldImageWidth = image.image.width; fieldImageHeight = image.image.height; @@ -115,11 +115,11 @@ class Field { })); } - void dispose() { + void dispose() async { instanceCounter--; if (instanceCounter <= 0) { - fieldImage.image.evict(); - PaintingBinding.instance.imageCache.clear(); + await fieldImage.image.evict(); + imageCache.clear(); fieldImageLoaded = false; } } diff --git a/lib/services/log.dart b/lib/services/log.dart index 8343523e..8cdafd3a 100644 --- a/lib/services/log.dart +++ b/lib/services/log.dart @@ -62,6 +62,10 @@ class Log { void debug(dynamic message, [dynamic error, StackTrace? stackTrace]) { log(Level.debug, message, error, stackTrace); } + + void trace(dynamic message, [dynamic error, StackTrace? stackTrace]) { + log(Level.trace, message, error, stackTrace); + } } Log get logger => Log.instance; diff --git a/lib/services/nt4_client.dart b/lib/services/nt4_client.dart index fa3891ef..a98d4bc6 100644 --- a/lib/services/nt4_client.dart +++ b/lib/services/nt4_client.dart @@ -53,7 +53,7 @@ class NT4TypeStr { static const kStringArr = 'string[]'; } -class NT4Subscription { +class NT4Subscription extends ValueNotifier { final String topic; final NT4SubscriptionOptions options; final int uid; @@ -67,7 +67,7 @@ class NT4Subscription { required this.topic, this.options = const NT4SubscriptionOptions(), this.uid = -1, - }); + }) : super(null); void listen(Function(Object?, int) onChanged) { _listeners.add(onChanged); @@ -127,6 +127,7 @@ class NT4Subscription { } currentValue = value; this.timestamp = timestamp; + super.value = value; } Map _toSubscribeJson() { @@ -243,6 +244,7 @@ class NT4Client { final VoidCallback? onConnect; final VoidCallback? onDisconnect; final List _topicAnnounceListeners = []; + final List _topicUnannounceListeners = []; final Map _subscriptions = {}; final Set _subscribedTopics = {}; @@ -328,6 +330,14 @@ class NT4Client { _topicAnnounceListeners.remove(onAnnounce); } + void addTopicUnannounceListener(Function(NT4Topic topic) onUnannounce) { + _topicUnannounceListeners.add(onUnannounce); + } + + void removeTopicUnannounceListener(Function(NT4Topic topic) onUnannounce) { + _topicUnannounceListeners.add(onUnannounce); + } + NT4Subscription subscribe({ required String topic, NT4SubscriptionOptions options = const NT4SubscriptionOptions(), @@ -525,7 +535,7 @@ class NT4Client { _clientId = Random().nextInt(99999999); - String mainServerAddr = 'ws://$serverBaseAddress:5810/nt/elastic'; + String mainServerAddr = 'ws://$serverBaseAddress:5810/nt/Elastic'; _mainWebsocket = WebSocketChannel.connect(Uri.parse(mainServerAddr), protocols: [ @@ -617,7 +627,7 @@ class NT4Client { return; } - String rttServerAddr = 'ws://$serverBaseAddress:5810/nt/elastic'; + String rttServerAddr = 'ws://$serverBaseAddress:5810/nt/Elastic'; _rttWebsocket = WebSocketChannel.connect(Uri.parse(rttServerAddr), protocols: ['rtt.networktables.first.wpi.edu']); @@ -793,6 +803,10 @@ class NT4Client { return; } announcedTopics.remove(removedTopic.id); + + for (final listener in _topicUnannounceListeners) { + listener.call(removedTopic); + } } else if (method == 'properties') { String topicName = params['name']; NT4Topic? topic = getTopicFromName(topicName); diff --git a/lib/services/nt_connection.dart b/lib/services/nt_connection.dart index 5f838b29..0de35f4b 100644 --- a/lib/services/nt_connection.dart +++ b/lib/services/nt_connection.dart @@ -15,12 +15,14 @@ class NTConnection { List onConnectedListeners = []; List onDisconnectedListeners = []; - bool _ntConnected = false; - bool _dsConnected = false; + final ValueNotifier _ntConnected = ValueNotifier(false); + ValueNotifier get ntConnected => _ntConnected; - bool get isNT4Connected => _ntConnected; + bool get isNT4Connected => _ntConnected.value; - bool get isDSConnected => _dsConnected; + final ValueNotifier _dsConnected = ValueNotifier(false); + bool get isDSConnected => _dsConnected.value; + ValueNotifier get dsConnected => _dsConnected; DSInteropClient get dsClient => _dsClient; int get serverTime => _ntClient.getServerTimeUS(); @@ -42,14 +44,14 @@ class NTConnection { _ntClient = NT4Client( serverBaseAddress: ipAddress, onConnect: () { - _ntConnected = true; + _ntConnected.value = true; for (VoidCallback callback in onConnectedListeners) { callback.call(); } }, onDisconnect: () { - _ntConnected = false; + _ntConnected.value = false; for (VoidCallback callback in onDisconnectedListeners) { callback.call(); @@ -58,7 +60,7 @@ class NTConnection { // Allows all published topics to be announced _ntClient.subscribe( - topic: '/', + topic: '', options: const NT4SubscriptionOptions(topicsOnly: true), ); } @@ -69,8 +71,8 @@ class NTConnection { _dsClient = DSInteropClient( onNewIPAnnounced: onIPAnnounced, onDriverStationDockChanged: onDriverStationDockChanged, - onConnect: () => _dsConnected = true, - onDisconnect: () => _dsConnected = false, + onConnect: () => _dsConnected.value = true, + onDisconnect: () => _dsConnected.value = false, ); } @@ -94,8 +96,16 @@ class NTConnection { _ntClient.addTopicAnnounceListener(onAnnounce); } - void removeTopicAnnounceListener(Function(NT4Topic topic) onUnannounce) { - _ntClient.removeTopicAnnounceListener(onUnannounce); + void removeTopicAnnounceListener(Function(NT4Topic topic) onAnnounce) { + _ntClient.removeTopicAnnounceListener(onAnnounce); + } + + void addTopicUnannounceListener(Function(NT4Topic topic) onUnannounce) { + _ntClient.addTopicUnannounceListener(onUnannounce); + } + + void removeTopicUnannounceListener(Function(NT4Topic topic) onUnannounce) { + _ntClient.removeTopicUnannounceListener(onUnannounce); } Future? subscribeAndRetrieveData(String topic, @@ -119,26 +129,13 @@ class NTConnection { } Stream connectionStatus() async* { - yield _ntConnected; - bool lastYielded = _ntConnected; - - while (true) { - if (_ntConnected != lastYielded) { - yield _ntConnected; - lastYielded = _ntConnected; - } - await Future.delayed(const Duration(seconds: 1)); - } - } - - Stream dsConnectionStatus() async* { - yield _dsConnected; - bool lastYielded = _dsConnected; + yield _ntConnected.value; + bool lastYielded = _ntConnected.value; while (true) { - if (_dsConnected != lastYielded) { - yield _dsConnected; - lastYielded = _dsConnected; + if (_ntConnected.value != lastYielded) { + yield _ntConnected.value; + lastYielded = _ntConnected.value; } await Future.delayed(const Duration(seconds: 1)); } diff --git a/lib/services/nt_widget_builder.dart b/lib/services/nt_widget_builder.dart index 565e843c..111fc366 100644 --- a/lib/services/nt_widget_builder.dart +++ b/lib/services/nt_widget_builder.dart @@ -44,27 +44,28 @@ import 'package:elastic_dashboard/widgets/nt_widgets/single_topic/toggle_button. import 'package:elastic_dashboard/widgets/nt_widgets/single_topic/toggle_switch.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/single_topic/voltage_view.dart'; +typedef NTModelJsonProvider = NTWidgetModel Function({ + required Map jsonData, + required NTConnection ntConnection, + required SharedPreferences preferences, +}); + +typedef NTModelProvider = NTWidgetModel Function({ + String dataType, + required NTConnection ntConnection, + double period, + required SharedPreferences preferences, + required String topic, +}); + +typedef NTWidgetProvider = NTWidget Function({Key? key}); + class NTWidgetBuilder { - static final Map _widgetNameBuildMap = - {}; - - static final Map< - String, - NTWidgetModel Function({ - required NTConnection ntConnection, - required SharedPreferences preferences, - required String topic, - String dataType, - double period, - })> _modelNameBuildMap = {}; - - static final Map< - String, - NTWidgetModel Function({ - required NTConnection ntConnection, - required SharedPreferences preferences, - required Map jsonData, - })> _modelJsonBuildMap = {}; + static final Map _widgetNameBuildMap = {}; + + static final Map _modelNameBuildMap = {}; + + static final Map _modelJsonBuildMap = {}; static final Map _minimumWidthMap = {}; static final Map _minimumHeightMap = {}; @@ -84,236 +85,269 @@ class NTWidgetBuilder { logger.info('Configuring NT Widget Builder'); - _modelNameBuildMap.addAll({ - BooleanBox.widgetType: BooleanBoxModel.new, - GraphWidget.widgetType: GraphModel.new, - MatchTimeWidget.widgetType: MatchTimeModel.new, - NumberBar.widgetType: NumberBarModel.new, - NumberSlider.widgetType: NumberSliderModel.new, - RadialGauge.widgetType: RadialGaugeModel.new, - 'Simple Dial': RadialGaugeModel.new, - TextDisplay.widgetType: TextDisplayModel.new, - 'Text View': TextDisplayModel.new, - VoltageView.widgetType: VoltageViewModel.new, - }); - - _modelNameBuildMap.addAll({ - AccelerometerWidget.widgetType: AccelerometerModel.new, - SwerveDriveWidget.widgetType: BasicSwerveModel.new, - CameraStreamWidget.widgetType: CameraStreamModel.new, - ComboBoxChooser.widgetType: ComboBoxChooserModel.new, - 'String Chooser': ComboBoxChooserModel.new, - CommandSchedulerWidget.widgetType: CommandSchedulerModel.new, - CommandWidget.widgetType: CommandModel.new, - DifferentialDrive.widgetType: DifferentialDriveModel.new, - 'Differential Drivebase': DifferentialDriveModel.new, - EncoderWidget.widgetType: EncoderModel.new, - 'Quadrature Encoder': EncoderModel.new, - FieldWidget.widgetType: FieldWidgetModel.new, - 'Field2d': FieldWidgetModel.new, - FMSInfo.widgetType: FMSInfoModel.new, - Gyro.widgetType: GyroModel.new, - MotorController.widgetType: MotorControllerModel.new, - 'Nidec Brushless': MotorControllerModel.new, - NetworkAlerts.widgetType: NetworkAlertsModel.new, - PIDControllerWidget.widgetType: PIDControllerModel.new, - 'PID Controller': PIDControllerModel.new, - PowerDistribution.widgetType: PowerDistributionModel.new, - 'PDP': PowerDistributionModel.new, - ProfiledPIDControllerWidget.widgetType: ProfiledPIDControllerModel.new, - RelayWidget.widgetType: RelayModel.new, - RobotPreferences.widgetType: RobotPreferencesModel.new, - SplitButtonChooser.widgetType: SplitButtonChooserModel.new, - SubsystemWidget.widgetType: SubsystemModel.new, - ThreeAxisAccelerometer.widgetType: ThreeAxisAccelerometerModel.new, - '3AxisAccelerometer': ThreeAxisAccelerometerModel.new, - Ultrasonic.widgetType: UltrasonicModel.new, - YAGSLSwerveDrive.widgetType: YAGSLSwerveDriveModel.new, - }); + register( + name: BooleanBox.widgetType, + model: BooleanBoxModel.new, + widget: BooleanBox.new, + fromJson: BooleanBoxModel.fromJson); + + register( + name: GraphWidget.widgetType, + model: GraphModel.new, + widget: GraphWidget.new, + fromJson: GraphModel.fromJson, + minWidth: _normalSize * 2, + minHeight: _normalSize * 2); + + register( + name: MatchTimeWidget.widgetType, + model: MatchTimeModel.new, + widget: MatchTimeWidget.new, + fromJson: MatchTimeModel.fromJson); + + register( + name: NumberBar.widgetType, + model: NumberBarModel.new, + widget: NumberBar.new, + fromJson: NumberBarModel.fromJson, + minHeight: _normalSize); + + register( + name: NumberSlider.widgetType, + model: NumberSliderModel.new, + widget: NumberSlider.new, + fromJson: NumberSliderModel.fromJson, + minHeight: _normalSize); + + registerWithAlias( + names: {RadialGaugeWidget.widgetType, 'Simple Dial'}, + model: RadialGaugeModel.new, + widget: RadialGaugeWidget.new, + fromJson: RadialGaugeModel.fromJson, + minWidth: _normalSize * 1.6, + minHeight: _normalSize * 1.6); + + registerWithAlias( + names: {TextDisplay.widgetType, 'Text View'}, + model: TextDisplayModel.new, + widget: TextDisplay.new, + fromJson: TextDisplayModel.fromJson); + + register( + name: VoltageView.widgetType, + model: VoltageViewModel.new, + widget: VoltageView.new, + fromJson: VoltageViewModel.fromJson, + minHeight: _normalSize); + + register( + name: AccelerometerWidget.widgetType, + model: AccelerometerModel.new, + widget: AccelerometerWidget.new, + fromJson: AccelerometerModel.fromJson); + + register( + name: SwerveDriveWidget.widgetType, + model: BasicSwerveModel.new, + widget: SwerveDriveWidget.new, + fromJson: BasicSwerveModel.fromJson, + minWidth: _normalSize * 2, + minHeight: _normalSize * 2, + defaultWidth: 2, + defaultHeight: 2); + + register( + name: CameraStreamWidget.widgetType, + model: CameraStreamModel.new, + widget: CameraStreamWidget.new, + fromJson: CameraStreamModel.fromJson, + minWidth: _normalSize * 2, + minHeight: _normalSize * 2, + defaultWidth: 2, + defaultHeight: 2); + + registerWithAlias( + names: {ComboBoxChooser.widgetType, 'String Chooser'}, + model: ComboBoxChooserModel.new, + widget: ComboBoxChooser.new, + fromJson: ComboBoxChooserModel.fromJson, + minHeight: _normalSize * 0.85); + + register( + name: CommandSchedulerWidget.widgetType, + model: CommandSchedulerModel.new, + widget: CommandSchedulerWidget.new, + fromJson: CommandSchedulerModel.fromJson, + minWidth: _normalSize * 2, + minHeight: _normalSize * 2, + defaultWidth: 2, + defaultHeight: 3); + + register( + name: CommandWidget.widgetType, + model: CommandModel.new, + widget: CommandWidget.new, + fromJson: CommandModel.fromJson, + minWidth: _normalSize * 2, + minHeight: _normalSize * 0.90, + defaultWidth: 2); + + registerWithAlias( + names: {DifferentialDrive.widgetType, 'Differential Drivebase'}, + model: DifferentialDriveModel.new, + widget: DifferentialDrive.new, + fromJson: DifferentialDriveModel.fromJson, + minWidth: _normalSize * 2, + minHeight: _normalSize * 2, + defaultWidth: 3, + defaultHeight: 2); + + registerWithAlias( + names: {EncoderWidget.widgetType, 'Quadrature Encoder'}, + model: EncoderModel.new, + widget: EncoderWidget.new, + fromJson: EncoderModel.fromJson, + minWidth: _normalSize * 2, + minHeight: _normalSize * 0.86, + defaultWidth: 2); + + registerWithAlias( + names: {FieldWidget.widgetType, 'Field2d'}, + model: FieldWidgetModel.new, + widget: FieldWidget.new, + fromJson: FieldWidgetModel.fromJson, + minWidth: _normalSize * 3, + minHeight: _normalSize * 2, + defaultWidth: 3, + defaultHeight: 2); + + register( + name: FMSInfo.widgetType, + model: FMSInfoModel.new, + widget: FMSInfo.new, + fromJson: FMSInfoModel.fromJson, + minWidth: _normalSize * 3, + minHeight: _normalSize, + defaultWidth: 3); + + register( + name: Gyro.widgetType, + model: GyroModel.new, + widget: Gyro.new, + fromJson: GyroModel.fromJson, + minWidth: _normalSize * 2, + minHeight: _normalSize * 2, + defaultWidth: 2, + defaultHeight: 2); + + registerWithAlias( + names: {MotorController.widgetType, 'Nidec Brushless'}, + model: MotorControllerModel.new, + widget: MotorController.new, + fromJson: MotorControllerModel.fromJson, + minHeight: _normalSize * 0.92); + + register( + name: NetworkAlerts.widgetType, + model: NetworkAlertsModel.new, + widget: NetworkAlerts.new, + fromJson: NetworkAlertsModel.fromJson, + minWidth: _normalSize * 2, + minHeight: _normalSize * 2, + defaultWidth: 2, + defaultHeight: 3); + + registerWithAlias( + names: {PIDControllerWidget.widgetType, 'PID Controller'}, + model: PIDControllerModel.new, + widget: PIDControllerWidget.new, + fromJson: PIDControllerModel.fromJson, + minWidth: _normalSize * 2, + minHeight: _normalSize * 3, + defaultWidth: 2, + defaultHeight: 3); + + registerWithAlias( + names: {PowerDistribution.widgetType, 'PDP'}, + model: PowerDistributionModel.new, + widget: PowerDistribution.new, + fromJson: PowerDistributionModel.fromJson, + minWidth: _normalSize * 3, + minHeight: _normalSize * 3, + defaultWidth: 3, + defaultHeight: 4); + + register( + name: ProfiledPIDControllerWidget.widgetType, + model: ProfiledPIDControllerModel.new, + widget: ProfiledPIDControllerWidget.new, + fromJson: ProfiledPIDControllerModel.fromJson, + minWidth: _normalSize * 2, + minHeight: _normalSize * 3, + defaultWidth: 2, + defaultHeight: 3); + + register( + name: RelayWidget.widgetType, + model: RelayModel.new, + widget: RelayWidget.new, + fromJson: RelayModel.fromJson, + minHeight: _normalSize * 2, + defaultHeight: 2); + + register( + name: RobotPreferences.widgetType, + model: RobotPreferencesModel.new, + widget: RobotPreferences.new, + fromJson: RobotPreferencesModel.fromJson, + minWidth: _normalSize * 2, + minHeight: _normalSize * 2, + defaultWidth: 2, + defaultHeight: 3); + + register( + name: SplitButtonChooser.widgetType, + model: SplitButtonChooserModel.new, + widget: SplitButtonChooser.new, + fromJson: SplitButtonChooserModel.fromJson); + + register( + name: SubsystemWidget.widgetType, + model: SubsystemModel.new, + widget: SubsystemWidget.new, + fromJson: SubsystemModel.fromJson, + minWidth: _normalSize * 2, + defaultWidth: 2); + + registerWithAlias( + names: {ThreeAxisAccelerometer.widgetType, '3AxisAccelerometer'}, + model: ThreeAxisAccelerometerModel.new, + widget: ThreeAxisAccelerometer.new, + fromJson: ThreeAxisAccelerometerModel.fromJson); + + register( + name: Ultrasonic.widgetType, + model: UltrasonicModel.new, + widget: Ultrasonic.new, + fromJson: UltrasonicModel.fromJson, + minWidth: _normalSize * 2, + defaultWidth: 2); + + register( + name: YAGSLSwerveDrive.widgetType, + model: YAGSLSwerveDriveModel.new, + widget: YAGSLSwerveDrive.new, + fromJson: YAGSLSwerveDriveModel.fromJson, + minWidth: _normalSize * 2, + minHeight: _normalSize * 2, + defaultWidth: 2, + defaultHeight: 2); - _modelJsonBuildMap.addAll({ - BooleanBox.widgetType: BooleanBoxModel.fromJson, - GraphWidget.widgetType: GraphModel.fromJson, - MatchTimeWidget.widgetType: MatchTimeModel.fromJson, - NumberBar.widgetType: NumberBarModel.fromJson, - NumberSlider.widgetType: NumberSliderModel.fromJson, - RadialGauge.widgetType: RadialGaugeModel.fromJson, - 'Simple Dial': RadialGaugeModel.fromJson, - TextDisplay.widgetType: TextDisplayModel.fromJson, - 'Text View': TextDisplayModel.fromJson, - VoltageView.widgetType: VoltageViewModel.fromJson, - }); - - _modelJsonBuildMap.addAll({ - AccelerometerWidget.widgetType: AccelerometerModel.fromJson, - SwerveDriveWidget.widgetType: BasicSwerveModel.fromJson, - CameraStreamWidget.widgetType: CameraStreamModel.fromJson, - ComboBoxChooser.widgetType: ComboBoxChooserModel.fromJson, - 'String Chooser': ComboBoxChooserModel.fromJson, - CommandSchedulerWidget.widgetType: CommandSchedulerModel.fromJson, - CommandWidget.widgetType: CommandModel.fromJson, - DifferentialDrive.widgetType: DifferentialDriveModel.fromJson, - 'Differential Drivebase': DifferentialDriveModel.fromJson, - EncoderWidget.widgetType: EncoderModel.fromJson, - 'Quadrature Encoder': EncoderModel.fromJson, - FieldWidget.widgetType: FieldWidgetModel.fromJson, - 'Field2d': FieldWidgetModel.fromJson, - FMSInfo.widgetType: FMSInfoModel.fromJson, - Gyro.widgetType: GyroModel.fromJson, - MotorController.widgetType: MotorControllerModel.fromJson, - 'Nidec Brushless': MotorControllerModel.fromJson, - NetworkAlerts.widgetType: NetworkAlertsModel.fromJson, - PIDControllerWidget.widgetType: PIDControllerModel.fromJson, - 'PID Controller': PIDControllerModel.fromJson, - PowerDistribution.widgetType: PowerDistributionModel.fromJson, - 'PDP': PowerDistributionModel.fromJson, - ProfiledPIDControllerWidget.widgetType: - ProfiledPIDControllerModel.fromJson, - RelayWidget.widgetType: RelayModel.fromJson, - RobotPreferences.widgetType: RobotPreferencesModel.fromJson, - SplitButtonChooser.widgetType: SplitButtonChooserModel.fromJson, - SubsystemWidget.widgetType: SubsystemModel.fromJson, - ThreeAxisAccelerometer.widgetType: ThreeAxisAccelerometerModel.fromJson, - '3AxisAccelerometer': ThreeAxisAccelerometerModel.fromJson, - Ultrasonic.widgetType: UltrasonicModel.fromJson, - YAGSLSwerveDrive.widgetType: YAGSLSwerveDriveModel.fromJson, - }); - - // Used when building widgets from network tables (drag and drop) _widgetNameBuildMap.addAll({ - BooleanBox.widgetType: BooleanBox.new, - GraphWidget.widgetType: GraphWidget.new, - MatchTimeWidget.widgetType: MatchTimeWidget.new, - MultiColorView.widgetType: MultiColorView.new, - NumberBar.widgetType: NumberBar.new, - NumberSlider.widgetType: NumberSlider.new, - RadialGauge.widgetType: RadialGauge.new, - SingleColorView.widgetType: SingleColorView.new, - TextDisplay.widgetType: TextDisplay.new, - 'Text View': TextDisplay.new, ToggleButton.widgetType: ToggleButton.new, ToggleSwitch.widgetType: ToggleSwitch.new, - VoltageView.widgetType: VoltageView.new, - }); - - _widgetNameBuildMap.addAll({ - AccelerometerWidget.widgetType: AccelerometerWidget.new, - CameraStreamWidget.widgetType: CameraStreamWidget.new, - ComboBoxChooser.widgetType: ComboBoxChooser.new, - 'String Chooser': ComboBoxChooser.new, - CommandSchedulerWidget.widgetType: CommandSchedulerWidget.new, - CommandWidget.widgetType: CommandWidget.new, - DifferentialDrive.widgetType: DifferentialDrive.new, - 'Differential Drivebase': DifferentialDrive.new, - EncoderWidget.widgetType: EncoderWidget.new, - 'Quadrature Encoder': EncoderWidget.new, - FieldWidget.widgetType: FieldWidget.new, - 'Field2d': FieldWidget.new, - FMSInfo.widgetType: FMSInfo.new, - Gyro.widgetType: Gyro.new, - MotorController.widgetType: MotorController.new, - 'Nidec Brushless': MotorController.new, - NetworkAlerts.widgetType: NetworkAlerts.new, - PIDControllerWidget.widgetType: PIDControllerWidget.new, - 'PID Controller': PIDControllerWidget.new, - PowerDistribution.widgetType: PowerDistribution.new, - 'PDP': PowerDistribution.new, - ProfiledPIDControllerWidget.widgetType: ProfiledPIDControllerWidget.new, - RelayWidget.widgetType: RelayWidget.new, - RobotPreferences.widgetType: RobotPreferences.new, - SplitButtonChooser.widgetType: SplitButtonChooser.new, - SubsystemWidget.widgetType: SubsystemWidget.new, - SwerveDriveWidget.widgetType: SwerveDriveWidget.new, - ThreeAxisAccelerometer.widgetType: ThreeAxisAccelerometer.new, - '3AxisAccelerometer': ThreeAxisAccelerometer.new, - Ultrasonic.widgetType: Ultrasonic.new, - YAGSLSwerveDrive.widgetType: YAGSLSwerveDrive.new, - }); - - // Min width and height - _minimumWidthMap.addAll({ - CameraStreamWidget.widgetType: _normalSize * 2, - CommandSchedulerWidget.widgetType: _normalSize * 2, - CommandWidget.widgetType: _normalSize * 2, - DifferentialDrive.widgetType: _normalSize * 2, - EncoderWidget.widgetType: _normalSize * 2, - FieldWidget.widgetType: _normalSize * 3, - FMSInfo.widgetType: _normalSize * 3, - GraphWidget.widgetType: _normalSize * 2, - Gyro.widgetType: _normalSize * 2, - NetworkAlerts.widgetType: _normalSize * 2, - PIDControllerWidget.widgetType: _normalSize * 2, - PowerDistribution.widgetType: _normalSize * 3, - ProfiledPIDControllerWidget.widgetType: _normalSize * 2, - RadialGauge.widgetType: _normalSize * 1.6, - RobotPreferences.widgetType: _normalSize * 2, - SubsystemWidget.widgetType: _normalSize * 2, - SwerveDriveWidget.widgetType: _normalSize * 2, - Ultrasonic.widgetType: _normalSize * 2, - YAGSLSwerveDrive.widgetType: _normalSize * 2, - }); - - _minimumHeightMap.addAll({ - YAGSLSwerveDrive.widgetType: _normalSize * 2, - CameraStreamWidget.widgetType: _normalSize * 2, - ComboBoxChooser.widgetType: _normalSize * 0.85, - CommandSchedulerWidget.widgetType: _normalSize * 2, - CommandWidget.widgetType: _normalSize * 0.90, - DifferentialDrive.widgetType: _normalSize * 2, - EncoderWidget.widgetType: _normalSize * 0.86, - FieldWidget.widgetType: _normalSize * 2, - FMSInfo.widgetType: _normalSize, - GraphWidget.widgetType: _normalSize * 2, - Gyro.widgetType: _normalSize * 2, - MotorController.widgetType: _normalSize * 0.92, - NetworkAlerts.widgetType: _normalSize * 2, - NumberBar.widgetType: _normalSize, - NumberSlider.widgetType: _normalSize, - PIDControllerWidget.widgetType: _normalSize * 3, - PowerDistribution.widgetType: _normalSize * 3, - ProfiledPIDControllerWidget.widgetType: _normalSize * 3, - RadialGauge.widgetType: _normalSize * 1.6, - RelayWidget.widgetType: _normalSize * 2, - RobotPreferences.widgetType: _normalSize * 2, - SwerveDriveWidget.widgetType: _normalSize * 2, - VoltageView.widgetType: _normalSize, - }); - - // Default width and height (when dragging and dropping) - _defaultWidthMap.addAll({ - YAGSLSwerveDrive.widgetType: 2, - CameraStreamWidget.widgetType: 2, - CommandSchedulerWidget.widgetType: 2, - CommandWidget.widgetType: 2, - DifferentialDrive.widgetType: 3, - EncoderWidget.widgetType: 2, - FieldWidget.widgetType: 3, - FMSInfo.widgetType: 3, - Gyro.widgetType: 2, - NetworkAlerts.widgetType: 2, - PIDControllerWidget.widgetType: 2, - PowerDistribution.widgetType: 3, - ProfiledPIDControllerWidget.widgetType: 2, - RobotPreferences.widgetType: 2, - SubsystemWidget.widgetType: 2, - SwerveDriveWidget.widgetType: 2, - Ultrasonic.widgetType: 2, - }); - - _defaultHeightMap.addAll({ - YAGSLSwerveDrive.widgetType: 2, - CameraStreamWidget.widgetType: 2, - CommandSchedulerWidget.widgetType: 3, - DifferentialDrive.widgetType: 2, - FieldWidget.widgetType: 2, - Gyro.widgetType: 2, - NetworkAlerts.widgetType: 3, - PIDControllerWidget.widgetType: 3, - PowerDistribution.widgetType: 4, - ProfiledPIDControllerWidget.widgetType: 3, - RelayWidget.widgetType: 2, - RobotPreferences.widgetType: 3, - SwerveDriveWidget.widgetType: 2, + SingleColorView.widgetType: SingleColorView.new, + MultiColorView.widgetType: MultiColorView.new, }); _initialized = true; @@ -351,7 +385,7 @@ class NTWidgetBuilder { ); } - return NTWidgetModel.createDefault( + return SingleTopicNTWidgetModel.createDefault( ntConnection: ntConnection, preferences: preferences, type: type, @@ -380,7 +414,7 @@ class NTWidgetBuilder { onWidgetTypeNotFound ?.call('Unknown widget type: \'$type\', defaulting to Empty Model.'); - return NTWidgetModel.createDefault( + return SingleTopicNTWidgetModel.createDefault( ntConnection: ntConnection, preferences: preferences, type: type, @@ -449,4 +483,66 @@ class NTWidgetBuilder { static double getNormalSize([int? gridSize]) { return DraggableWidgetContainer.snapToGrid(_normalSize, gridSize); } + + static bool isRegistered(String name) { + ensureInitialized(); + + return (_modelNameBuildMap.containsKey(name) && + _modelJsonBuildMap.containsKey(name)) || + _widgetNameBuildMap.containsKey(name); + } + + static void + register({ + required String name, + required NTModelProvider model, + required NTWidgetProvider widget, + required NTModelJsonProvider fromJson, + double? minWidth, + double? minHeight, + double? defaultWidth, + double? defaultHeight, + }) { + _modelNameBuildMap.addAll({name: model}); + _modelJsonBuildMap.addAll({name: fromJson}); + _widgetNameBuildMap.addAll({name: widget}); + + if (minWidth != null) { + _minimumWidthMap.addAll({name: minWidth}); + } + if (minHeight != null) { + _minimumHeightMap.addAll({name: minHeight}); + } + if (defaultWidth != null) { + _defaultWidthMap.addAll({name: defaultWidth}); + } + if (defaultHeight != null) { + _defaultHeightMap.addAll({name: defaultHeight}); + } + } + + static void registerWithAlias({ + required Set names, + required NTModelProvider model, + required NTWidgetProvider widget, + required NTModelJsonProvider fromJson, + double? minWidth, + double? minHeight, + double? defaultWidth, + double? defaultHeight, + }) { + for (String name in names) { + register( + name: name, + model: model, + widget: widget, + fromJson: fromJson, + minHeight: minHeight, + minWidth: minWidth, + defaultHeight: defaultHeight, + defaultWidth: defaultWidth, + ); + } + } } diff --git a/lib/services/robot_notifications_listener.dart b/lib/services/robot_notifications_listener.dart index 49e57fc0..99978695 100644 --- a/lib/services/robot_notifications_listener.dart +++ b/lib/services/robot_notifications_listener.dart @@ -9,7 +9,8 @@ import 'package:elastic_dashboard/services/nt_connection.dart'; class RobotNotificationsListener { bool _alertFirstRun = true; final NTConnection ntConnection; - final Function(String title, String description, Icon icon) onNotification; + final Function(String title, String description, Icon icon, + Duration displayTime, double width, double? height) onNotification; RobotNotificationsListener({ required this.ntConnection, @@ -37,7 +38,6 @@ class RobotNotificationsListener { // If the alert existed 3 or more seconds before the client connected, ignore it Duration serverTime = Duration(microseconds: ntConnection.serverTime); Duration alertTime = Duration(microseconds: timestamp); - // In theory if you had high enough latency and there was no existing data, // this would not work as intended. However, if you find yourself with 3 // seconds of latency you have a much more serious issue to deal with as you @@ -50,6 +50,9 @@ class RobotNotificationsListener { } Map data; + Duration displayTime = const Duration(seconds: 3); + double width = 350; + double? height; try { data = jsonDecode(alertData.toString()); } catch (e) { @@ -60,6 +63,22 @@ class RobotNotificationsListener { return; } + if (data.containsKey('displayTime')) { + displayTime = + Duration(milliseconds: (tryCast(data['displayTime']) ?? 3000)); + } + + if (data.containsKey('width')) { + width = tryCast(data['width']) ?? 350; + } + if (data.containsKey('height')) { + height = tryCast(data['height']) ?? -1; + + if (height < 0) { + height = null; + } + } + Icon icon; if (data['level'] == 'INFO') { @@ -84,6 +103,6 @@ class RobotNotificationsListener { return; } - onNotification(title, description, icon); + onNotification(title, description, icon, displayTime, width, height); } } diff --git a/lib/services/settings.dart b/lib/services/settings.dart index 60945529..6fc1efc2 100644 --- a/lib/services/settings.dart +++ b/lib/services/settings.dart @@ -17,16 +17,19 @@ class Defaults { static IPAddressMode ipAddressMode = IPAddressMode.driverStation; static FlexSchemeVariant themeVariant = FlexSchemeVariant.material3Legacy; - static const String defaultVariantName = 'Material-3 Legacy (Default)'; + static const String defaultVariantName = 'Material-3 Legacy (Default)'; static const String ipAddress = '127.0.0.1'; + static const int teamNumber = 9999; static const int gridSize = 128; + static const bool layoutLocked = false; - static const double cornerRadius = 15.0; static const bool showGrid = true; static const bool autoResizeToDS = false; + static const bool showOpenAssetsFolderWarning = true; + static const double cornerRadius = 15.0; static const double defaultPeriod = 0.06; static const double defaultGraphPeriod = 0.033; } @@ -46,6 +49,6 @@ class PrefKeys { static String rememberWindowPosition = 'remember_window_position'; static String defaultPeriod = 'default_period'; static String defaultGraphPeriod = 'default_graph_period'; - + static String showOpenAssetsFolderWarning = "show_assets_folder_warning"; static String windowPosition = 'window_position'; } diff --git a/lib/services/shuffleboard_nt_listener.dart b/lib/services/shuffleboard_nt_listener.dart index 0a7eefa8..c991e8cb 100644 --- a/lib/services/shuffleboard_nt_listener.dart +++ b/lib/services/shuffleboard_nt_listener.dart @@ -45,16 +45,16 @@ class ShuffleboardNTListener { } void initializeListeners() { - selectedSubscription.periodicStream(yieldAll: false).listen((data) { - if (data is! String?) { + selectedSubscription.addListener(() { + if (selectedSubscription.value is! String?) { return; } - if (data != previousSelection && data != null) { - _handleTabChange(data); + if (selectedSubscription.value != null) { + _handleTabChange(selectedSubscription.value! as String); } - previousSelection = data; + previousSelection = selectedSubscription.value! as String; }); // Also clear data when connected in case if threads auto populate json after disconnection diff --git a/lib/services/struct_schemas/pose2d_struct.dart b/lib/services/struct_schemas/pose2d_struct.dart new file mode 100644 index 00000000..fd1ee270 --- /dev/null +++ b/lib/services/struct_schemas/pose2d_struct.dart @@ -0,0 +1,60 @@ +import 'dart:typed_data'; + +class Pose2dStruct { + static const int length = 24; + + final double x; + final double y; + final double angle; + + const Pose2dStruct({ + required this.x, + required this.y, + required this.angle, + }); + + factory Pose2dStruct.valueFromBytes(Uint8List value) { + ByteData view = ByteData.view(value.buffer); + + int length = view.lengthInBytes; + + double x = 0.0; + double y = 0.0; + double angle = 0.0; + + if (length >= 8) { + x = view.getFloat64(0, Endian.little); + } + if (length >= 16) { + y = view.getFloat64(8, Endian.little); + } + if (length >= 24) { + angle = view.getFloat64(16, Endian.little); + } + + return Pose2dStruct(x: x, y: y, angle: angle); + } + + static List listFromBytes(Uint8List value) { + ByteData view = ByteData.view(value.buffer); + + int viewLength = view.lengthInBytes; + + int arraySize = viewLength ~/ length; + + List poseList = []; + + for (int i = 0; i < arraySize; i++) { + if (i * length + length > viewLength) { + break; + } + + Uint8List elementBytes = + Uint8List.sublistView(view, i * length, i * length + length); + + poseList.add(Pose2dStruct.valueFromBytes(elementBytes)); + } + + return poseList; + } +} diff --git a/lib/services/struct_schemas/swerve_module_state_struct.dart b/lib/services/struct_schemas/swerve_module_state_struct.dart new file mode 100644 index 00000000..b4cd6a49 --- /dev/null +++ b/lib/services/struct_schemas/swerve_module_state_struct.dart @@ -0,0 +1,51 @@ +import 'dart:typed_data'; + +class SwerveModuleStateStruct { + static const int length = 16; + + final double speed; + final double angle; + + const SwerveModuleStateStruct({required this.speed, required this.angle}); + + factory SwerveModuleStateStruct.valueFromBytes(Uint8List value) { + ByteData view = ByteData.view(value.buffer); + + int length = view.lengthInBytes; + + double speed = 0.0; + double angle = 0.0; + + if (length >= 8) { + speed = view.getFloat64(0, Endian.little); + } + if (length >= 16) { + angle = view.getFloat64(8, Endian.little); + } + + return SwerveModuleStateStruct(speed: speed, angle: angle); + } + + static List listFromBytes(Uint8List value) { + ByteData view = ByteData.view(value.buffer); + + int viewLength = view.lengthInBytes; + + int arraySize = viewLength ~/ length; + + List poseList = []; + + for (int i = 0; i < arraySize; i++) { + if (i * length + length > viewLength) { + break; + } + + Uint8List elementBytes = + Uint8List.sublistView(view, i * length, i * length + length); + + poseList.add(SwerveModuleStateStruct.valueFromBytes(elementBytes)); + } + + return poseList; + } +} diff --git a/lib/widgets/custom_appbar.dart b/lib/widgets/custom_appbar.dart index bc58151c..4709e0ee 100644 --- a/lib/widgets/custom_appbar.dart +++ b/lib/widgets/custom_appbar.dart @@ -8,26 +8,28 @@ import 'package:elastic_dashboard/services/settings.dart'; class CustomAppBar extends AppBar { final String titleText; final Color? appBarColor; - final MenuBar menuBar; final VoidCallback? onWindowClose; + final List trailing; + static const double _leadingSize = 500; static const ThemeType buttonType = ThemeType.materia; - CustomAppBar( - {super.key, - this.titleText = 'Elastic', - this.appBarColor, - this.onWindowClose, - required this.menuBar}) - : super( + CustomAppBar({ + super.key, + this.titleText = 'Elastic', + this.appBarColor, + this.onWindowClose, + this.trailing = const [], + required super.leading, + }) : super( toolbarHeight: 36, backgroundColor: appBarColor ?? const Color.fromARGB(255, 25, 25, 25), elevation: 0.0, scrolledUnderElevation: 0.0, - leading: menuBar, leadingWidth: _leadingSize, centerTitle: true, + notificationPredicate: (_) => false, actions: [ SizedBox( width: _leadingSize, @@ -37,6 +39,7 @@ class CustomAppBar extends AppBar { const Expanded( child: _WindowDragArea(), ), + ...trailing.map((e) => ExcludeFocus(child: e)), InkWell( canRequestFocus: false, onTap: () async => await windowManager.minimize(), diff --git a/lib/widgets/dialog_widgets/dialog_color_picker.dart b/lib/widgets/dialog_widgets/dialog_color_picker.dart index 7595e86e..be99e546 100644 --- a/lib/widgets/dialog_widgets/dialog_color_picker.dart +++ b/lib/widgets/dialog_widgets/dialog_color_picker.dart @@ -7,12 +7,17 @@ class DialogColorPicker extends StatefulWidget { final Function(Color color) onColorPicked; final String label; final Color initialColor; + final Color? defaultColor; + final MainAxisSize rowSize; - const DialogColorPicker( - {super.key, - required this.onColorPicked, - required this.label, - required this.initialColor}); + const DialogColorPicker({ + super.key, + required this.onColorPicked, + required this.label, + required this.initialColor, + this.rowSize = MainAxisSize.min, + this.defaultColor, + }); @override State createState() => _DialogColorPickerState(); @@ -36,7 +41,7 @@ class _DialogColorPickerState extends State { return Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, crossAxisAlignment: CrossAxisAlignment.center, - mainAxisSize: MainAxisSize.min, + mainAxisSize: widget.rowSize, children: [ Text(widget.label), const SizedBox(width: 5), @@ -106,6 +111,18 @@ class _DialogColorPickerState extends State { }, child: const Text('Cancel'), ), + if (widget.defaultColor != null) + TextButton( + onPressed: () { + Navigator.of(context).pop(false); + widget.onColorPicked.call(widget.defaultColor!); + + setState(() { + selectedColor = widget.defaultColor!; + }); + }, + child: const Text('Restore Default'), + ), TextButton( onPressed: () { Navigator.of(context).pop(false); diff --git a/lib/widgets/dialog_widgets/dialog_text_input.dart b/lib/widgets/dialog_widgets/dialog_text_input.dart index ae8401c8..401e165d 100644 --- a/lib/widgets/dialog_widgets/dialog_text_input.dart +++ b/lib/widgets/dialog_widgets/dialog_text_input.dart @@ -7,7 +7,9 @@ class DialogTextInput extends StatefulWidget { final String? label; final String? initialText; final bool allowEmptySubmission; + final bool autoFocus; final bool enabled; + final bool updateOnChanged; final TextEditingController? textEditingController; @@ -20,6 +22,8 @@ class DialogTextInput extends StatefulWidget { this.enabled = true, this.formatter, this.textEditingController, + this.autoFocus = false, + this.updateOnChanged = false, }); @override @@ -56,7 +60,14 @@ class _DialogTextInputState extends State { focused = value; }, child: TextField( + autofocus: widget.autoFocus, enabled: widget.enabled, + onChanged: (value) { + if (widget.updateOnChanged && + (value.isNotEmpty || widget.allowEmptySubmission)) { + widget.onSubmit.call(value); + } + }, onSubmitted: (value) { if (value.isNotEmpty || widget.allowEmptySubmission) { widget.onSubmit.call(value); diff --git a/lib/widgets/draggable_containers/draggable_widget_container.dart b/lib/widgets/draggable_containers/draggable_widget_container.dart index 740747af..618bdbac 100644 --- a/lib/widgets/draggable_containers/draggable_widget_container.dart +++ b/lib/widgets/draggable_containers/draggable_widget_container.dart @@ -54,29 +54,29 @@ class DraggableWidgetContainer extends StatelessWidget { ); }, onDragStart: (event) { - model.setDragging(true); - model.setPreviewVisible(true); - model.setDraggingIntoLayout(false); - model.setDragStartLocation(model.displayRect); - model.setPreviewRect(model.dragStartLocation); - model.setValidLocation( + model.dragging = true; + model.previewVisible = true; + model.draggingIntoLayout = false; + model.dragStartLocation = model.displayRect; + model.previewRect = model.dragStartLocation; + model.validLocation = updateFunctions?.isValidMoveLocation(model, model.previewRect) ?? - true); + true; updateFunctions?.onDragBegin(model); controller?.setRect(model.draggingRect); }, onResizeStart: (handle, event) { - model.setDragging(true); - model.setResizing(true); - model.setPreviewVisible(true); - model.setDraggingIntoLayout(false); - model.setDragStartLocation(model.displayRect); - model.setPreviewRect(model.dragStartLocation); - model.setValidLocation( + model.dragging = true; + model.resizing = true; + model.previewVisible = true; + model.draggingIntoLayout = false; + model.dragStartLocation = model.displayRect; + model.previewRect = model.dragStartLocation; + model.validLocation = updateFunctions?.isValidMoveLocation(model, model.previewRect) ?? - true); + true; updateFunctions?.onResizeBegin.call(model); @@ -88,7 +88,7 @@ class DraggableWidgetContainer extends StatelessWidget { return; } - model.setCursorGlobalLocation(event.globalPosition); + model.cursorGlobalLocation = event.globalPosition; updateFunctions?.onUpdate(model, result.rect, result); @@ -98,7 +98,7 @@ class DraggableWidgetContainer extends StatelessWidget { if (!model.dragging) { return; } - model.setDragging(false); + model.dragging = false; updateFunctions?.onDragEnd(model, model.draggingRect, globalPosition: model.cursorGlobalLocation); @@ -107,7 +107,7 @@ class DraggableWidgetContainer extends StatelessWidget { }, onDragCancel: () { Future(() { - model.setDragging(false); + model.dragging = false; }); updateFunctions?.onDragCancel(model); @@ -118,16 +118,16 @@ class DraggableWidgetContainer extends StatelessWidget { if (!model.dragging && !model.resizing) { return; } - model.setDragging(false); - model.setResizing(false); + model.dragging = false; + model.resizing = false; updateFunctions?.onResizeEnd(model, model.draggingRect); controller?.setRect(model.draggingRect); }, onResizeCancel: (handle) { - model.setDragging(false); - model.setResizing(false); + model.dragging = false; + model.resizing = false; updateFunctions?.onDragCancel(model); diff --git a/lib/widgets/draggable_containers/models/layout_container_model.dart b/lib/widgets/draggable_containers/models/layout_container_model.dart index 7e61d707..56f5c4c4 100644 --- a/lib/widgets/draggable_containers/models/layout_container_model.dart +++ b/lib/widgets/draggable_containers/models/layout_container_model.dart @@ -2,7 +2,6 @@ import 'dart:ui'; import 'package:flutter/foundation.dart'; -import 'nt_widget_container_model.dart'; import 'widget_container_model.dart'; abstract class LayoutContainerModel extends WidgetContainerModel { @@ -41,5 +40,7 @@ abstract class LayoutContainerModel extends WidgetContainerModel { bool willAcceptWidget(WidgetContainerModel widget, {Offset? globalPosition}); - void addWidget(NTWidgetContainerModel model); + void addWidget(WidgetContainerModel widget); + + void removeWidget(WidgetContainerModel widget); } diff --git a/lib/widgets/draggable_containers/models/list_layout_model.dart b/lib/widgets/draggable_containers/models/list_layout_model.dart index 90556888..85b0d722 100644 --- a/lib/widgets/draggable_containers/models/list_layout_model.dart +++ b/lib/widgets/draggable_containers/models/list_layout_model.dart @@ -14,10 +14,15 @@ import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_text_input.dart' import 'package:elastic_dashboard/widgets/draggable_containers/draggable_widget_container.dart'; import 'package:elastic_dashboard/widgets/draggable_containers/models/layout_container_model.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/nt_widget.dart'; -import 'package:elastic_dashboard/widgets/tab_grid.dart'; import 'nt_widget_container_model.dart'; import 'widget_container_model.dart'; +typedef DragOutFunctions = ({ + bool Function(WidgetContainerModel widget) dragOutEnd, + void Function( + WidgetContainerModel widget, Offset globalPosition) dragOutUpdate +}); + class ListLayoutModel extends LayoutContainerModel { @override String type = 'List Layout'; @@ -26,7 +31,7 @@ class ListLayoutModel extends LayoutContainerModel { String labelPosition = 'TOP'; - final TabGridModel tabGrid; + final DragOutFunctions? dragOutFunctions; final NTWidgetContainerModel? Function( SharedPreferences preferences, Map jsonData, @@ -48,7 +53,7 @@ class ListLayoutModel extends LayoutContainerModel { required super.preferences, required super.initialPosition, required super.title, - required this.tabGrid, + this.dragOutFunctions, required this.onDragCancel, this.ntWidgetBuilder, List? children, @@ -65,7 +70,7 @@ class ListLayoutModel extends LayoutContainerModel { required super.jsonData, required super.preferences, required this.ntWidgetBuilder, - required this.tabGrid, + this.dragOutFunctions, required this.onDragCancel, super.enabled, super.minWidth, @@ -160,12 +165,12 @@ class ListLayoutModel extends LayoutContainerModel { } @override - void setEnabled(bool enabled) { + set enabled(bool enabled) { for (var container in children) { - container.setEnabled(enabled); + container.enabled = enabled; } - super.setEnabled(enabled); + super.enabled = enabled; } @override @@ -238,6 +243,7 @@ class ListLayoutModel extends LayoutContainerModel { container.unSubscribe(); container.disposeModel( deleting: true); + container.forceDispose(); notifyListeners(); }); @@ -299,7 +305,7 @@ class ListLayoutModel extends LayoutContainerModel { DialogTextInput( onSubmit: (value) { setState(() { - container.setTitle(value); + container.title = value; notifyListeners(); }); @@ -331,8 +337,14 @@ class ListLayoutModel extends LayoutContainerModel { } @override - void addWidget(NTWidgetContainerModel model) { - children.add(model); + void addWidget(WidgetContainerModel widget) { + children.add(widget as NTWidgetContainerModel); + notifyListeners(); + } + + @override + void removeWidget(WidgetContainerModel widget) { + children.remove(widget); notifyListeners(); } @@ -472,13 +484,11 @@ class ListLayoutModel extends LayoutContainerModel { } widget.cursorGlobalLocation = details.globalPosition; - Future(() { - if (dragging || resizing) { - onDragCancel?.call(this); - } + if (dragging || resizing) { + onDragCancel?.call(this); + } - setDraggable(false); - }); + draggable = false; }, onPanUpdate: (details) { if (preferences.getBool(PrefKeys.layoutLocked) ?? @@ -490,48 +500,32 @@ class ListLayoutModel extends LayoutContainerModel { Offset location = details.globalPosition - Offset(widget.displayRect.width, widget.displayRect.height) / 2; - tabGrid.layoutDragOutUpdate(widget, location); + dragOutFunctions?.dragOutUpdate(widget, location); }, onPanEnd: (details) { if (preferences.getBool(PrefKeys.layoutLocked) ?? Defaults.layoutLocked) { return; } - Future(() => setDraggable(true)); - - int? gridSize = preferences.getInt(PrefKeys.gridSize); - - Rect previewLocation = Rect.fromLTWH( - DraggableWidgetContainer.snapToGrid( - widget.draggingRect.left, gridSize), - DraggableWidgetContainer.snapToGrid( - widget.draggingRect.top, gridSize), - widget.displayRect.width, - widget.displayRect.height, - ); - - if ((tabGrid.isValidMoveLocation(widget, previewLocation) || - tabGrid - .isValidLayoutLocation(widget.cursorGlobalLocation)) && - tabGrid.isDraggingInContainer()) { + + if (dragOutFunctions?.dragOutEnd(widget) ?? false) { children.remove(widget); notifyListeners(); } - tabGrid.layoutDragOutEnd(widget); + draggable = true; }, onPanCancel: () { if (preferences.getBool(PrefKeys.layoutLocked) ?? Defaults.layoutLocked) { return; } - Future(() { - if (dragging || resizing) { - onDragCancel?.call(this); - } - setDraggable(true); - }); + if (dragging || resizing) { + onDragCancel?.call(this); + } + + draggable = true; }, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 2.5, vertical: 2.5), diff --git a/lib/widgets/draggable_containers/models/nt_widget_container_model.dart b/lib/widgets/draggable_containers/models/nt_widget_container_model.dart index 0dd78022..b615fd3f 100644 --- a/lib/widgets/draggable_containers/models/nt_widget_container_model.dart +++ b/lib/widgets/draggable_containers/models/nt_widget_container_model.dart @@ -319,7 +319,9 @@ class NTWidgetContainerModel extends WidgetContainerModel { preferences, type, childModel.topic, - dataType: childModel.dataType, + dataType: (childModel is SingleTopicNTWidgetModel) + ? cast(childModel).dataType + : 'Unkown', period: (type != 'Graph') ? childModel.period : preferences.getDouble(PrefKeys.defaultGraphPeriod) ?? diff --git a/lib/widgets/draggable_containers/models/widget_container_model.dart b/lib/widgets/draggable_containers/models/widget_container_model.dart index 51847716..498e0da8 100644 --- a/lib/widgets/draggable_containers/models/widget_container_model.dart +++ b/lib/widgets/draggable_containers/models/widget_container_model.dart @@ -12,67 +12,165 @@ abstract class WidgetContainerModel extends ChangeNotifier { final Key key = UniqueKey(); final SharedPreferences preferences; - String? title; + String? _title; - late bool draggable = + String? get title => _title; + + set title(String? value) { + _title = value; + notifyListeners(); + } + + late bool _draggable = !(preferences.getBool(PrefKeys.layoutLocked) ?? Defaults.layoutLocked); + + bool get draggable => _draggable; + + set draggable(bool value) { + _draggable = value; + notifyListeners(); + } + bool _disposed = false; bool _forceDispose = false; - late Rect draggingRect = Rect.fromLTWH( + late Rect _draggingRect = Rect.fromLTWH( 0, 0, (preferences.getInt(PrefKeys.gridSize) ?? Defaults.gridSize).toDouble(), (preferences.getInt(PrefKeys.gridSize) ?? Defaults.gridSize).toDouble()); - Offset cursorGlobalLocation = const Offset(double.nan, double.nan); + Rect get draggingRect => _draggingRect; + + set draggingRect(Rect value) { + _draggingRect = value; + notifyListeners(); + } + + Offset _cursorGlobalLocation = const Offset(double.nan, double.nan); - late Rect displayRect = Rect.fromLTWH( + Offset get cursorGlobalLocation => _cursorGlobalLocation; + + set cursorGlobalLocation(Offset value) { + _cursorGlobalLocation = value; + notifyListeners(); + } + + late Rect _displayRect = Rect.fromLTWH( 0, 0, (preferences.getInt(PrefKeys.gridSize) ?? Defaults.gridSize).toDouble(), (preferences.getInt(PrefKeys.gridSize) ?? Defaults.gridSize).toDouble()); - late Rect previewRect = Rect.fromLTWH( + Rect get displayRect => _displayRect; + + set displayRect(Rect value) { + _displayRect = value; + notifyListeners(); + } + + late Rect _previewRect = Rect.fromLTWH( 0, 0, (preferences.getInt(PrefKeys.gridSize) ?? Defaults.gridSize).toDouble(), (preferences.getInt(PrefKeys.gridSize) ?? Defaults.gridSize).toDouble()); - bool enabled = false; - bool dragging = false; - bool resizing = false; - bool draggingIntoLayout = false; - bool previewVisible = false; - bool validLocation = true; + Rect get previewRect => _previewRect; + + set previewRect(Rect value) { + _previewRect = value; + notifyListeners(); + } + + bool _enabled = false; + + bool get enabled => _enabled; + + set enabled(bool value) { + _enabled = value; + notifyListeners(); + } + + bool _dragging = false; + + bool get dragging => _dragging; + + set dragging(bool value) { + _dragging = value; + notifyListeners(); + } + + bool _resizing = false; + + bool get resizing => _resizing; + + set resizing(bool value) { + _resizing = value; + notifyListeners(); + } + + bool _draggingIntoLayout = false; + + bool get draggingIntoLayout => _draggingIntoLayout; + + set draggingIntoLayout(bool value) { + _draggingIntoLayout = value; + notifyListeners(); + } + + bool _previewVisible = false; + + bool get previewVisible => _previewVisible; + + set previewVisible(bool value) { + _previewVisible = value; + notifyListeners(); + } + + bool _validLocation = true; + + bool get validLocation => _validLocation; + + set validLocation(bool value) { + _validLocation = value; + notifyListeners(); + } late double minWidth = (preferences.getInt(PrefKeys.gridSize) ?? Defaults.gridSize).toDouble(); late double minHeight = (preferences.getInt(PrefKeys.gridSize) ?? Defaults.gridSize).toDouble(); - late Rect dragStartLocation; + late Rect _dragStartLocation; + + Rect get dragStartLocation => _dragStartLocation; + + set dragStartLocation(Rect value) { + _dragStartLocation = value; + notifyListeners(); + } WidgetContainerModel({ required this.preferences, required Rect initialPosition, - required this.title, - this.enabled = false, + required String? title, + bool enabled = false, this.minWidth = 128.0, this.minHeight = 128.0, - }) { - displayRect = initialPosition; + }) : _title = title, + _enabled = enabled { + _displayRect = initialPosition; init(); } WidgetContainerModel.fromJson({ required Map jsonData, required this.preferences, - this.enabled = false, + bool enabled = false, this.minWidth = 128.0, this.minHeight = 128.0, Function(String errorMessage)? onJsonLoadingWarning, - }) { + }) : _enabled = enabled { fromJson(jsonData); init(); } @@ -159,72 +257,6 @@ abstract class WidgetContainerModel extends ChangeNotifier { notifyListeners(); } - void setTitle(String title) { - this.title = title; - - notifyListeners(); - } - - void setDraggable(bool draggable) { - this.draggable = draggable; - notifyListeners(); - } - - void setDragging(bool dragging) { - this.dragging = dragging; - notifyListeners(); - } - - void setResizing(bool resizing) { - this.resizing = resizing; - notifyListeners(); - } - - void setPreviewVisible(bool previewVisible) { - this.previewVisible = previewVisible; - notifyListeners(); - } - - void setValidLocation(bool validLocation) { - this.validLocation = validLocation; - notifyListeners(); - } - - void setDraggingIntoLayout(bool draggingIntoLayout) { - this.draggingIntoLayout = draggingIntoLayout; - notifyListeners(); - } - - void setEnabled(bool enabled) { - this.enabled = enabled; - notifyListeners(); - } - - void setDisplayRect(Rect displayRect) { - this.displayRect = displayRect; - notifyListeners(); - } - - void setDraggingRect(Rect draggingRect) { - this.draggingRect = draggingRect; - notifyListeners(); - } - - void setPreviewRect(Rect previewRect) { - this.previewRect = previewRect; - notifyListeners(); - } - - void setDragStartLocation(Rect dragStartLocation) { - this.dragStartLocation = dragStartLocation; - notifyListeners(); - } - - void setCursorGlobalLocation(Offset globalLocation) { - cursorGlobalLocation = globalLocation; - notifyListeners(); - } - void showEditProperties(BuildContext context) { showDialog( context: context, @@ -261,7 +293,7 @@ abstract class WidgetContainerModel extends ChangeNotifier { const SizedBox(height: 5), DialogTextInput( onSubmit: (value) { - setTitle(value); + title = value; }, label: 'Title', initialText: title, diff --git a/lib/widgets/editable_tab_bar.dart b/lib/widgets/editable_tab_bar.dart index 5fa89342..cfb71d58 100644 --- a/lib/widgets/editable_tab_bar.dart +++ b/lib/widgets/editable_tab_bar.dart @@ -172,6 +172,7 @@ class EditableTabBar extends StatelessWidget { }, ); }, + // The tab itself child: AnimatedContainer( duration: const Duration(milliseconds: 300), curve: Curves.easeOutExpo, @@ -199,12 +200,14 @@ class EditableTabBar extends StatelessWidget { : theme.colorScheme.onPrimaryContainer, ), ), + // Spacing for close button Visibility( visible: !(preferences .getBool(PrefKeys.layoutLocked) ?? Defaults.layoutLocked), child: const SizedBox(width: 10), ), + // Close button Visibility( visible: !(preferences .getBool(PrefKeys.layoutLocked) ?? @@ -235,6 +238,7 @@ class EditableTabBar extends StatelessWidget { ), ), const SizedBox(width: 16), + // Tab movement buttons (move left, close, move right) Row( children: [ IconButton( diff --git a/lib/widgets/mjpeg.dart b/lib/widgets/mjpeg.dart index fedc382d..3fa17c61 100644 --- a/lib/widgets/mjpeg.dart +++ b/lib/widgets/mjpeg.dart @@ -4,32 +4,10 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; 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(); - } -} +import 'package:elastic_dashboard/services/log.dart'; /// A preprocessor for each JPEG frame from an MJPEG stream. class MjpegPreprocessor { @@ -37,107 +15,132 @@ class MjpegPreprocessor { } /// An Mjpeg. -class Mjpeg extends HookWidget { - final streamKey = UniqueKey(); - final MjpegStreamState mjpegStream; +class Mjpeg extends StatefulWidget { + final MjpegController controller; final BoxFit? fit; + final bool expandToFit; final double? width; final double? height; final WidgetBuilder? loading; final Widget Function(BuildContext contet, dynamic error, dynamic stack)? error; - Mjpeg({ - required this.mjpegStream, + const Mjpeg({ + required this.controller, this.width, this.height, this.fit, + this.expandToFit = false, this.error, this.loading, super.key, }); + @override + State createState() => _MjpegState(); +} + +class _MjpegState extends State { + final streamKey = UniqueKey(); + + late void Function() listener; + + @override + void initState() { + listener = () => setState(() {}); + widget.controller.addListener(listener); + super.initState(); + } + + @override + void dispose() { + widget.controller.removeListener(listener); + + widget.controller.setMounted(streamKey, false); + widget.controller.setVisible(streamKey, false); + + super.dispose(); + } + + @override + void didUpdateWidget(Mjpeg oldWidget) { + final controller = widget.controller; + final oldController = oldWidget.controller; + + if (oldController != controller) { + oldController.removeListener(listener); + controller.addListener(listener); + + controller.setMounted(streamKey, oldController.isMounted(streamKey)); + controller.setVisible(streamKey, oldController.isVisible(streamKey)); + } + super.didUpdateWidget(oldWidget); + } + @override Widget build(BuildContext context) { - final image = useState(null); - final state = useMemoized(() => _MjpegStateNotifier()); - final visible = useListenable(state); - final errorState = useState?>(null); - isMounted() => context.mounted; - - final manager = useMemoized( - () => _StreamManager( - mjpegStream: mjpegStream, - mounted: isMounted, - visible: () => visible.visible, - ), - [ - visible.visible, - isMounted(), - mjpegStream, - ]); - - final key = useMemoized(() => UniqueKey(), [manager]); - - useEffect(() { - errorState.value = null; - manager.updateStream(streamKey, image, errorState); - return () { - if (visible.visible && isMounted()) { - return; - } - mjpegStream.cancelSubscription(streamKey); - }; - }, [manager]); + final controller = widget.controller; + + controller.setMounted(streamKey, context.mounted); + + if (controller.isVisible(streamKey)) { + controller.startStream(); + } - if (errorState.value != null && kDebugMode) { + if (controller.errorState.value != null && kDebugMode) { return SizedBox( - width: width, - height: height, - child: error == null + width: widget.width, + height: widget.height, + child: widget.error == null ? Center( child: Padding( padding: const EdgeInsets.all(8.0), child: Text( - '${errorState.value}', + '${controller.errorState.value}', textAlign: TextAlign.center, style: const TextStyle(color: Colors.red), ), ), ) - : error!(context, errorState.value!.first, errorState.value!.last), + : widget.error!(context, controller.errorState.value!.first, + controller.errorState.value!.last), ); } - if ((image.value == null && mjpegStream.previousImage == null) || - errorState.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 ?? mjpegStream.previousImage!, - width: width, - height: height, - gaplessPlayback: true, - fit: fit, - ), + key: streamKey, + child: StreamBuilder?>( + stream: controller.imageStream.stream, + builder: (context, snapshot) { + if ((snapshot.data == null && controller.previousImage == null) || + controller.errorState.value != null) { + return SizedBox( + width: widget.width, + height: widget.height, + child: widget.loading?.call(context) ?? + const Center(child: CircularProgressIndicator()), + ); + } + + return Image.memory( + Uint8List.fromList(snapshot.data ?? controller.previousImage!), + width: widget.width, + height: widget.height, + gaplessPlayback: true, + fit: widget.fit, + scale: (widget.expandToFit) ? 1e-6 : 1.0, + ); + }), onVisibilityChanged: (VisibilityInfo info) { - if (visible.mounted) { - visible.visible = info.visibleFraction != 0; + if (controller.isMounted(streamKey)) { + controller.setVisible(streamKey, info.visibleFraction != 0); } }, ); } } -class MjpegStreamState { +class MjpegController extends ChangeNotifier { static const _trigger = 0xFF; static const _soi = 0xD8; static const _eoi = 0xD9; @@ -147,216 +150,188 @@ class MjpegStreamState { final Duration timeout; final Map headers; Client httpClient = Client(); - Stream>? byteStream; + + StreamSubscription>? _rawSubscription; + + ValueNotifier bandwidth = ValueNotifier(0); + ValueNotifier framesPerSecond = ValueNotifier(0); + + Timer? _metricsTimer; + + int _bitCount = 0; + int _frameCount = 0; + + ValueNotifier?> errorState = ValueNotifier(null); + StreamController?> imageStream = StreamController.broadcast(); + List? previousImage; final MjpegPreprocessor? preprocessor; - MemoryImage? previousImage; + final Set _mountedKeys = {}; + final Set _visibleKeys = {}; + + bool isVisible(Key key) => _visibleKeys.contains(key); + + void setVisible(Key key, bool value) { + logger.trace('Setting visibility to $value for $stream'); + if (value) { + bool hasChanged = !_visibleKeys.contains(key); + _visibleKeys.add(key); + + if (hasChanged) { + logger.trace( + 'Visibility changed to true, notifying listeners for mjpeg stream'); + notifyListeners(); + } + } else { + _visibleKeys.remove(key); + + if (_visibleKeys.isEmpty) { + stopStream(); + } + } + } - final Map _subscriptions = {}; + bool isMounted(Key key) => _mountedKeys.contains(key); - StreamSubscription? _bitSubscription; - int bitCount = 0; - double bandwidth = 0.0; + void setMounted(Key key, bool value) { + logger.trace('Setting mounted to $value for $stream'); + if (value) { + _mountedKeys.add(key); + } else { + _mountedKeys.remove(key); + } + } - late final Timer bandwidthTimer; + bool get isStreaming => _rawSubscription != null; - MjpegStreamState({ + MjpegController({ required this.stream, this.isLive = true, this.timeout = const Duration(seconds: 5), this.headers = const {}, this.preprocessor, }) { - bandwidthTimer = Timer.periodic(const Duration(seconds: 1), (timer) { - bandwidth = bitCount / 1e6; - - bitCount = 0; - }); + errorState.addListener(notifyListeners); } + @override void dispose() { - for (StreamSubscription subscription in _subscriptions.values) { - subscription.cancel(); - } - _subscriptions.clear(); - _bitSubscription?.cancel(); - _bitSubscription = null; - byteStream = null; - httpClient.close(); - httpClient = Client(); - bitCount = 0; + errorState.removeListener(notifyListeners); + stopStream(); + imageStream.close(); + super.dispose(); } - void cancelSubscription(Key key) { - if (_subscriptions.containsKey(key)) { - _subscriptions.remove(key)!.cancel(); - - if (_subscriptions.isEmpty) { - dispose(); + void startStream() async { + if (isStreaming) { + return; + } + logger.debug('Starting camera stream on URL $stream'); + Stream>? byteStream; + 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) { + byteStream = response.stream; + } else { + if (_mountedKeys.isNotEmpty) { + errorState.value = [ + HttpException('Stream returned ${response.statusCode} status'), + StackTrace.current + ]; + imageStream.add(null); + } + stopStream(); + } + } 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 (_mountedKeys.isNotEmpty) { + errorState.value = [error, stack]; + imageStream.add(null); + } } } - } - void sendImage( - ValueNotifier image, - ValueNotifier errorState, - List chunks, { - required bool Function() mounted, - }) async { - // pass image through preprocessor sending to [Image] for rendering - final List? imageData; - - if (preprocessor != null) { - imageData = preprocessor?.process(chunks); - } else { - imageData = chunks; + if (byteStream == null) { + return; } - if (imageData == null) return; + var buffer = []; + _rawSubscription = byteStream.listen((data) { + _bitCount += data.length * Uint8List.bytesPerElement * 8; + _handleData(buffer, data); + }); - final imageMemory = MemoryImage(Uint8List.fromList(imageData)); - previousImage?.evict(); - previousImage = imageMemory; - if (mounted()) { - errorState.value = null; - image.value = imageMemory; - } + _metricsTimer = Timer.periodic(const Duration(seconds: 1), _updateMetrics); } - void _onDataReceived({ - required List carry, - required List chunk, - required ValueNotifier image, - required ValueNotifier?> errorState, - required bool Function() mounted, - }) async { - if (carry.isNotEmpty && carry.last == _trigger) { - if (chunk.first == _eoi) { - carry.add(chunk.first); - sendImage(image, errorState, carry, mounted: mounted); - carry = []; - if (!isLive) { - dispose(); - } - } - } + void _updateMetrics(_) { + bandwidth.value = _bitCount / 1e6; + framesPerSecond.value = _frameCount; - for (var i = 0; i < chunk.length - 1; i++) { - final d = chunk[i]; - final d1 = chunk[i + 1]; + _bitCount = 0; + _frameCount = 0; + } - if (d == _trigger && d1 == _soi) { - carry = []; - carry.add(d); - } else if (d == _trigger && d1 == _eoi && carry.isNotEmpty) { - carry.add(d); - carry.add(d1); - - sendImage(image, errorState, carry, mounted: mounted); - carry = []; + void stopStream() async { + logger.debug('Stopping camera stream on URL $stream'); + await _rawSubscription?.cancel(); + _metricsTimer?.cancel(); + _rawSubscription = null; + _bitCount = 0; + _frameCount = 0; + httpClient.close(); + httpClient = Client(); + } + + void _handleNewPacket(List packet) { + logger.trace('Handling a ${packet.length} byte packet'); + previousImage = packet; + List imageData = preprocessor?.process(packet) ?? packet; + imageStream.add(imageData); + _frameCount++; + } + + void _handleData(List buffer, List data) { + if (buffer.isNotEmpty && buffer.last == _trigger) { + if (data.first == _eoi) { + buffer.add(data.first); + _handleNewPacket(buffer); + buffer = []; if (!isLive) { dispose(); } - } else if (carry.isNotEmpty) { - carry.add(d); - if (i == chunk.length - 2) { - carry.add(d1); - } } } - } + for (var i = 0; i < data.length - 1; i++) { + final d = data[i]; + final d1 = data[i + 1]; - void updateStream( - Key key, - ValueNotifier image, - ValueNotifier?> errorState, { - required bool Function() visible, - required bool Function() mounted, - }) async { - if (byteStream == null && visible() && mounted()) { - 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) { - byteStream = response.stream.asBroadcastStream(); - - _bitSubscription = byteStream!.listen((data) { - bitCount += data.length * Uint8List.bytesPerElement * 8; - }); - } else { - if (mounted()) { - errorState.value = [ - HttpException('Stream returned ${response.statusCode} status'), - StackTrace.current - ]; - image.value = null; - } + if (d == _trigger && d1 == _soi) { + buffer = []; + buffer.add(d); + } else if (d == _trigger && d1 == _eoi && buffer.isNotEmpty) { + buffer.add(d); + buffer.add(d1); + + _handleNewPacket(buffer); + buffer = []; + if (!isLive) { 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; - } + } else if (buffer.isNotEmpty) { + buffer.add(d); + if (i == buffer.length - 2) { + buffer.add(d1); } } } - - if (byteStream == null) { - return; - } - - var carry = []; - _subscriptions.putIfAbsent( - key, - () => byteStream!.listen((chunk) { - if (!visible() || !mounted()) { - carry.clear(); - return; - } - _onDataReceived( - carry: carry, - chunk: chunk, - image: image, - errorState: errorState, - mounted: mounted, - ); - }, onError: (error, stack) { - try { - if (mounted()) { - errorState.value = [error, stack]; - image.value = null; - } - } finally { - dispose(); - } - }, cancelOnError: true)); - } -} - -class _StreamManager { - final MjpegStreamState mjpegStream; - - final bool Function() mounted; - final bool Function() visible; - - _StreamManager({ - required this.mjpegStream, - required this.mounted, - required this.visible, - }); - - void updateStream(Key key, ValueNotifier image, - ValueNotifier?> errorState) async { - mjpegStream.updateStream(key, image, errorState, - visible: visible, mounted: mounted); } } diff --git a/lib/widgets/network_tree/networktables_tree.dart b/lib/widgets/network_tree/networktables_tree.dart index ec16d970..4289e920 100644 --- a/lib/widgets/network_tree/networktables_tree.dart +++ b/lib/widgets/network_tree/networktables_tree.dart @@ -21,22 +21,23 @@ typedef ListLayoutBuilder = ListLayoutModel Function({ class NetworkTableTree extends StatefulWidget { final NTConnection ntConnection; final SharedPreferences preferences; - final ListLayoutBuilder listLayoutBuilder; + final ListLayoutBuilder? listLayoutBuilder; final Function(Offset globalPosition, WidgetContainerModel widget)? onDragUpdate; final Function(WidgetContainerModel widget)? onDragEnd; - + final String searchQuery; final bool hideMetadata; const NetworkTableTree({ super.key, required this.ntConnection, required this.preferences, - required this.listLayoutBuilder, + this.listLayoutBuilder, required this.hideMetadata, this.onDragUpdate, this.onDragEnd, + this.searchQuery = '', }); @override @@ -65,22 +66,55 @@ class _NetworkTableTreeState extends State { treeController = TreeController( roots: root.children, childrenProvider: (node) { - if (widget.hideMetadata) { - return node.children - .whereNot((element) => element.rowName.startsWith('.')); + List nodes = node.children; + + // Apply the filter to the children + List filteredChildren = _filterChildren(nodes); + + // If there are any filtered children, include the parent node + if (filteredChildren.isNotEmpty || _matchesFilter(node)) { + if (widget.hideMetadata) { + return filteredChildren + .whereNot((element) => element.rowName.startsWith('.')) + .toList(); + } else { + return filteredChildren; + } } else { - return node.children; + return []; } }, ); widget.ntConnection.addTopicAnnounceListener(onNewTopicAnnounced = (topic) { setState(() { + treeController.roots = _filterChildren(root.children); treeController.rebuild(); }); }); } + List _filterChildren( + List children) { + // Apply the filter to each child + return children.where((child) { + if (_matchesFilter(child)) { + return true; + } + // Recursively check if any descendant matches the filter + return _filterChildren(child.children).isNotEmpty; + }).toList(); + } + + bool _matchesFilter(NetworkTableTreeRow node) { + // Don't filter if there isn't a search + if (widget.searchQuery.isEmpty) { + return true; + } + // Check if the node matches the filter + return node.topic.toLowerCase().contains(widget.searchQuery.toLowerCase()); + } + @override void dispose() { widget.ntConnection.removeTopicAnnounceListener(onNewTopicAnnounced); @@ -90,7 +124,9 @@ class _NetworkTableTreeState extends State { @override void didUpdateWidget(NetworkTableTree oldWidget) { - if (widget.hideMetadata != oldWidget.hideMetadata) { + if (widget.hideMetadata != oldWidget.hideMetadata || + widget.searchQuery != oldWidget.searchQuery) { + treeController.roots = _filterChildren(root.children); treeController.rebuild(); } super.didUpdateWidget(oldWidget); @@ -98,6 +134,9 @@ class _NetworkTableTreeState extends State { void createRows(NT4Topic nt4Topic) { String topic = nt4Topic.name; + if (!topic.startsWith('/')) { + topic = '/$topic'; + } List rows = topic.substring(1).split('/'); NetworkTableTreeRow? current; @@ -148,6 +187,8 @@ class _NetworkTableTreeState extends State { root.sort(); + treeController.roots = _filterChildren(root.children); + return TreeView( treeController: treeController, nodeBuilder: @@ -171,104 +212,138 @@ class _NetworkTableTreeState extends State { } } -class TreeTile extends StatelessWidget { +class TreeTile extends StatefulWidget { final SharedPreferences preferences; final TreeEntry entry; final VoidCallback onTap; - final ListLayoutBuilder listLayoutBuilder; + final ListLayoutBuilder? listLayoutBuilder; final Function(Offset globalPosition, WidgetContainerModel widget)? onDragUpdate; final Function(WidgetContainerModel widget)? onDragEnd; - WidgetContainerModel? draggingWidget; - - TreeTile({ + const TreeTile({ super.key, required this.preferences, required this.entry, required this.onTap, - required this.listLayoutBuilder, + this.listLayoutBuilder, this.onDragUpdate, this.onDragEnd, }); + @override + State createState() => _TreeTileState(); +} + +class _TreeTileState extends State { + WidgetContainerModel? draggingWidget; + bool dragging = false; + + @override + void dispose() { + draggingWidget?.unSubscribe(); + draggingWidget?.disposeModel(deleting: true); + draggingWidget?.forceDispose(); + + super.dispose(); + } + @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( - supportedDevices: PointerDeviceKind.values - .whereNot((element) => element == PointerDeviceKind.trackpad) - .toSet(), - onPanStart: (details) async { - if (draggingWidget != null) { - return; - } - - draggingWidget = await entry.node - .toWidgetContainerModel(listLayoutBuilder: listLayoutBuilder); - }, - onPanUpdate: (details) { - if (draggingWidget == null) { - return; - } - - draggingWidget!.cursorGlobalLocation = details.globalPosition; - - Offset position = details.globalPosition - - Offset( - draggingWidget!.displayRect.width, - draggingWidget!.displayRect.height, - ) / - 2; - - onDragUpdate?.call(position, draggingWidget!); - }, - onPanEnd: (details) { - if (draggingWidget == null) { - return; - } - - onDragEnd?.call(draggingWidget!); - - draggingWidget = null; - }, - child: Padding( - padding: EdgeInsetsDirectional.only(start: entry.level * 16.0), - child: Column( - children: [ - ListTile( - dense: true, - contentPadding: const EdgeInsets.only(right: 20.0), - leading: - (entry.hasChildren || entry.node.containsOnlyMetadata()) - ? FolderButton( - openedIcon: const Icon(Icons.arrow_drop_down), - closedIcon: const Icon(Icons.arrow_right), - iconSize: 24, - isOpen: entry.hasChildren && entry.isExpanded, - onPressed: entry.hasChildren ? onTap : null, - ) - : const SizedBox(width: 8.0), - title: Text(entry.node.rowName), - trailing: (entry.node.ntTopic != null) - ? Text(entry.node.ntTopic!.type, style: trailingStyle) - : null, - ), - ], + // I have absolutely no idea why Material is needed, but otherwise the tiles start bleeding all over the place, it makes zero sense + return Material( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: widget.onTap, + child: GestureDetector( + supportedDevices: PointerDeviceKind.values + .whereNot((element) => element == PointerDeviceKind.trackpad) + .toSet(), + onPanStart: (details) async { + if (draggingWidget != null) { + return; + } + dragging = true; + + draggingWidget = await widget.entry.node.toWidgetContainerModel( + listLayoutBuilder: widget.listLayoutBuilder); + if (!dragging) { + draggingWidget?.unSubscribe(); + draggingWidget?.disposeModel(deleting: true); + draggingWidget?.forceDispose(); + + draggingWidget = null; + } + }, + onPanUpdate: (details) { + if (draggingWidget == null) { + return; + } + + draggingWidget!.cursorGlobalLocation = details.globalPosition; + + Offset position = details.globalPosition - + Offset( + draggingWidget!.displayRect.width, + draggingWidget!.displayRect.height, + ) / + 2; + + widget.onDragUpdate?.call(position, draggingWidget!); + }, + onPanEnd: (details) { + if (draggingWidget == null) { + dragging = false; + return; + } + + widget.onDragEnd?.call(draggingWidget!); + + draggingWidget = null; + + dragging = false; + }, + child: Padding( + padding: EdgeInsetsDirectional.only( + start: widget.entry.level * 16.0), + child: Column( + children: [ + ListTile( + dense: true, + contentPadding: const EdgeInsets.only(right: 20.0), + leading: (widget.entry.hasChildren || + widget.entry.node.containsOnlyMetadata()) + ? FolderButton( + openedIcon: const Icon(Icons.arrow_drop_down), + closedIcon: const Icon(Icons.arrow_right), + iconSize: 24, + isOpen: widget.entry.hasChildren && + widget.entry.isExpanded, + onPressed: widget.entry.hasChildren + ? widget.onTap + : null, + ) + : const SizedBox(width: 8.0), + title: Text(widget.entry.node.rowName), + trailing: (widget.entry.node.ntTopic != null) + ? Text(widget.entry.node.ntTopic!.type, + style: trailingStyle) + : null, + ), + ], + ), ), ), ), - ), - const Divider(height: 0), - ], + const Divider(height: 0), + ], + ), ); } } diff --git a/lib/widgets/network_tree/networktables_tree_row.dart b/lib/widgets/network_tree/networktables_tree_row.dart index 72ac7281..9f70a48e 100644 --- a/lib/widgets/network_tree/networktables_tree_row.dart +++ b/lib/widgets/network_tree/networktables_tree_row.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:collection/collection.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:elastic_dashboard/services/nt4_client.dart'; @@ -113,8 +114,10 @@ class NetworkTableTreeRow { children.clear(); } - static NTWidgetModel? getNTWidgetFromTopic(NTConnection ntConnection, - SharedPreferences preferences, NT4Topic ntTopic) { + static SingleTopicNTWidgetModel? getNTWidgetFromTopic( + NTConnection ntConnection, + SharedPreferences preferences, + NT4Topic ntTopic) { switch (ntTopic.type) { case NT4TypeStr.kFloat64: case NT4TypeStr.kInt: @@ -181,7 +184,8 @@ class NetworkTableTreeRow { } Future getTypeString(String typeTopic) async { - return ntConnection.subscribeAndRetrieveData(typeTopic); + return ntConnection.subscribeAndRetrieveData(typeTopic, + timeout: const Duration(milliseconds: 500)); } Future? getTypedWidget(String typeTopic) async { @@ -192,28 +196,26 @@ class NetworkTableTreeRow { } return NTWidgetBuilder.buildNTModelFromType( - ntConnection, preferences, type, topic); + ntConnection, + preferences, + type, + topic, + ); } Future?> getListLayoutChildren() async { - List listChildren = []; - for (NetworkTableTreeRow child in children) { - if (child.rowName.startsWith('.')) { - continue; - } - WidgetContainerModel? childModel = - await child.toWidgetContainerModel(resortToListLayout: false); + Iterable> childrenFutures = children + .whereNot((e) => e.rowName.startsWith('.')) + .map((e) => e.toWidgetContainerModel(resortToListLayout: false)); - if (childModel is NTWidgetContainerModel) { - listChildren.add(childModel); - } - } + Iterable listChildren = + (await Future.wait(childrenFutures)).whereType(); if (listChildren.isEmpty) { return null; } - return listChildren; + return listChildren.toList(); } Future toWidgetContainerModel({ @@ -222,13 +224,17 @@ class NetworkTableTreeRow { }) async { NTWidgetModel? primary = await getPrimaryWidget(); - if (primary == null) { - if (resortToListLayout) { + if (primary == null || !NTWidgetBuilder.isRegistered(primary.type)) { + primary?.unSubscribe(); + primary?.disposeWidget(deleting: true); + primary?.forceDispose(); + + if (resortToListLayout && listLayoutBuilder != null) { List? listLayoutChildren = await getListLayoutChildren(); if (listLayoutChildren != null) { - return listLayoutBuilder?.call( + return listLayoutBuilder.call( title: rowName, children: listLayoutChildren, ); @@ -240,6 +246,9 @@ class NetworkTableTreeRow { NTWidget? widget = NTWidgetBuilder.buildNTWidgetFromModel(primary); if (widget == null) { + primary.unSubscribe(); + primary.disposeWidget(deleting: true); + primary.forceDispose(); return null; } diff --git a/lib/widgets/nt_widgets/multi-topic/accelerometer.dart b/lib/widgets/nt_widgets/multi-topic/accelerometer.dart index 081147ad..331c6007 100644 --- a/lib/widgets/nt_widgets/multi-topic/accelerometer.dart +++ b/lib/widgets/nt_widgets/multi-topic/accelerometer.dart @@ -6,20 +6,24 @@ import 'package:provider/provider.dart'; import 'package:elastic_dashboard/services/nt4_client.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/nt_widget.dart'; -class AccelerometerModel extends NTWidgetModel { +class AccelerometerModel extends MultiTopicNTWidgetModel { @override String type = AccelerometerWidget.widgetType; late NT4Subscription _valueSubscription; + NT4Subscription get valueSubscription => _valueSubscription; String get valueTopic => '$topic/Value'; + @override + List get subscriptions => [_valueSubscription]; + AccelerometerModel({ required super.ntConnection, required super.preferences, required super.topic, - super.dataType, super.period, + super.dataType, }) : super(); AccelerometerModel.fromJson({ @@ -29,29 +33,9 @@ class AccelerometerModel extends NTWidgetModel { }) : super.fromJson(); @override - void init() { - super.init(); - - _valueSubscription = ntConnection.subscribe(valueTopic, super.period); - } - - @override - void resetSubscription() { - ntConnection.unSubscribe(_valueSubscription); - + void initializeSubscriptions() { _valueSubscription = ntConnection.subscribe(valueTopic, super.period); - - super.resetSubscription(); } - - @override - void unSubscribe() { - ntConnection.unSubscribe(_valueSubscription); - - super.unSubscribe(); - } - - NT4Subscription get valueSubscription => _valueSubscription; } class AccelerometerWidget extends NTWidget { @@ -63,11 +47,10 @@ class AccelerometerWidget extends NTWidget { Widget build(BuildContext context) { AccelerometerModel model = cast(context.watch()); - return StreamBuilder( - stream: model.valueSubscription.periodicStream(yieldAll: false), - initialData: model.ntConnection.getLastAnnouncedValue(model.valueTopic), - builder: (context, snapshot) { - double value = tryCast(snapshot.data) ?? 0.0; + return ValueListenableBuilder( + valueListenable: model.valueSubscription, + builder: (context, data, child) { + double value = tryCast(data) ?? 0.0; return Row( children: [ diff --git a/lib/widgets/nt_widgets/multi-topic/basic_swerve_drive.dart b/lib/widgets/nt_widgets/multi-topic/basic_swerve_drive.dart index 252648c8..75102372 100644 --- a/lib/widgets/nt_widgets/multi-topic/basic_swerve_drive.dart +++ b/lib/widgets/nt_widgets/multi-topic/basic_swerve_drive.dart @@ -6,10 +6,11 @@ import 'package:dot_cast/dot_cast.dart'; import 'package:provider/provider.dart'; import 'package:vector_math/vector_math_64.dart' show radians; +import 'package:elastic_dashboard/services/nt4_client.dart'; import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_toggle_switch.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/nt_widget.dart'; -class BasicSwerveModel extends NTWidgetModel { +class BasicSwerveModel extends MultiTopicNTWidgetModel { @override String type = SwerveDriveWidget.widgetType; @@ -31,6 +32,30 @@ class BasicSwerveModel extends NTWidgetModel { get robotAngleTopic => '$topic/Robot Angle'; + late NT4Subscription frontLeftAngleSubscription; + late NT4Subscription frontLeftVelocitySubscription; + late NT4Subscription frontRightAngleSubscription; + late NT4Subscription frontRightVelocitySubscription; + late NT4Subscription backLeftAngleSubscription; + late NT4Subscription backLeftVelocitySubscription; + late NT4Subscription backRightAngleSubscription; + late NT4Subscription backRightVelocitySubscription; + + late NT4Subscription robotAngleSubscription; + + @override + List get subscriptions => [ + frontLeftAngleSubscription, + frontLeftVelocitySubscription, + frontRightAngleSubscription, + frontRightVelocitySubscription, + backLeftAngleSubscription, + backLeftVelocitySubscription, + backRightAngleSubscription, + backRightVelocitySubscription, + robotAngleSubscription, + ]; + bool _showRobotRotation = true; String _rotationUnit = 'Radians'; @@ -56,6 +81,46 @@ class BasicSwerveModel extends NTWidgetModel { _rotationUnit = tryCast(jsonData['rotation_unit']) ?? 'Degrees'; } + @override + void init() { + initSubscriptions(); + + super.init(); + } + + void initSubscriptions() { + frontLeftAngleSubscription = + ntConnection.subscribe(frontLeftAngleTopic, super.period); + frontLeftVelocitySubscription = + ntConnection.subscribe(frontLeftVelocityTopic, super.period); + frontRightAngleSubscription = + ntConnection.subscribe(frontRightAngleTopic, super.period); + frontRightVelocitySubscription = + ntConnection.subscribe(frontRightVelocityTopic, super.period); + backLeftAngleSubscription = + ntConnection.subscribe(backLeftAngleTopic, super.period); + backLeftVelocitySubscription = + ntConnection.subscribe(backLeftVelocityTopic, super.period); + backRightAngleSubscription = + ntConnection.subscribe(backRightAngleTopic, super.period); + backRightVelocitySubscription = + ntConnection.subscribe(backRightVelocityTopic, super.period); + + robotAngleSubscription = + ntConnection.subscribe(robotAngleTopic, super.period); + } + + @override + void resetSubscription() { + for (NT4Subscription subscription in subscriptions) { + ntConnection.unSubscribe(subscription); + } + + initSubscriptions(); + + super.resetSubscription(); + } + @override Map toJson() { return { @@ -127,49 +192,6 @@ class BasicSwerveModel extends NTWidgetModel { ]; } - @override - List getCurrentData() { - double frontLeftAngle = - tryCast(ntConnection.getLastAnnouncedValue(frontLeftAngleTopic)) ?? 0.0; - double frontLeftVelocity = - tryCast(ntConnection.getLastAnnouncedValue(frontLeftVelocityTopic)) ?? - 0.0; - - double frontRightAngle = - tryCast(ntConnection.getLastAnnouncedValue(frontRightAngleTopic)) ?? - 0.0; - double frontRightVelocity = - tryCast(ntConnection.getLastAnnouncedValue(frontRightVelocityTopic)) ?? - 0.0; - - double backLeftAngle = - tryCast(ntConnection.getLastAnnouncedValue(backLeftAngleTopic)) ?? 0.0; - double backLeftVelocity = - tryCast(ntConnection.getLastAnnouncedValue(backLeftVelocityTopic)) ?? - 0.0; - - double backRightAngle = - tryCast(ntConnection.getLastAnnouncedValue(backRightAngleTopic)) ?? 0.0; - double backRightVelocity = - tryCast(ntConnection.getLastAnnouncedValue(backRightVelocityTopic)) ?? - 0.0; - - double robotAngle = - tryCast(ntConnection.getLastAnnouncedValue(robotAngleTopic)) ?? 0.0; - - return [ - frontLeftAngle, - frontLeftVelocity, - frontRightAngle, - frontRightVelocity, - backLeftAngle, - backLeftVelocity, - backRightAngle, - backRightVelocity, - robotAngle, - ]; - } - get showRobotRotation => _showRobotRotation; set showRobotRotation(value) { @@ -194,40 +216,30 @@ class SwerveDriveWidget extends NTWidget { Widget build(BuildContext context) { BasicSwerveModel model = cast(context.watch()); - return StreamBuilder( - stream: model.multiTopicPeriodicStream, - builder: (context, snapshot) { - double frontLeftAngle = tryCast(model.ntConnection - .getLastAnnouncedValue(model.frontLeftAngleTopic)) ?? - 0.0; - double frontLeftVelocity = tryCast(model.ntConnection - .getLastAnnouncedValue(model.frontLeftVelocityTopic)) ?? - 0.0; - - double frontRightAngle = tryCast(model.ntConnection - .getLastAnnouncedValue(model.frontRightAngleTopic)) ?? - 0.0; - double frontRightVelocity = tryCast(model.ntConnection - .getLastAnnouncedValue(model.frontRightVelocityTopic)) ?? - 0.0; - - double backLeftAngle = tryCast(model.ntConnection - .getLastAnnouncedValue(model.backLeftAngleTopic)) ?? - 0.0; - double backLeftVelocity = tryCast(model.ntConnection - .getLastAnnouncedValue(model.backLeftVelocityTopic)) ?? - 0.0; - - double backRightAngle = tryCast(model.ntConnection - .getLastAnnouncedValue(model.backRightAngleTopic)) ?? - 0.0; - double backRightVelocity = tryCast(model.ntConnection - .getLastAnnouncedValue(model.backRightVelocityTopic)) ?? - 0.0; - - double robotAngle = tryCast(model.ntConnection - .getLastAnnouncedValue(model.robotAngleTopic)) ?? - 0.0; + return ListenableBuilder( + listenable: Listenable.merge(model.subscriptions), + builder: (context, child) { + double frontLeftAngle = + tryCast(model.frontLeftAngleSubscription.value) ?? 0.0; + double frontLeftVelocity = + tryCast(model.frontLeftVelocitySubscription.value) ?? 0.0; + + double frontRightAngle = + tryCast(model.frontRightAngleSubscription.value) ?? 0.0; + double frontRightVelocity = + tryCast(model.frontRightVelocitySubscription.value) ?? 0.0; + + double backLeftAngle = + tryCast(model.backLeftAngleSubscription.value) ?? 0.0; + double backLeftVelocity = + tryCast(model.backLeftVelocitySubscription.value) ?? 0.0; + + double backRightAngle = + tryCast(model.backRightAngleSubscription.value) ?? 0.0; + double backRightVelocity = + tryCast(model.backRightVelocitySubscription.value) ?? 0.0; + + double robotAngle = tryCast(model.robotAngleSubscription.value) ?? 0.0; if (model.rotationUnit == 'Degrees') { frontLeftAngle = radians(frontLeftAngle); diff --git a/lib/widgets/nt_widgets/multi-topic/camera_stream.dart b/lib/widgets/nt_widgets/multi-topic/camera_stream.dart index 1aec534a..56336c04 100644 --- a/lib/widgets/nt_widgets/multi-topic/camera_stream.dart +++ b/lib/widgets/nt_widgets/multi-topic/camera_stream.dart @@ -4,28 +4,28 @@ import 'package:flutter/services.dart'; import 'package:dot_cast/dot_cast.dart'; import 'package:provider/provider.dart'; +import 'package:elastic_dashboard/services/nt4_client.dart'; import 'package:elastic_dashboard/widgets/custom_loading_indicator.dart'; import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_text_input.dart'; import 'package:elastic_dashboard/widgets/mjpeg.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/nt_widget.dart'; -class CameraStreamModel extends NTWidgetModel { +class CameraStreamModel extends MultiTopicNTWidgetModel { @override String type = CameraStreamWidget.widgetType; String get streamsTopic => '$topic/streams'; + late NT4Subscription streamsSubscription; + + @override + List get subscriptions => [streamsSubscription]; + int? _quality; int? _fps; Size? _resolution; - MemoryImage? _lastDisplayedImage; - - MjpegStreamState? mjpegStream; - - MemoryImage? get lastDisplayedImage => _lastDisplayedImage; - - set lastDisplayedImage(value) => _lastDisplayedImage = value; + MjpegController? controller; int? get quality => _quality; @@ -85,10 +85,20 @@ class CameraStreamModel extends NTWidgetModel { .toList(); if (resolution != null && resolution.length > 1) { - _resolution = Size(resolution[0].toDouble(), resolution[1].toDouble()); + if (resolution[0] % 2 != 0) { + resolution[0] += 1; + } + if (resolution[0] > 0 && resolution[1] > 0) { + _resolution = Size(resolution[0].toDouble(), resolution[1].toDouble()); + } } } + @override + void initializeSubscriptions() { + streamsSubscription = ntConnection.subscribe(streamsTopic, super.period); + } + @override void resetSubscription() { closeClient(); @@ -153,7 +163,12 @@ class CameraStreamModel extends NTWidgetModel { return; } - resolution = Size(newWidth.toDouble(), + if (newWidth! % 2 != 0) { + // Won't allow += for some reason + newWidth = newWidth! + 1; + } + + resolution = Size(newWidth!.toDouble(), resolution?.height.toDouble() ?? 0); }); }, @@ -221,31 +236,15 @@ class CameraStreamModel extends NTWidgetModel { @override void disposeWidget({bool deleting = false}) { if (deleting) { - _lastDisplayedImage?.evict(); - mjpegStream?.previousImage?.evict(); - mjpegStream?.dispose(); + controller?.dispose(); } super.disposeWidget(deleting: deleting); } void closeClient() { - _lastDisplayedImage?.evict(); - _lastDisplayedImage = mjpegStream?.previousImage; - mjpegStream?.dispose(); - mjpegStream = null; - } - - @override - List getCurrentData() { - List rawStreams = - tryCast(ntConnection.getLastAnnouncedValue(streamsTopic)) ?? []; - List streams = rawStreams.whereType().toList(); - - return [ - ...streams, - ntConnection.isNT4Connected, - ]; + controller?.dispose(); + controller = null; } } @@ -258,12 +257,14 @@ class CameraStreamWidget extends NTWidget { Widget build(BuildContext context) { CameraStreamModel model = cast(context.watch()); - return StreamBuilder( - stream: model.multiTopicPeriodicStream, - builder: (context, snapshot) { - List rawStreams = tryCast( - model.ntConnection.getLastAnnouncedValue(model.streamsTopic)) ?? - []; + return ListenableBuilder( + listenable: Listenable.merge([ + model.streamsSubscription, + model.ntConnection.ntConnected, + ]), + builder: (context, child) { + List rawStreams = + tryCast(model.streamsSubscription.value) ?? []; List streams = []; for (Object? stream in rawStreams) { @@ -276,16 +277,15 @@ class CameraStreamWidget extends NTWidget { streams.add(stream.substring('mjpg:'.length)); } - if (streams.isEmpty || !model.ntConnection.isNT4Connected) { + if (streams.isEmpty || !model.ntConnection.ntConnected.value) { return Stack( fit: StackFit.expand, children: [ - if (model.mjpegStream != null || model.lastDisplayedImage != null) + if (model.controller?.previousImage != null) Opacity( opacity: 0.35, - child: Image( - image: model.mjpegStream?.previousImage ?? - model.lastDisplayedImage!, + child: Image.memory( + Uint8List.fromList(model.controller!.previousImage!), fit: BoxFit.contain, ), ), @@ -307,28 +307,47 @@ class CameraStreamWidget extends NTWidget { ); } - bool createNewWidget = model.mjpegStream == null; + bool createNewWidget = model.controller == null; String stream = model.getUrlWithParameters(streams.last); createNewWidget = - createNewWidget || (model.mjpegStream?.stream != stream); + createNewWidget || (model.controller?.stream != stream); if (createNewWidget) { - model.lastDisplayedImage?.evict(); - model.mjpegStream?.dispose(); + model.controller?.dispose(); - model.mjpegStream = MjpegStreamState(stream: stream); + model.controller = MjpegController(stream: stream); } - return Stack( - fit: StackFit.expand, - children: [ - Mjpeg( - mjpegStream: model.mjpegStream!, - fit: BoxFit.contain, - ), - ], + return IntrinsicWidth( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + children: [ + ValueListenableBuilder( + valueListenable: model.controller!.framesPerSecond, + builder: (context, value, child) => Text('FPS: $value'), + ), + const Spacer(), + ValueListenableBuilder( + valueListenable: model.controller!.bandwidth, + builder: (context, value, child) => + Text('Bandwidth: ${value.toStringAsFixed(2)} Mbps'), + ), + ], + ), + Flexible( + child: Mjpeg( + controller: model.controller!, + fit: BoxFit.contain, + expandToFit: true, + ), + ), + const Text(''), + ], + ), ); }, ); diff --git a/lib/widgets/nt_widgets/multi-topic/combo_box_chooser.dart b/lib/widgets/nt_widgets/multi-topic/combo_box_chooser.dart index 7da578b6..ae5aae3d 100644 --- a/lib/widgets/nt_widgets/multi-topic/combo_box_chooser.dart +++ b/lib/widgets/nt_widgets/multi-topic/combo_box_chooser.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:dot_cast/dot_cast.dart'; @@ -8,7 +10,7 @@ import 'package:elastic_dashboard/services/nt4_client.dart'; import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_toggle_switch.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/nt_widget.dart'; -class ComboBoxChooserModel extends NTWidgetModel { +class ComboBoxChooserModel extends MultiTopicNTWidgetModel { @override String type = ComboBoxChooser.widgetType; @@ -17,6 +19,19 @@ class ComboBoxChooserModel extends NTWidgetModel { String get activeTopicName => '$topic/active'; String get defaultTopicName => '$topic/default'; + late NT4Subscription optionsSubscription; + late NT4Subscription selectedSubscription; + late NT4Subscription activeSubscription; + late NT4Subscription defaultSubscription; + + @override + List get subscriptions => [ + optionsSubscription, + selectedSubscription, + activeSubscription, + defaultSubscription, + ]; + final TextEditingController _searchController = TextEditingController(); String? _selectedChoice; @@ -31,7 +46,6 @@ class ComboBoxChooserModel extends NTWidgetModel { StringChooserData? previousData; NT4Topic? _selectedTopic; - NT4Topic? _activeTopic; bool _sortOptions = false; @@ -60,6 +74,17 @@ class ComboBoxChooserModel extends NTWidgetModel { _sortOptions = tryCast(jsonData['sort_options']) ?? _sortOptions; } + @override + void initializeSubscriptions() { + optionsSubscription = + ntConnection.subscribe(optionsTopicName, super.period); + selectedSubscription = + ntConnection.subscribe(selectedTopicName, super.period); + activeSubscription = ntConnection.subscribe(activeTopicName, super.period); + defaultSubscription = + ntConnection.subscribe(defaultTopicName, super.period); + } + @override void resetSubscription() { _selectedTopic = null; @@ -98,52 +123,6 @@ class ComboBoxChooserModel extends NTWidgetModel { ntConnection.updateDataFromTopic(_selectedTopic!, selected); } - - void _publishActiveValue(String? active) { - if (active == null || !ntConnection.isNT4Connected) { - return; - } - - bool publishTopic = _activeTopic == null; - - _activeTopic ??= ntConnection.getTopicFromName(activeTopicName); - - if (_activeTopic == null) { - return; - } - - if (publishTopic) { - ntConnection.publishTopic(_activeTopic!); - } - - ntConnection.updateDataFromTopic(_activeTopic!, active); - } - - @override - List getCurrentData() { - List rawOptions = ntConnection - .getLastAnnouncedValue(optionsTopicName) - ?.tryCast>() ?? - []; - - List options = rawOptions.whereType().toList(); - - String active = - tryCast(ntConnection.getLastAnnouncedValue(activeTopicName)) ?? ''; - - String selected = - tryCast(ntConnection.getLastAnnouncedValue(selectedTopicName)) ?? ''; - - String defaultOption = - tryCast(ntConnection.getLastAnnouncedValue(defaultTopicName)) ?? ''; - - return [ - ...options, - active, - selected, - defaultOption, - ]; - } } class ComboBoxChooser extends NTWidget { @@ -155,13 +134,11 @@ class ComboBoxChooser extends NTWidget { Widget build(BuildContext context) { ComboBoxChooserModel model = cast(context.watch()); - return StreamBuilder( - stream: model.multiTopicPeriodicStream, - builder: (context, snapshot) { - List rawOptions = model.ntConnection - .getLastAnnouncedValue(model.optionsTopicName) - ?.tryCast>() ?? - []; + return ListenableBuilder( + listenable: Listenable.merge(model.subscriptions), + builder: (context, child) { + List rawOptions = + model.optionsSubscription.value?.tryCast>() ?? []; List options = rawOptions.whereType().toList(); @@ -169,20 +146,17 @@ class ComboBoxChooser extends NTWidget { options.sort(); } - String? active = tryCast( - model.ntConnection.getLastAnnouncedValue(model.activeTopicName)); + String? active = tryCast(model.activeSubscription.value); if (active != null && active == '') { active = null; } - String? selected = tryCast( - model.ntConnection.getLastAnnouncedValue(model.selectedTopicName)); + String? selected = tryCast(model.selectedSubscription.value); if (selected != null && selected == '') { selected = null; } - String? defaultOption = tryCast( - model.ntConnection.getLastAnnouncedValue(model.defaultTopicName)); + String? defaultOption = tryCast(model.defaultSubscription.value); if (defaultOption != null && defaultOption == '') { defaultOption = null; } @@ -309,77 +283,81 @@ class _StringChooserDropdown extends StatelessWidget { child: Tooltip( message: selected ?? '', waitDuration: const Duration(milliseconds: 250), - child: DropdownButton2( - isExpanded: true, - value: selected, - selectedItemBuilder: (context) => [ - ...options.map((String option) { - return Container( - alignment: AlignmentDirectional.centerStart, - child: Text( - option, - style: Theme.of(context).textTheme.bodyLarge, - overflow: TextOverflow.ellipsis, + child: LayoutBuilder( + builder: (context, constraints) { + return DropdownButton2( + isExpanded: true, + value: selected, + selectedItemBuilder: (context) => [ + ...options.map((String option) { + return Container( + alignment: AlignmentDirectional.centerStart, + child: Text( + option, + style: Theme.of(context).textTheme.bodyLarge, + overflow: TextOverflow.ellipsis, + ), + ); + }), + ], + dropdownStyleData: DropdownStyleData( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15.0), ), - ); - }), - ], - dropdownStyleData: DropdownStyleData( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(15.0), - ), - maxHeight: 250, - width: 250, - ), - dropdownSearchData: DropdownSearchData( - searchController: textController, - searchMatchFn: (item, searchValue) { - return item.value - .toString() - .toLowerCase() - .contains(searchValue.toLowerCase()); - }, - searchInnerWidgetHeight: 50, - searchInnerWidget: Container( - color: Theme.of(context).colorScheme.surface, - height: 50, - padding: const EdgeInsets.only( - top: 8, - bottom: 4, - right: 8, - left: 8, + maxHeight: 250, + width: max(constraints.maxWidth, 250), ), - child: TextFormField( - expands: true, - maxLines: null, - controller: textController, - decoration: InputDecoration( - isDense: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 8, + dropdownSearchData: DropdownSearchData( + searchController: textController, + searchMatchFn: (item, searchValue) { + return item.value + .toString() + .toLowerCase() + .contains(searchValue.toLowerCase()); + }, + searchInnerWidgetHeight: 50, + searchInnerWidget: Container( + color: Theme.of(context).colorScheme.surface, + height: 50, + padding: const EdgeInsets.only( + top: 8, + bottom: 4, + right: 8, + left: 8, ), - label: const Text('Search'), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), + child: TextFormField( + expands: true, + maxLines: null, + controller: textController, + decoration: InputDecoration( + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + label: const Text('Search'), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + ), ), ), ), - ), - ), - items: options.map((String option) { - return DropdownMenuItem( - value: option, - child: - Text(option, style: Theme.of(context).textTheme.bodyMedium), + items: options.map((String option) { + return DropdownMenuItem( + value: option, + child: Text(option, + style: Theme.of(context).textTheme.bodyMedium), + ); + }).toList(), + onMenuStateChange: (isOpen) { + if (!isOpen) { + textController.clear(); + } + }, + onChanged: onValueChanged, ); - }).toList(), - onMenuStateChange: (isOpen) { - if (!isOpen) { - textController.clear(); - } }, - onChanged: onValueChanged, ), ), ); diff --git a/lib/widgets/nt_widgets/multi-topic/command_scheduler.dart b/lib/widgets/nt_widgets/multi-topic/command_scheduler.dart index 99e58bba..4b08a049 100644 --- a/lib/widgets/nt_widgets/multi-topic/command_scheduler.dart +++ b/lib/widgets/nt_widgets/multi-topic/command_scheduler.dart @@ -8,7 +8,7 @@ import 'package:provider/provider.dart'; import 'package:elastic_dashboard/services/nt4_client.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/nt_widget.dart'; -class CommandSchedulerModel extends NTWidgetModel { +class CommandSchedulerModel extends MultiTopicNTWidgetModel { @override String type = CommandSchedulerWidget.widgetType; @@ -18,6 +18,17 @@ class CommandSchedulerModel extends NTWidgetModel { String get idsTopicName => '$topic/Ids'; String get cancelTopicName => '$topic/Cancel'; + late NT4Subscription namesSubscription; + late NT4Subscription idsSubscription; + late NT4Subscription cancelSubscription; + + @override + List get subscriptions => [ + namesSubscription, + idsSubscription, + cancelSubscription, + ]; + CommandSchedulerModel({ required super.ntConnection, required super.preferences, @@ -32,6 +43,13 @@ class CommandSchedulerModel extends NTWidgetModel { required super.jsonData, }) : super.fromJson(); + @override + void initializeSubscriptions() { + namesSubscription = ntConnection.subscribe(namesTopicName, super.period); + idsSubscription = ntConnection.subscribe(idsTopicName, super.period); + cancelSubscription = ntConnection.subscribe(cancelTopicName, super.period); + } + @override void resetSubscription() { _cancelTopic = null; @@ -40,10 +58,8 @@ class CommandSchedulerModel extends NTWidgetModel { } void cancelCommand(int id) { - List currentCancellationsRaw = ntConnection - .getLastAnnouncedValue(cancelTopicName) - ?.tryCast>() ?? - []; + List currentCancellationsRaw = + cancelSubscription.value?.tryCast>() ?? []; List currentCancellations = currentCancellationsRaw.whereType().toList(); @@ -59,24 +75,6 @@ class CommandSchedulerModel extends NTWidgetModel { ntConnection.updateDataFromTopic(_cancelTopic!, currentCancellations); } - - @override - List getCurrentData() { - List rawNames = ntConnection - .getLastAnnouncedValue(namesTopicName) - ?.tryCast>() ?? - []; - - List rawIds = ntConnection - .getLastAnnouncedValue(idsTopicName) - ?.tryCast>() ?? - []; - - List names = rawNames.whereType().toList(); - List ids = rawIds.whereType().toList(); - - return [...names, ...ids]; - } } class CommandSchedulerWidget extends NTWidget { @@ -88,18 +86,14 @@ class CommandSchedulerWidget extends NTWidget { Widget build(BuildContext context) { CommandSchedulerModel model = cast(context.watch()); - return StreamBuilder( - stream: model.multiTopicPeriodicStream, - builder: (context, snapshot) { - List rawNames = model.ntConnection - .getLastAnnouncedValue(model.namesTopicName) - ?.tryCast>() ?? - []; - - List rawIds = model.ntConnection - .getLastAnnouncedValue(model.idsTopicName) - ?.tryCast>() ?? - []; + return ListenableBuilder( + listenable: Listenable.merge(model.subscriptions), + builder: (context, child) { + List rawNames = + model.namesSubscription.value?.tryCast>() ?? []; + + List rawIds = + model.idsSubscription.value?.tryCast>() ?? []; List names = rawNames.whereType().toList(); List ids = rawIds.whereType().toList(); diff --git a/lib/widgets/nt_widgets/multi-topic/command_widget.dart b/lib/widgets/nt_widgets/multi-topic/command_widget.dart index 2937ff46..47b2f581 100644 --- a/lib/widgets/nt_widgets/multi-topic/command_widget.dart +++ b/lib/widgets/nt_widgets/multi-topic/command_widget.dart @@ -7,15 +7,24 @@ import 'package:elastic_dashboard/services/nt4_client.dart'; import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_toggle_switch.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/nt_widget.dart'; -class CommandModel extends NTWidgetModel { +class CommandModel extends MultiTopicNTWidgetModel { @override String type = CommandWidget.widgetType; - NT4Topic? runningTopic; - String get runningTopicName => '$topic/running'; String get nameTopicName => '$topic/name'; + late NT4Subscription runningSubscription; + late NT4Subscription nameSubscription; + + @override + List get subscriptions => [ + runningSubscription, + nameSubscription, + ]; + + NT4Topic? runningTopic; + bool _showType = true; bool get showType => _showType; @@ -43,6 +52,13 @@ class CommandModel extends NTWidgetModel { _showType = tryCast(jsonData['show_type']) ?? _showType; } + @override + void initializeSubscriptions() { + runningSubscription = + ntConnection.subscribe(runningTopicName, super.period); + nameSubscription = ntConnection.subscribe(nameTopicName, super.period); + } + @override void resetSubscription() { runningTopic = null; @@ -70,18 +86,6 @@ class CommandModel extends NTWidgetModel { ), ]; } - - @override - List getCurrentData() { - bool running = - ntConnection.getLastAnnouncedValue(runningTopicName)?.tryCast() ?? - false; - String name = - ntConnection.getLastAnnouncedValue(nameTopicName)?.tryCast() ?? - 'Unknown'; - - return [running, name]; - } } class CommandWidget extends NTWidget { @@ -93,76 +97,77 @@ class CommandWidget extends NTWidget { Widget build(BuildContext context) { CommandModel model = cast(context.watch()); - return StreamBuilder( - stream: model.multiTopicPeriodicStream, - builder: (context, snapshot) { - bool running = model.ntConnection - .getLastAnnouncedValue(model.runningTopicName) - ?.tryCast() ?? - false; - String name = model.ntConnection - .getLastAnnouncedValue(model.nameTopicName) - ?.tryCast() ?? - 'Unknown'; - - String buttonText = - model.topic.substring(model.topic.lastIndexOf('/') + 1); - - ThemeData theme = Theme.of(context); - - return Column( - children: [ - Visibility( - visible: model.showType, - child: Text('Type: $name', - style: theme.textTheme.bodySmall, - overflow: TextOverflow.ellipsis), - ), - const SizedBox(height: 10), - GestureDetector( - onTapUp: (_) { - bool publishTopic = model.runningTopic == null; - - model.runningTopic = - model.ntConnection.getTopicFromName(model.runningTopicName); - - if (model.runningTopic == null) { - return; - } - - if (publishTopic) { - model.ntConnection.publishTopic(model.runningTopic!); - } - - model.ntConnection - .updateDataFromTopic(model.runningTopic!, !running); - }, - child: AnimatedContainer( - duration: const Duration(milliseconds: 50), - padding: - const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8.0), - boxShadow: const [ - BoxShadow( - offset: Offset(2, 2), - blurRadius: 10.0, - spreadRadius: -5, - color: Colors.black, - ), - ], - color: (running) - ? theme.colorScheme.primaryContainer - : const Color.fromARGB(255, 50, 50, 50), - ), - child: Text(buttonText, - style: theme.textTheme.bodyLarge, - overflow: TextOverflow.ellipsis), - ), - ), - ], - ); - }, + String buttonText = model.topic.substring(model.topic.lastIndexOf('/') + 1); + + ThemeData theme = Theme.of(context); + + return Column( + children: [ + Visibility( + visible: model.showType, + child: ValueListenableBuilder( + valueListenable: model.nameSubscription, + builder: (context, data, child) { + String name = tryCast(data) ?? 'Unknown'; + + return Text('Type: $name', + style: theme.textTheme.bodySmall, + overflow: TextOverflow.ellipsis); + }), + ), + const SizedBox(height: 10), + GestureDetector( + onTapUp: (_) { + bool publishTopic = model.runningTopic == null; + + model.runningTopic ??= + model.ntConnection.getTopicFromName(model.runningTopicName); + + if (model.runningTopic == null) { + return; + } + + if (publishTopic) { + model.ntConnection.publishTopic(model.runningTopic!); + } + + // Prevents widget from locking up if double pressed fast enough + bool running = + model.runningSubscription.value?.tryCast() ?? false; + + model.ntConnection + .updateDataFromTopic(model.runningTopic!, !running); + }, + child: ValueListenableBuilder( + valueListenable: model.runningSubscription, + builder: (context, data, child) { + bool running = tryCast(data) ?? false; + + return AnimatedContainer( + duration: const Duration(milliseconds: 50), + padding: const EdgeInsets.symmetric( + horizontal: 8.0, vertical: 4.0), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8.0), + boxShadow: const [ + BoxShadow( + offset: Offset(2, 2), + blurRadius: 10.0, + spreadRadius: -5, + color: Colors.black, + ), + ], + color: (running) + ? theme.colorScheme.primaryContainer + : const Color.fromARGB(255, 50, 50, 50), + ), + child: Text(buttonText, + style: theme.textTheme.bodyLarge, + overflow: TextOverflow.ellipsis), + ); + }), + ), + ], ); } } diff --git a/lib/widgets/nt_widgets/multi-topic/differential_drive.dart b/lib/widgets/nt_widgets/multi-topic/differential_drive.dart index 93a9ddbe..5a43f7cc 100644 --- a/lib/widgets/nt_widgets/multi-topic/differential_drive.dart +++ b/lib/widgets/nt_widgets/multi-topic/differential_drive.dart @@ -3,27 +3,36 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:dot_cast/dot_cast.dart'; +import 'package:geekyants_flutter_gauges/geekyants_flutter_gauges.dart'; import 'package:provider/provider.dart'; -import 'package:syncfusion_flutter_gauges/gauges.dart'; import 'package:elastic_dashboard/services/nt4_client.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/nt_widget.dart'; -class DifferentialDriveModel extends NTWidgetModel { +class DifferentialDriveModel extends MultiTopicNTWidgetModel { @override String type = DifferentialDrive.widgetType; String get leftSpeedTopicName => '$topic/Left Motor Speed'; String get rightSpeedTopicName => '$topic/Right Motor Speed'; + late NT4Subscription leftSpeedSubscription; + late NT4Subscription rightSpeedSubscription; + + @override + List get subscriptions => [ + leftSpeedSubscription, + rightSpeedSubscription, + ]; + NT4Topic? leftSpeedTopic; NT4Topic? rightSpeedTopic; double _leftSpeedPreviousValue = 0.0; double _rightSpeedPreviousValue = 0.0; - double _leftSpeedCurrentValue = 0.0; - double _rightSpeedCurrentValue = 0.0; + ValueNotifier leftSpeedCurrentValue = ValueNotifier(0.0); + ValueNotifier rightSpeedCurrentValue = ValueNotifier(0.0); get leftSpeedPreviousValue => _leftSpeedPreviousValue; @@ -33,14 +42,6 @@ class DifferentialDriveModel extends NTWidgetModel { set rightSpeedPreviousValue(value) => _rightSpeedPreviousValue = value; - get leftSpeedCurrentValue => _leftSpeedCurrentValue; - - set leftSpeedCurrentValue(value) => _leftSpeedCurrentValue = value; - - get rightSpeedCurrentValue => _rightSpeedCurrentValue; - - set rightSpeedCurrentValue(value) => _rightSpeedCurrentValue = value; - DifferentialDriveModel({ required super.ntConnection, required super.preferences, @@ -55,36 +56,27 @@ class DifferentialDriveModel extends NTWidgetModel { required super.jsonData, }) : super.fromJson(); + @override + void initializeSubscriptions() { + leftSpeedSubscription = + ntConnection.subscribe(leftSpeedTopicName, super.period); + rightSpeedSubscription = + ntConnection.subscribe(rightSpeedTopicName, super.period); + } + @override void resetSubscription() { leftSpeedTopic = null; rightSpeedTopic = null; leftSpeedPreviousValue = 0.0; - leftSpeedCurrentValue = 0.0; + leftSpeedCurrentValue.value = 0.0; - rightSpeedPreviousValue = 0.0; - rightSpeedCurrentValue = 0.0; + rightSpeedPreviousValue.value = 0.0; + rightSpeedCurrentValue.value = 0.0; super.resetSubscription(); } - - @override - List getCurrentData() { - double leftSpeed = - tryCast(ntConnection.getLastAnnouncedValue(leftSpeedTopicName)) ?? 0.0; - double rightSpeed = - tryCast(ntConnection.getLastAnnouncedValue(rightSpeedTopicName)) ?? 0.0; - - return [ - leftSpeed, - rightSpeed, - _leftSpeedPreviousValue, - _rightSpeedPreviousValue, - _leftSpeedCurrentValue, - _rightSpeedCurrentValue, - ]; - } } class DifferentialDrive extends NTWidget { @@ -96,23 +88,23 @@ class DifferentialDrive extends NTWidget { Widget build(BuildContext context) { DifferentialDriveModel model = cast(context.watch()); - return StreamBuilder( - stream: model.multiTopicPeriodicStream, - builder: (context, snapshot) { - double leftSpeed = tryCast(model.ntConnection - .getLastAnnouncedValue(model.leftSpeedTopicName)) ?? - 0.0; - double rightSpeed = tryCast(model.ntConnection - .getLastAnnouncedValue(model.rightSpeedTopicName)) ?? - 0.0; + return ListenableBuilder( + listenable: Listenable.merge([ + ...model.subscriptions, + model.leftSpeedCurrentValue, + model.rightSpeedCurrentValue, + ]), + builder: (context, child) { + double leftSpeed = tryCast(model.leftSpeedSubscription.value) ?? 0.0; + double rightSpeed = tryCast(model.rightSpeedSubscription.value) ?? 0.0; if (leftSpeed != model.leftSpeedPreviousValue) { - model.leftSpeedCurrentValue = leftSpeed; + model.leftSpeedCurrentValue.value = leftSpeed; } model.leftSpeedPreviousValue = leftSpeed; if (rightSpeed != model.rightSpeedPreviousValue) { - model.rightSpeedCurrentValue = rightSpeed; + model.rightSpeedCurrentValue.value = rightSpeed; } model.rightSpeedPreviousValue = rightSpeed; @@ -120,26 +112,36 @@ class DifferentialDrive extends NTWidget { mainAxisSize: MainAxisSize.min, children: [ // Left speed gauge - SfLinearGauge( - key: UniqueKey(), - maximum: 1.0, - minimum: -1.0, - labelPosition: LinearLabelPosition.inside, - tickPosition: LinearElementPosition.inside, - markerPointers: [ - LinearShapePointer( - value: model.leftSpeedCurrentValue.clamp(-1.0, 1.0), + LinearGauge( + rulers: RulerStyle( + rulerPosition: RulerPosition.right, + showLabel: true, + textStyle: Theme.of(context).textTheme.bodySmall, + primaryRulerColor: Colors.grey, + secondaryRulerColor: Colors.grey, + labelOffset: 5, + ), + extendLinearGauge: 1, + linearGaugeBoxDecoration: const LinearGaugeBoxDecoration( + backgroundColor: Color.fromRGBO(87, 87, 87, 1), + thickness: 7.5, + borderRadius: 10, + ), + pointers: [ + Pointer( + key: UniqueKey(), + enableAnimation: false, + value: model.leftSpeedCurrentValue.value.clamp(-1.0, 1.0), + shape: PointerShape.triangle, color: Theme.of(context).colorScheme.primary, - height: 12.5, + pointerPosition: PointerPosition.left, width: 12.5, - animationDuration: 0, - shapeType: LinearShapePointerType.invertedTriangle, - position: LinearElementPosition.outside, - dragBehavior: LinearMarkerDragBehavior.free, + height: 12.5, + isInteractive: true, onChanged: (value) { - model.leftSpeedCurrentValue = value; + model.leftSpeedCurrentValue.value = value; }, - onChangeEnd: (value) { + onChangeEnd: () { bool publishTopic = model.leftSpeedTopic == null; model.leftSpeedTopic ??= model.ntConnection @@ -156,19 +158,18 @@ class DifferentialDrive extends NTWidget { model.ntConnection.updateDataFromTopic( model.leftSpeedTopic!, model.leftSpeedCurrentValue); - model.leftSpeedPreviousValue = model.leftSpeedCurrentValue; + model.leftSpeedPreviousValue = + model.leftSpeedCurrentValue.value; }, - ), + ) ], - axisTrackStyle: const LinearAxisTrackStyle( - thickness: 7.5, - edgeStyle: LinearEdgeStyle.bothCurve, - ), - orientation: LinearGaugeOrientation.vertical, - interval: 0.5, - minorTicksPerInterval: 2, + gaugeOrientation: GaugeOrientation.vertical, + start: -1.0, + end: 1.0, + steps: 0.5, + enableGaugeAnimation: false, ), - const SizedBox(width: 5), + const SizedBox(width: 15), // Robot Flexible( child: LayoutBuilder( @@ -181,37 +182,48 @@ class DifferentialDrive extends NTWidget { height: sideLength, child: CustomPaint( painter: _DifferentialDrivePainter( - leftSpeed: model.leftSpeedCurrentValue.clamp(-1.0, 1.0), + leftSpeed: + model.leftSpeedCurrentValue.value.clamp(-1.0, 1.0), rightSpeed: - model.rightSpeedCurrentValue.clamp(-1.0, 1.0), + model.rightSpeedCurrentValue.value.clamp(-1.0, 1.0), ), ), ); }, ), ), - const SizedBox(width: 5), + const SizedBox(width: 15), // Right speed gauge - SfLinearGauge( - key: UniqueKey(), - maximum: 1.0, - minimum: -1.0, - labelPosition: LinearLabelPosition.outside, - tickPosition: LinearElementPosition.outside, - markerPointers: [ - LinearShapePointer( - value: model.rightSpeedCurrentValue.clamp(-1.0, 1.0), + LinearGauge( + rulers: RulerStyle( + rulerPosition: RulerPosition.left, + showLabel: true, + textStyle: Theme.of(context).textTheme.bodySmall, + primaryRulerColor: Colors.grey, + secondaryRulerColor: Colors.grey, + labelOffset: 5, + ), + extendLinearGauge: 1, + linearGaugeBoxDecoration: const LinearGaugeBoxDecoration( + backgroundColor: Color.fromRGBO(87, 87, 87, 1), + thickness: 7.5, + borderRadius: 10, + ), + pointers: [ + Pointer( + key: UniqueKey(), + enableAnimation: false, + value: model.rightSpeedCurrentValue.value.clamp(-1.0, 1.0), + shape: PointerShape.triangle, color: Theme.of(context).colorScheme.primary, - height: 12.5, + pointerPosition: PointerPosition.right, width: 12.5, - animationDuration: 0, - shapeType: LinearShapePointerType.triangle, - position: LinearElementPosition.inside, - dragBehavior: LinearMarkerDragBehavior.free, + height: 12.5, + isInteractive: true, onChanged: (value) { - model.rightSpeedCurrentValue = value; + model.rightSpeedCurrentValue.value = value; }, - onChangeEnd: (value) { + onChangeEnd: () { bool publishTopic = model.rightSpeedTopic == null; model.rightSpeedTopic ??= model.ntConnection @@ -229,16 +241,15 @@ class DifferentialDrive extends NTWidget { model.rightSpeedTopic!, model.rightSpeedCurrentValue); model.rightSpeedPreviousValue = - model.rightSpeedCurrentValue; + model.rightSpeedCurrentValue.value; }, ), ], - axisTrackStyle: const LinearAxisTrackStyle( - thickness: 7.5, - edgeStyle: LinearEdgeStyle.bothCurve, - ), - orientation: LinearGaugeOrientation.vertical, - interval: 0.5, + gaugeOrientation: GaugeOrientation.vertical, + start: -1.0, + end: 1.0, + steps: 0.5, + enableGaugeAnimation: false, ), ], ); diff --git a/lib/widgets/nt_widgets/multi-topic/encoder_widget.dart b/lib/widgets/nt_widgets/multi-topic/encoder_widget.dart index 2d0f39be..a45f39cb 100644 --- a/lib/widgets/nt_widgets/multi-topic/encoder_widget.dart +++ b/lib/widgets/nt_widgets/multi-topic/encoder_widget.dart @@ -3,15 +3,25 @@ import 'package:flutter/material.dart'; import 'package:dot_cast/dot_cast.dart'; import 'package:provider/provider.dart'; +import 'package:elastic_dashboard/services/nt4_client.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/nt_widget.dart'; -class EncoderModel extends NTWidgetModel { +class EncoderModel extends MultiTopicNTWidgetModel { @override String type = EncoderWidget.widgetType; String get distanceTopic => '$topic/Distance'; String get speedTopic => '$topic/Speed'; + late NT4Subscription distanceSubscription; + late NT4Subscription speedSubscription; + + @override + List get subscriptions => [ + distanceSubscription, + speedSubscription, + ]; + EncoderModel({ required super.ntConnection, required super.preferences, @@ -27,13 +37,9 @@ class EncoderModel extends NTWidgetModel { }) : super.fromJson(); @override - List getCurrentData() { - double distance = - tryCast(ntConnection.getLastAnnouncedValue(distanceTopic)) ?? 0.0; - double speed = - tryCast(ntConnection.getLastAnnouncedValue(speedTopic)) ?? 0.0; - - return [distance, speed]; + void initializeSubscriptions() { + distanceSubscription = ntConnection.subscribe(distanceTopic, super.period); + speedSubscription = ntConnection.subscribe(speedTopic, super.period); } } @@ -46,74 +52,72 @@ class EncoderWidget extends NTWidget { Widget build(BuildContext context) { EncoderModel model = cast(context.watch()); - return StreamBuilder( - stream: model.multiTopicPeriodicStream, - builder: (context, snapshot) { - double distance = tryCast(model.ntConnection - .getLastAnnouncedValue(model.distanceTopic)) ?? - 0.0; - double speed = tryCast( - model.ntConnection.getLastAnnouncedValue(model.speedTopic)) ?? - 0.0; - - return Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + return Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Row( children: [ - Row( - children: [ - const Text('Distance'), - const SizedBox(width: 10), - Expanded( - child: Container( - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: Colors.grey.shade700, - width: 1.5, - ), - ), - ), - child: SelectableText( - distance.toStringAsPrecision(10), - maxLines: 1, - showCursor: true, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - overflow: TextOverflow.ellipsis, - ), + const Text('Distance'), + const SizedBox(width: 10), + Expanded( + child: Container( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Colors.grey.shade700, + width: 1.5, ), ), ), - ], + child: ValueListenableBuilder( + valueListenable: model.distanceSubscription, + builder: (context, value, child) { + double distance = tryCast(value) ?? 0.0; + return SelectableText( + distance.toStringAsPrecision(10), + maxLines: 1, + showCursor: true, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + overflow: TextOverflow.ellipsis, + ), + ); + }), + ), ), - Row( - children: [ - const Text('Speed'), - const SizedBox(width: 10), - Expanded( - child: Container( - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: Colors.grey.shade700, - width: 1.5, - ), - ), - ), - child: SelectableText( - speed.toStringAsPrecision(10), - maxLines: 1, - showCursor: true, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - overflow: TextOverflow.ellipsis, - ), + ], + ), + Row( + children: [ + const Text('Speed'), + const SizedBox(width: 10), + Expanded( + child: Container( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Colors.grey.shade700, + width: 1.5, ), ), ), - ], + child: ValueListenableBuilder( + valueListenable: model.speedSubscription, + builder: (context, value, child) { + double speed = tryCast(value) ?? 0.0; + return SelectableText( + speed.toStringAsPrecision(10), + maxLines: 1, + showCursor: true, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + overflow: TextOverflow.ellipsis, + ), + ); + }), + ), ), ], - ); - }, + ), + ], ); } } diff --git a/lib/widgets/nt_widgets/multi-topic/field_widget.dart b/lib/widgets/nt_widgets/multi-topic/field_widget.dart index 080c0319..bc18dd83 100644 --- a/lib/widgets/nt_widgets/multi-topic/field_widget.dart +++ b/lib/widgets/nt_widgets/multi-topic/field_widget.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'dart:math'; import 'package:flutter/gestures.dart'; @@ -10,17 +11,33 @@ import 'package:vector_math/vector_math_64.dart' show radians; import 'package:elastic_dashboard/services/field_images.dart'; import 'package:elastic_dashboard/services/nt4_client.dart'; -import 'package:elastic_dashboard/services/settings.dart'; import 'package:elastic_dashboard/services/text_formatter_builder.dart'; +import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_color_picker.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/dialog_widgets/dialog_toggle_switch.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/nt_widget.dart'; -class FieldWidgetModel extends NTWidgetModel { +class FieldWidgetModel extends MultiTopicNTWidgetModel { @override String type = 'Field'; + String get robotTopicName => '$topic/Robot'; + late NT4Subscription robotSubscription; + + final List _otherObjectTopics = []; + final List _otherObjectSubscriptions = []; + + @override + List get subscriptions => [ + robotSubscription, + ..._otherObjectSubscriptions, + ]; + + bool rendered = false; + + late Function(NT4Topic topic) topicAnnounceListener; + static const String _defaultGame = 'Crescendo'; String _fieldGame = _defaultGame; late Field _field; @@ -31,17 +48,67 @@ class FieldWidgetModel extends NTWidgetModel { bool _showOtherObjects = true; bool _showTrajectories = true; + Color _robotColor = Colors.red; + Color _trajectoryColor = Colors.white; + final double _otherObjectSize = 0.55; final double _trajectoryPointSize = 0.08; Size? _widgetSize; - late String _robotTopicName; - final List _otherObjectTopics = []; + get robotWidthMeters => _robotWidthMeters; - bool rendered = false; + set robotWidthMeters(value) { + _robotWidthMeters = value; + refresh(); + } - late Function(NT4Topic topic) topicAnnounceListener; + get robotLengthMeters => _robotLengthMeters; + + set robotLengthMeters(value) { + _robotLengthMeters = value; + refresh(); + } + + get showOtherObjects => _showOtherObjects; + + set showOtherObjects(value) { + _showOtherObjects = value; + refresh(); + } + + get showTrajectories => _showTrajectories; + + set showTrajectories(value) { + _showTrajectories = value; + refresh(); + } + + get robotColor => _robotColor; + + set robotColor(value) { + _robotColor = value; + refresh(); + } + + get trajectoryColor => _trajectoryColor; + + set trajectoryColor(value) { + _trajectoryColor = value; + refresh(); + } + + get otherObjectSize => _otherObjectSize; + + get trajectoryPointSize => _trajectoryPointSize; + + get widgetSize => _widgetSize; + + set widgetSize(value) { + _widgetSize = value; + } + + get field => _field; FieldWidgetModel({ required super.ntConnection, @@ -50,10 +117,18 @@ class FieldWidgetModel extends NTWidgetModel { String? fieldName, bool showOtherObjects = true, bool showTrajectories = true, + double robotWidthMeters = 0.85, + double robotLengthMeters = 0.85, + Color robotColor = Colors.red, + Color trajectoryColor = Colors.white, super.dataType, super.period, }) : _showTrajectories = showTrajectories, _showOtherObjects = showOtherObjects, + _robotWidthMeters = robotWidthMeters, + _robotLengthMeters = robotLengthMeters, + _robotColor = robotColor, + _trajectoryColor = trajectoryColor, super() { _fieldGame = fieldName ?? _fieldGame; @@ -84,32 +159,46 @@ class FieldWidgetModel extends NTWidgetModel { } _field = FieldImages.getFieldFromGame(_fieldGame)!; + + _robotColor = Color(tryCast(jsonData['robot_color']) ?? Colors.red.value); + _trajectoryColor = + Color(tryCast(jsonData['trajectory_color']) ?? Colors.white.value); } @override void init() { super.init(); - _robotTopicName = '$topic/Robot'; - topicAnnounceListener = (nt4Topic) { if (nt4Topic.name.contains(topic) && !nt4Topic.name.contains('Robot') && !nt4Topic.name.contains('.') && !_otherObjectTopics.contains(nt4Topic.name)) { _otherObjectTopics.add(nt4Topic.name); + _otherObjectSubscriptions + .add(ntConnection.subscribe(nt4Topic.name, super.period)); } }; ntConnection.addTopicAnnounceListener(topicAnnounceListener); } + @override + void initializeSubscriptions() { + _otherObjectSubscriptions.clear(); + + robotSubscription = ntConnection.subscribe(robotTopicName, super.period); + } + @override void resetSubscription() { - _robotTopicName = '$topic/Robot'; _otherObjectTopics.clear(); super.resetSubscription(); + + // If the topic changes the other objects need to be found under the new root table + ntConnection.removeTopicAnnounceListener(topicAnnounceListener); + ntConnection.addTopicAnnounceListener(topicAnnounceListener); } @override @@ -134,6 +223,8 @@ class FieldWidgetModel extends NTWidgetModel { 'robot_length': _robotLengthMeters, 'show_other_objects': _showOtherObjects, 'show_trajectories': _showTrajectories, + 'robot_color': robotColor.value, + 'trajectory_color': trajectoryColor.value, }; } @@ -274,116 +365,41 @@ class FieldWidgetModel extends NTWidgetModel { ), ], ), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: DialogColorPicker( + onColorPicked: (color) { + robotColor = color; + }, + label: 'Robot Color', + initialColor: robotColor, + defaultColor: Colors.red, + ), + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: DialogColorPicker( + onColorPicked: (color) { + trajectoryColor = color; + }, + label: 'Trajectory Color', + initialColor: trajectoryColor, + defaultColor: Colors.white, + ), + ), + ), + ], + ), ]; } - - @override - List getCurrentData() { - List data = []; - - List robotPositionRaw = ntConnection - .getLastAnnouncedValue(_robotTopicName) - ?.tryCast>() ?? - []; - - List robotPosition = robotPositionRaw.whereType().toList(); - - data.addAll(robotPosition); - - if (_showOtherObjects || _showTrajectories) { - for (String objectTopic in _otherObjectTopics) { - List? objectPositionRaw = ntConnection - .getLastAnnouncedValue(objectTopic) - ?.tryCast>(); - - if (objectPositionRaw == null) { - continue; - } - - bool isTrajectory = objectPositionRaw.length > 24; - - if (isTrajectory && !_showTrajectories) { - continue; - } else if (!_showOtherObjects && !isTrajectory) { - continue; - } - - List objectPosition = - objectPositionRaw.whereType().toList(); - - data.addAll(objectPosition); - } - } - - return data; - } - - @override - Stream get multiTopicPeriodicStream async* { - final Duration delayTime = Duration( - microseconds: ((subscription?.options.periodicRateSeconds ?? - preferences.getDouble(PrefKeys.defaultPeriod) ?? - Defaults.defaultPeriod) * - 1e6) - .round()); - - yield Object(); - - int previousHash = Object.hashAll(getCurrentData()); - - while (true) { - int currentHash = Object.hashAll(getCurrentData()); - - if (previousHash != currentHash) { - yield Object(); - previousHash = currentHash; - } else if (!rendered) { - yield Object(); - } - - await Future.delayed(delayTime); - } - } - - get robotWidthMeters => _robotWidthMeters; - - set robotWidthMeters(value) { - _robotWidthMeters = value; - refresh(); - } - - get robotLengthMeters => _robotLengthMeters; - - set robotLengthMeters(value) { - _robotLengthMeters = value; - refresh(); - } - - get showOtherObjects => _showOtherObjects; - - set showOtherObjects(value) { - _showOtherObjects = value; - refresh(); - } - - get showTrajectories => _showTrajectories; - - set showTrajectories(value) { - _showTrajectories = value; - refresh(); - } - - get otherObjectSize => _otherObjectSize; - - get trajectoryPointSize => _trajectoryPointSize; - - get widgetSize => _widgetSize; - - set widgetSize(value) { - _widgetSize = value; - } - - get field => _field; } class FieldWidget extends NTWidget { @@ -448,7 +464,7 @@ class FieldWidget extends NTWidget { decoration: BoxDecoration( color: Colors.black.withOpacity(0.35), border: Border.all( - color: Colors.red, + color: model.robotColor, width: 4.0, ), ), @@ -499,13 +515,18 @@ class FieldWidget extends NTWidget { Widget build(BuildContext context) { FieldWidgetModel model = cast(context.watch()); - return StreamBuilder( - stream: model.multiTopicPeriodicStream, - builder: (context, snapshot) { - List robotPositionRaw = model.ntConnection - .getLastAnnouncedValue(model._robotTopicName) - ?.tryCast>() ?? - []; + List listeners = []; + listeners.add(model.robotSubscription); + if (model._showOtherObjects || model._showTrajectories) { + listeners.addAll(model._otherObjectSubscriptions); + } + + return ListenableBuilder( + listenable: Listenable.merge(listeners), + child: model.field.fieldImage, + builder: (context, child) { + List robotPositionRaw = + model.robotSubscription.value?.tryCast>() ?? []; List? robotPosition = []; if (robotPositionRaw.isEmpty) { @@ -545,6 +566,13 @@ class FieldWidget extends NTWidget { model.rendered = true; } + // Try rebuilding again if the image isn't fully rendered + // Can't do it if it's in a unit test cause it causes issues with timers running + if (!model.rendered && + !Platform.environment.containsKey('FLUTTER_TEST')) { + Future.delayed(const Duration(milliseconds: 100), model.refresh); + } + Widget robot = _getTransformedFieldObject( model, robotPosition ?? [0.0, 0.0, 0.0], @@ -557,10 +585,10 @@ class FieldWidget extends NTWidget { List> trajectoryPoints = []; if (model.showOtherObjects || model.showTrajectories) { - for (String objectTopic in model._otherObjectTopics) { - List? objectPositionRaw = model.ntConnection - .getLastAnnouncedValue(objectTopic) - ?.tryCast>(); + for (NT4Subscription objectSubscription + in model._otherObjectSubscriptions) { + List? objectPositionRaw = + objectSubscription.value?.tryCast>(); if (objectPositionRaw == null) { continue; @@ -609,10 +637,11 @@ class FieldWidget extends NTWidget { return Stack( children: [ - model.field.fieldImage, + child!, for (List points in trajectoryPoints) CustomPaint( painter: TrajectoryPainter( + color: model.trajectoryColor, points: points, strokeWidth: model.trajectoryPointSize * model.field.pixelsPerMeterHorizontal * @@ -668,10 +697,12 @@ class TrianglePainter extends CustomPainter { class TrajectoryPainter extends CustomPainter { final List points; final double strokeWidth; + final Color color; TrajectoryPainter({ required this.points, required this.strokeWidth, + this.color = Colors.white, }); @override @@ -680,7 +711,7 @@ class TrajectoryPainter extends CustomPainter { return; } Paint trajectoryPaint = Paint() - ..color = Colors.white + ..color = color ..strokeWidth = strokeWidth ..style = PaintingStyle.stroke ..strokeCap = StrokeCap.round; diff --git a/lib/widgets/nt_widgets/multi-topic/fms_info.dart b/lib/widgets/nt_widgets/multi-topic/fms_info.dart index 52a9e076..9bc98374 100644 --- a/lib/widgets/nt_widgets/multi-topic/fms_info.dart +++ b/lib/widgets/nt_widgets/multi-topic/fms_info.dart @@ -4,9 +4,10 @@ import 'package:dot_cast/dot_cast.dart'; import 'package:patterns_canvas/patterns_canvas.dart'; import 'package:provider/provider.dart'; +import 'package:elastic_dashboard/services/nt4_client.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/nt_widget.dart'; -class FMSInfoModel extends NTWidgetModel { +class FMSInfoModel extends MultiTopicNTWidgetModel { @override String type = FMSInfo.widgetType; @@ -18,6 +19,23 @@ class FMSInfoModel extends NTWidgetModel { String get replayNumberTopic => '$topic/ReplayNumber'; String get stationNumberTopic => '$topic/StationNumber'; + late NT4Subscription eventNameSubscription; + late NT4Subscription controlDataSubscription; + late NT4Subscription allianceSubscription; + late NT4Subscription matchNumberSubscription; + late NT4Subscription matchTypeSubscription; + late NT4Subscription replayNumberSubscription; + + @override + List get subscriptions => [ + eventNameSubscription, + controlDataSubscription, + allianceSubscription, + matchNumberSubscription, + matchTypeSubscription, + replayNumberSubscription, + ]; + FMSInfoModel({ required super.ntConnection, required super.preferences, @@ -33,28 +51,18 @@ class FMSInfoModel extends NTWidgetModel { }) : super.fromJson(); @override - List getCurrentData() { - String eventName = - tryCast(ntConnection.getLastAnnouncedValue(eventNameTopic)) ?? ''; - int controlData = - tryCast(ntConnection.getLastAnnouncedValue(controlDataTopic)) ?? 32; - bool redAlliance = - tryCast(ntConnection.getLastAnnouncedValue(allianceTopic)) ?? true; - int matchNumber = - tryCast(ntConnection.getLastAnnouncedValue(matchNumberTopic)) ?? 0; - int matchType = - tryCast(ntConnection.getLastAnnouncedValue(matchTypeTopic)) ?? 0; - int replayNumber = - tryCast(ntConnection.getLastAnnouncedValue(replayNumberTopic)) ?? 0; - - return [ - eventName, - controlData, - redAlliance, - matchNumber, - matchType, - replayNumber, - ]; + void initializeSubscriptions() { + eventNameSubscription = + ntConnection.subscribe(eventNameTopic, super.period); + controlDataSubscription = + ntConnection.subscribe(controlDataTopic, super.period); + allianceSubscription = ntConnection.subscribe(allianceTopic, super.period); + matchNumberSubscription = + ntConnection.subscribe(matchNumberTopic, super.period); + matchTypeSubscription = + ntConnection.subscribe(matchTypeTopic, super.period); + replayNumberSubscription = + ntConnection.subscribe(replayNumberTopic, super.period); } } @@ -91,27 +99,15 @@ class FMSInfo extends NTWidget { Widget build(BuildContext context) { FMSInfoModel model = cast(context.watch()); - return StreamBuilder( - stream: model.multiTopicPeriodicStream, - builder: (context, snapshot) { - String eventName = tryCast(model.ntConnection - .getLastAnnouncedValue(model.eventNameTopic)) ?? - ''; - int controlData = tryCast(model.ntConnection - .getLastAnnouncedValue(model.controlDataTopic)) ?? - 32; - bool redAlliance = tryCast(model.ntConnection - .getLastAnnouncedValue(model.allianceTopic)) ?? - true; - int matchNumber = tryCast(model.ntConnection - .getLastAnnouncedValue(model.matchNumberTopic)) ?? - 0; - int matchType = tryCast(model.ntConnection - .getLastAnnouncedValue(model.matchTypeTopic)) ?? - 0; - int replayNumber = tryCast(model.ntConnection - .getLastAnnouncedValue(model.replayNumberTopic)) ?? - 0; + return ListenableBuilder( + listenable: Listenable.merge(model.subscriptions), + builder: (context, child) { + String eventName = tryCast(model.eventNameSubscription.value) ?? ''; + int controlData = tryCast(model.controlDataSubscription.value) ?? 32; + bool redAlliance = tryCast(model.allianceSubscription.value) ?? true; + int matchNumber = tryCast(model.matchNumberSubscription.value) ?? 0; + int matchType = tryCast(model.matchTypeSubscription.value) ?? 0; + int replayNumber = tryCast(model.replayNumberSubscription.value) ?? 0; String eventNameDisplay = '$eventName${(eventName != '') ? ' ' : ''}'; String matchTypeString = _getMatchTypeString(matchType); diff --git a/lib/widgets/nt_widgets/multi-topic/gyro.dart b/lib/widgets/nt_widgets/multi-topic/gyro.dart index c93ec51b..4314b68d 100644 --- a/lib/widgets/nt_widgets/multi-topic/gyro.dart +++ b/lib/widgets/nt_widgets/multi-topic/gyro.dart @@ -1,14 +1,16 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:dot_cast/dot_cast.dart'; +import 'package:geekyants_flutter_gauges/geekyants_flutter_gauges.dart'; import 'package:provider/provider.dart'; -import 'package:syncfusion_flutter_gauges/gauges.dart'; import 'package:elastic_dashboard/services/nt4_client.dart'; import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_toggle_switch.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/nt_widget.dart'; -class GyroModel extends NTWidgetModel { +class GyroModel extends MultiTopicNTWidgetModel { @override String type = Gyro.widgetType; @@ -16,6 +18,9 @@ class GyroModel extends NTWidgetModel { late NT4Subscription valueSubscription; + @override + List get subscriptions => [valueSubscription]; + bool _counterClockwisePositive = false; get counterClockwisePositive => _counterClockwisePositive; @@ -45,26 +50,8 @@ class GyroModel extends NTWidgetModel { } @override - void init() { - super.init(); - - valueSubscription = ntConnection.subscribe(valueTopic, super.period); - } - - @override - void resetSubscription() { - ntConnection.unSubscribe(valueSubscription); - + void initializeSubscriptions() { valueSubscription = ntConnection.subscribe(valueTopic, super.period); - - super.resetSubscription(); - } - - @override - void unSubscribe() { - ntConnection.unSubscribe(valueSubscription); - - super.unSubscribe(); } @override @@ -108,11 +95,10 @@ class Gyro extends NTWidget { Widget build(BuildContext context) { GyroModel model = cast(context.watch()); - return StreamBuilder( - stream: model.valueSubscription.periodicStream(yieldAll: false), - initialData: model.ntConnection.getLastAnnouncedValue(model.valueTopic), - builder: (context, snapshot) { - double value = tryCast(snapshot.data) ?? 0.0; + return ValueListenableBuilder( + valueListenable: model.valueSubscription, + builder: (context, data, child) { + double value = tryCast(data) ?? 0.0; if (model.counterClockwisePositive) { value *= -1; @@ -123,40 +109,63 @@ class Gyro extends NTWidget { 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, + child: LayoutBuilder(builder: (context, constraints) { + double squareSide = + min(constraints.maxWidth, constraints.maxHeight); + + // Formula taken from radial gauge source code + final maxNeedleHeight = squareSide / (2 * 0.65) - (2 * 7.5); + return Stack( + alignment: Alignment.center, + children: [ + RadialGauge( + radiusFactor: 0.65, + track: RadialTrack( + thickness: 7.5, + start: 0, + end: 360, + startAngle: 90, + endAngle: 90 + 360, + steps: 360 ~/ 45, + color: const Color.fromRGBO(97, 97, 97, 1), + trackStyle: TrackStyle( + primaryRulerColor: Colors.grey, + secondaryRulerColor: + const Color.fromRGBO(97, 97, 97, 1), + labelStyle: Theme.of(context).textTheme.bodySmall, + primaryRulersHeight: 7.5, + primaryRulersWidth: 2, + secondaryRulersHeight: 7.5, + rulersOffset: -18, + labelOffset: -57.5, + showLastLabel: false, + secondaryRulerPerInterval: 8, + inverseRulers: true), + trackLabelFormater: (value) => value.toStringAsFixed(0), + ), + needlePointer: [ + NeedlePointer( + needleWidth: squareSide * 0.03, + needleEndWidth: squareSide * 0.005, + needleHeight: maxNeedleHeight * 0.52 - + (squareSide - 175.875) * 0.075, + tailColor: Colors.grey, + tailRadius: squareSide * 0.1, + value: value, ), - ) - ], - axisLineStyle: const AxisLineStyle( - thickness: 5, + ], ), - axisLabelStyle: const GaugeTextStyle( - fontSize: 14, + Container( + width: squareSide * 0.07, + height: squareSide * 0.07, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.grey[300]!, + ), ), - ticksPosition: ElementsPosition.outside, - labelsPosition: ElementsPosition.outside, - showTicks: true, - minorTicksPerInterval: 8, - interval: 45, - minimum: 0, - maximum: 360, - startAngle: 270, - endAngle: 270, - ) - ], - ), + ], + ); + }), ), Text(angle.toStringAsFixed(2), style: Theme.of(context).textTheme.bodyLarge), diff --git a/lib/widgets/nt_widgets/multi-topic/motor_controller.dart b/lib/widgets/nt_widgets/multi-topic/motor_controller.dart index 91205635..4ba1b995 100644 --- a/lib/widgets/nt_widgets/multi-topic/motor_controller.dart +++ b/lib/widgets/nt_widgets/multi-topic/motor_controller.dart @@ -1,13 +1,13 @@ import 'package:flutter/material.dart'; import 'package:dot_cast/dot_cast.dart'; +import 'package:geekyants_flutter_gauges/geekyants_flutter_gauges.dart'; import 'package:provider/provider.dart'; -import 'package:syncfusion_flutter_gauges/gauges.dart'; import 'package:elastic_dashboard/services/nt4_client.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/nt_widget.dart'; -class MotorControllerModel extends NTWidgetModel { +class MotorControllerModel extends MultiTopicNTWidgetModel { @override String type = MotorController.widgetType; @@ -15,6 +15,9 @@ class MotorControllerModel extends NTWidgetModel { late NT4Subscription valueSubscription; + @override + List get subscriptions => [valueSubscription]; + MotorControllerModel({ required super.ntConnection, required super.preferences, @@ -30,27 +33,9 @@ class MotorControllerModel extends NTWidgetModel { }) : super.fromJson(); @override - void init() { - super.init(); - + void initializeSubscriptions() { valueSubscription = ntConnection.subscribe(valueTopic, super.period); } - - @override - void resetSubscription() { - ntConnection.unSubscribe(valueSubscription); - - valueSubscription = ntConnection.subscribe(valueTopic, super.period); - - super.resetSubscription(); - } - - @override - void unSubscribe() { - ntConnection.unSubscribe(valueSubscription); - - super.unSubscribe(); - } } class MotorController extends NTWidget { @@ -62,11 +47,10 @@ class MotorController extends NTWidget { Widget build(BuildContext context) { MotorControllerModel model = cast(context.watch()); - return StreamBuilder( - stream: model.valueSubscription.periodicStream(yieldAll: false), - initialData: model.ntConnection.getLastAnnouncedValue(model.valueTopic), - builder: (context, snapshot) { - double value = tryCast(snapshot.data) ?? 0.0; + return ValueListenableBuilder( + valueListenable: model.valueSubscription, + builder: (context, data, child) { + double value = tryCast(data) ?? 0.0; return Column( mainAxisAlignment: MainAxisAlignment.start, @@ -79,21 +63,32 @@ class MotorController extends NTWidget { const Flexible( child: SizedBox(height: 5), ), - SfLinearGauge( - maximum: 1.0, - minimum: -1.0, - markerPointers: [ - LinearShapePointer( + LinearGauge( + rulers: RulerStyle( + rulerPosition: RulerPosition.bottom, + primaryRulerColor: Colors.grey, + secondaryRulerColor: Colors.grey, + textStyle: Theme.of(context).textTheme.bodySmall, + ), + extendLinearGauge: 1, + linearGaugeBoxDecoration: const LinearGaugeBoxDecoration( + backgroundColor: Color.fromRGBO(87, 87, 87, 1), + thickness: 5, + ), + pointers: [ + Pointer( + enableAnimation: false, value: value.clamp(-1.0, 1.0), + shape: PointerShape.diamond, color: Theme.of(context).colorScheme.primary, width: 10.0, height: 14.0, - enableAnimation: false, - shapeType: LinearShapePointerType.diamond, - position: LinearElementPosition.cross, - ), + ) ], - interval: 0.5, + enableGaugeAnimation: false, + start: -1.0, + end: 1.0, + steps: 0.5, ), ], ); diff --git a/lib/widgets/nt_widgets/multi-topic/network_alerts.dart b/lib/widgets/nt_widgets/multi-topic/network_alerts.dart index b6501804..2298f132 100644 --- a/lib/widgets/nt_widgets/multi-topic/network_alerts.dart +++ b/lib/widgets/nt_widgets/multi-topic/network_alerts.dart @@ -3,9 +3,10 @@ import 'package:flutter/material.dart'; import 'package:dot_cast/dot_cast.dart'; import 'package:provider/provider.dart'; +import 'package:elastic_dashboard/services/nt4_client.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/nt_widget.dart'; -class NetworkAlertsModel extends NTWidgetModel { +class NetworkAlertsModel extends MultiTopicNTWidgetModel { @override String type = NetworkAlerts.widgetType; @@ -13,6 +14,17 @@ class NetworkAlertsModel extends NTWidgetModel { String get warningsTopicName => '$topic/warnings'; String get infosTopicName => '$topic/infos'; + late NT4Subscription errorsSubscription; + late NT4Subscription warningsSubscription; + late NT4Subscription infosSubscription; + + @override + List get subscriptions => [ + errorsSubscription, + warningsSubscription, + infosSubscription, + ]; + NetworkAlertsModel({ required super.ntConnection, required super.preferences, @@ -28,27 +40,11 @@ class NetworkAlertsModel extends NTWidgetModel { }) : super.fromJson(); @override - List getCurrentData() { - List errorsRaw = ntConnection - .getLastAnnouncedValue(errorsTopicName) - ?.tryCast>() ?? - []; - - List warningsRaw = ntConnection - .getLastAnnouncedValue(warningsTopicName) - ?.tryCast>() ?? - []; - - List infosRaw = ntConnection - .getLastAnnouncedValue(infosTopicName) - ?.tryCast>() ?? - []; - - List errors = errorsRaw.whereType().toList(); - List warnings = warningsRaw.whereType().toList(); - List infos = infosRaw.whereType().toList(); - - return [...errors, ...warnings, ...infos]; + void initializeSubscriptions() { + errorsSubscription = ntConnection.subscribe(errorsTopicName, super.period); + warningsSubscription = + ntConnection.subscribe(warningsTopicName, super.period); + infosSubscription = ntConnection.subscribe(infosTopicName, super.period); } } @@ -61,23 +57,17 @@ class NetworkAlerts extends NTWidget { Widget build(BuildContext context) { NetworkAlertsModel model = cast(context.watch()); - return StreamBuilder( - stream: model.multiTopicPeriodicStream, - builder: (context, snapshot) { - List errorsRaw = model.ntConnection - .getLastAnnouncedValue(model.errorsTopicName) - ?.tryCast>() ?? - []; - - List warningsRaw = model.ntConnection - .getLastAnnouncedValue(model.warningsTopicName) - ?.tryCast>() ?? - []; - - List infosRaw = model.ntConnection - .getLastAnnouncedValue(model.infosTopicName) - ?.tryCast>() ?? - []; + return ListenableBuilder( + listenable: Listenable.merge(model.subscriptions), + builder: (context, child) { + List errorsRaw = + model.errorsSubscription.value?.tryCast>() ?? []; + + List warningsRaw = + model.warningsSubscription.value?.tryCast>() ?? []; + + List infosRaw = + model.infosSubscription.value?.tryCast>() ?? []; List errors = errorsRaw.whereType().toList(); List warnings = warningsRaw.whereType().toList(); diff --git a/lib/widgets/nt_widgets/multi-topic/pid_controller.dart b/lib/widgets/nt_widgets/multi-topic/pid_controller.dart index d0b91abd..e5f18a6e 100644 --- a/lib/widgets/nt_widgets/multi-topic/pid_controller.dart +++ b/lib/widgets/nt_widgets/multi-topic/pid_controller.dart @@ -5,10 +5,9 @@ import 'package:provider/provider.dart'; import 'package:elastic_dashboard/services/nt4_client.dart'; import 'package:elastic_dashboard/services/text_formatter_builder.dart'; -import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_text_input.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/nt_widget.dart'; -class PIDControllerModel extends NTWidgetModel { +class PIDControllerModel extends MultiTopicNTWidgetModel { @override String type = PIDControllerWidget.widgetType; @@ -22,6 +21,19 @@ class PIDControllerModel extends NTWidgetModel { NT4Topic? _kdTopic; NT4Topic? _setpointTopic; + late NT4Subscription kpSubscription; + late NT4Subscription kiSubscription; + late NT4Subscription kdSubscription; + late NT4Subscription setpointSubscription; + + @override + List get subscriptions => [ + kpSubscription, + kiSubscription, + kdSubscription, + setpointSubscription, + ]; + TextEditingController? kpTextController; TextEditingController? kiTextController; TextEditingController? kdTextController; @@ -62,6 +74,15 @@ class PIDControllerModel extends NTWidgetModel { required super.jsonData, }) : super.fromJson(); + @override + void initializeSubscriptions() { + kpSubscription = ntConnection.subscribe(kpTopicName, super.period); + kiSubscription = ntConnection.subscribe(kiTopicName, super.period); + kdSubscription = ntConnection.subscribe(kdTopicName, super.period); + setpointSubscription = + ntConnection.subscribe(setpointTopicName, super.period); + } + @override void resetSubscription() { _kpTopic = null; @@ -143,30 +164,6 @@ class PIDControllerModel extends NTWidgetModel { ntConnection.updateDataFromTopic(_setpointTopic!, data); } - - @override - List getCurrentData() { - double kP = tryCast(ntConnection.getLastAnnouncedValue(kpTopicName)) ?? 0.0; - double kI = tryCast(ntConnection.getLastAnnouncedValue(kiTopicName)) ?? 0.0; - double kD = tryCast(ntConnection.getLastAnnouncedValue(kdTopicName)) ?? 0.0; - double setpoint = - tryCast(ntConnection.getLastAnnouncedValue(setpointTopicName)) ?? 0.0; - - return [ - kP, - kI, - kD, - setpoint, - _kpLastValue, - _kiLastValue, - _kdLastValue, - _setpointLastValue, - kpTextController?.text ?? '', - kiTextController?.text ?? '', - kdTextController?.text ?? '', - setpointTextController?.text ?? '', - ]; - } } class PIDControllerWidget extends NTWidget { @@ -178,29 +175,37 @@ class PIDControllerWidget extends NTWidget { Widget build(BuildContext context) { PIDControllerModel model = cast(context.watch()); - return StreamBuilder( - stream: model.multiTopicPeriodicStream, - builder: (context, snapshot) { - double kP = tryCast(model.ntConnection - .getLastAnnouncedValue(model.kpTopicName)) ?? - 0.0; - double kI = tryCast(model.ntConnection - .getLastAnnouncedValue(model.kiTopicName)) ?? - 0.0; - double kD = tryCast(model.ntConnection - .getLastAnnouncedValue(model.kdTopicName)) ?? - 0.0; - double setpoint = tryCast(model.ntConnection - .getLastAnnouncedValue(model.setpointTopicName)) ?? - 0.0; + return ListenableBuilder( + listenable: Listenable.merge([ + ...model.subscriptions, + model.kpTextController, + model.kiTextController, + model.kdTextController, + model.setpointTextController, + ]), + builder: (context, child) { + double kP = tryCast(model.kpSubscription.value) ?? 0.0; + double kI = tryCast(model.kiSubscription.value) ?? 0.0; + double kD = tryCast(model.kdSubscription.value) ?? 0.0; + double setpoint = tryCast(model.setpointSubscription.value) ?? 0.0; // Creates the text editing controllers if they are null + bool wasNull = model.kpTextController == null || + model.kiTextController == null || + model.kdTextController == null || + model.setpointTextController == null; + model.kpTextController ??= TextEditingController(text: kP.toString()); model.kiTextController ??= TextEditingController(text: kI.toString()); model.kdTextController ??= TextEditingController(text: kD.toString()); model.setpointTextController ??= TextEditingController(text: setpoint.toString()); + // Since they were null they're not being listened to when created during build + if (wasNull) { + model.refresh(); + } + // Updates the text of the text editing controller if the kp value has changed if (kP != model.kpLastValue) { model.kpTextController!.text = kP.toString(); @@ -236,6 +241,8 @@ class PIDControllerWidget extends NTWidget { kD != double.tryParse(model.kdTextController!.text) || setpoint != double.tryParse(model.setpointTextController!.text); + // The text fields can't be DialogTextInput since DialogTextInput + // manages its own state which causes setState() while build errors return Column( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ @@ -248,12 +255,19 @@ class PIDControllerWidget extends NTWidget { const Spacer(), Flexible( flex: 5, - child: DialogTextInput( - textEditingController: model.kpTextController, - initialText: model.kpTextController!.text, - label: 'kP', - formatter: TextFormatterBuilder.decimalTextFormatter(), - onSubmit: (value) {}, + child: TextField( + controller: model.kpTextController, + textAlign: TextAlign.left, + inputFormatters: [ + TextFormatterBuilder.decimalTextFormatter() + ], + decoration: InputDecoration( + contentPadding: const EdgeInsets.fromLTRB(8, 4, 8, 4), + labelText: 'kP', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(4)), + ), + onSubmitted: (value) {}, ), ), const Spacer(), @@ -268,12 +282,19 @@ class PIDControllerWidget extends NTWidget { const Spacer(), Flexible( flex: 5, - child: DialogTextInput( - textEditingController: model.kiTextController, - initialText: model.kiTextController!.text, - label: 'kI', - formatter: TextFormatterBuilder.decimalTextFormatter(), - onSubmit: (value) {}, + child: TextField( + controller: model.kiTextController, + textAlign: TextAlign.left, + inputFormatters: [ + TextFormatterBuilder.decimalTextFormatter() + ], + decoration: InputDecoration( + contentPadding: const EdgeInsets.fromLTRB(8, 4, 8, 4), + labelText: 'kI', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(4)), + ), + onSubmitted: (value) {}, ), ), const Spacer(), @@ -288,12 +309,19 @@ class PIDControllerWidget extends NTWidget { const Spacer(), Flexible( flex: 5, - child: DialogTextInput( - textEditingController: model.kdTextController, - initialText: model.kdTextController!.text, - label: 'kD', - formatter: TextFormatterBuilder.decimalTextFormatter(), - onSubmit: (value) {}, + child: TextField( + controller: model.kdTextController, + textAlign: TextAlign.left, + inputFormatters: [ + TextFormatterBuilder.decimalTextFormatter() + ], + decoration: InputDecoration( + contentPadding: const EdgeInsets.fromLTRB(8, 4, 8, 4), + labelText: 'kD', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(4)), + ), + onSubmitted: (value) {}, ), ), const Spacer(), @@ -306,13 +334,19 @@ class PIDControllerWidget extends NTWidget { const Spacer(), Flexible( flex: 5, - child: DialogTextInput( - textEditingController: model.setpointTextController, - initialText: model.setpointTextController!.text, - label: 'Setpoint', - formatter: TextFormatterBuilder.decimalTextFormatter( - allowNegative: true), - onSubmit: (value) {}, + child: TextField( + controller: model.setpointTextController, + textAlign: TextAlign.left, + inputFormatters: [ + TextFormatterBuilder.decimalTextFormatter() + ], + decoration: InputDecoration( + contentPadding: const EdgeInsets.fromLTRB(8, 4, 8, 4), + labelText: 'Setpoint', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(4)), + ), + onSubmitted: (value) {}, ), ), const Spacer(), diff --git a/lib/widgets/nt_widgets/multi-topic/power_distribution.dart b/lib/widgets/nt_widgets/multi-topic/power_distribution.dart index 0d090758..f8bb0762 100644 --- a/lib/widgets/nt_widgets/multi-topic/power_distribution.dart +++ b/lib/widgets/nt_widgets/multi-topic/power_distribution.dart @@ -3,9 +3,10 @@ import 'package:flutter/material.dart'; import 'package:dot_cast/dot_cast.dart'; import 'package:provider/provider.dart'; +import 'package:elastic_dashboard/services/nt4_client.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/nt_widget.dart'; -class PowerDistributionModel extends NTWidgetModel { +class PowerDistributionModel extends MultiTopicNTWidgetModel { @override String type = PowerDistribution.widgetType; @@ -16,6 +17,18 @@ class PowerDistributionModel extends NTWidgetModel { String get voltageTopic => '$topic/Voltage'; String get currentTopic => '$topic/TotalCurrent'; + late NT4Subscription voltageSubscription; + late NT4Subscription currentSubscription; + + final List channelSubscriptions = []; + + @override + List get subscriptions => [ + voltageSubscription, + currentSubscription, + ...channelSubscriptions, + ]; + PowerDistributionModel({ required super.ntConnection, required super.preferences, @@ -31,41 +44,20 @@ class PowerDistributionModel extends NTWidgetModel { }) : super.fromJson(); @override - void init() { - super.init(); - - for (int channel = 0; channel <= numberOfChannels; channel++) { - channelTopics.add('$topic/Chan$channel'); - } - } + void initializeSubscriptions() { + voltageSubscription = ntConnection.subscribe(voltageTopic, super.period); + currentSubscription = ntConnection.subscribe(currentTopic, super.period); - @override - void resetSubscription() { channelTopics.clear(); + channelSubscriptions.clear(); for (int channel = 0; channel <= numberOfChannels; channel++) { channelTopics.add('$topic/Chan$channel'); } - super.resetSubscription(); - } - - @override - List getCurrentData() { - List data = []; - - double voltage = - tryCast(ntConnection.getLastAnnouncedValue(voltageTopic)) ?? 0.0; - double totalCurrent = - tryCast(ntConnection.getLastAnnouncedValue(currentTopic)) ?? 0.0; - - data.addAll([voltage, totalCurrent]); - - for (String channel in channelTopics) { - data.add(tryCast(ntConnection.getLastAnnouncedValue(channel)) ?? 0.0); + for (String topic in channelTopics) { + channelSubscriptions.add(ntConnection.subscribe(topic, super.period)); } - - return data; } } @@ -79,24 +71,27 @@ class PowerDistribution extends NTWidget { List channels = []; for (int channel = start; channel <= end; channel++) { - double current = tryCast(model.ntConnection - .getLastAnnouncedValue(model.channelTopics[channel])) ?? - 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: 24.0, vertical: 4.0), - child: Text('${current.toStringAsFixed(2).padLeft(5, '0')} A', - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface)), + ValueListenableBuilder( + valueListenable: model.channelSubscriptions[channel], + builder: (context, value, child) { + double current = tryCast(value) ?? 0.0; + + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(10.0), + ), + padding: const EdgeInsets.symmetric( + horizontal: 24.0, vertical: 4.0), + child: Text('${current.toStringAsFixed(2).padLeft(5, '0')} A', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface)), + ); + }, ), const SizedBox(width: 10), Text('Ch. ${channel.toString().padRight(2)}'), @@ -117,10 +112,6 @@ class PowerDistribution extends NTWidget { List channels = []; for (int channel = start; channel >= end; channel--) { - double current = tryCast(model.ntConnection - .getLastAnnouncedValue(model.channelTopics[channel])) ?? - 0.0; - channels.add( Row( mainAxisSize: MainAxisSize.max, @@ -128,17 +119,23 @@ class PowerDistribution extends NTWidget { children: [ Text('Ch. $channel'), const SizedBox(width: 10), - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(10.0), - ), - padding: - const EdgeInsets.symmetric(horizontal: 24.0, vertical: 4.0), - child: Text('${current.toStringAsFixed(2).padLeft(5, '0')} A', - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface)), - ), + ValueListenableBuilder( + valueListenable: model.channelSubscriptions[channel], + builder: (context, value, child) { + double current = tryCast(value) ?? 0.0; + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(10.0), + ), + padding: const EdgeInsets.symmetric( + horizontal: 24.0, vertical: 4.0), + child: Text( + '${current.toStringAsFixed(2).padLeft(5, '0')} A', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface)), + ); + }), ], ), ); @@ -155,80 +152,80 @@ class PowerDistribution extends NTWidget { Widget build(BuildContext context) { PowerDistributionModel model = cast(context.watch()); - return StreamBuilder( - stream: model.multiTopicPeriodicStream, - builder: (context, snapshot) { - double voltage = tryCast( - model.ntConnection.getLastAnnouncedValue(model.voltageTopic)) ?? - 0.0; - double totalCurrent = tryCast( - model.ntConnection.getLastAnnouncedValue(model.currentTopic)) ?? - 0.0; - - return Column( + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + // Voltage + Column( children: [ - // Voltage - Column( - children: [ - const Text('Voltage'), - const SizedBox(height: 2.5), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 48.0, vertical: 4.0), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(10.0), - ), - child: Text( - '${voltage.toStringAsFixed(2).padLeft(5, '0')} V', - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, + const Text('Voltage'), + const SizedBox(height: 2.5), + ValueListenableBuilder( + valueListenable: model.voltageSubscription, + builder: (context, value, child) { + double voltage = tryCast(value) ?? 0.0; + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 48.0, vertical: 4.0), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(10.0), ), - ), - ), - ], - ), - // Current - Column( - children: [ - const Text('Total Current'), - const SizedBox(height: 2.5), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 48.0, vertical: 4.0), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(10.0), - ), - child: Text( - '${totalCurrent.toStringAsFixed(2).padLeft(5, '0')} A', - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, + child: Text( + '${voltage.toStringAsFixed(2).padLeft(5, '0')} V', + 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(model, context, 0, 11), - _getReversedChannelsColumn(model, context, 23, 12), - ], - ), + // Current + Column( + children: [ + const Text('Total Current'), + const SizedBox(height: 2.5), + ValueListenableBuilder( + valueListenable: model.currentSubscription, + builder: (context, value, child) { + double totalCurrent = tryCast(value) ?? 0.0; + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 48.0, vertical: 4.0), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(10.0), + ), + child: Text( + '${totalCurrent.toStringAsFixed(2).padLeft(5, '0')} 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(model, context, 0, 11), + _getReversedChannelsColumn(model, context, 23, 12), + ], + ), + ), + ], ); } } diff --git a/lib/widgets/nt_widgets/multi-topic/profiled_pid_controller.dart b/lib/widgets/nt_widgets/multi-topic/profiled_pid_controller.dart index bc7643fc..5fbef0c9 100644 --- a/lib/widgets/nt_widgets/multi-topic/profiled_pid_controller.dart +++ b/lib/widgets/nt_widgets/multi-topic/profiled_pid_controller.dart @@ -5,10 +5,9 @@ import 'package:provider/provider.dart'; import 'package:elastic_dashboard/services/nt4_client.dart'; import 'package:elastic_dashboard/services/text_formatter_builder.dart'; -import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_text_input.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/nt_widget.dart'; -class ProfiledPIDControllerModel extends NTWidgetModel { +class ProfiledPIDControllerModel extends MultiTopicNTWidgetModel { @override String type = ProfiledPIDControllerWidget.widgetType; @@ -22,6 +21,19 @@ class ProfiledPIDControllerModel extends NTWidgetModel { NT4Topic? _kdTopic; NT4Topic? _goalTopic; + late NT4Subscription kpSubscription; + late NT4Subscription kiSubscription; + late NT4Subscription kdSubscription; + late NT4Subscription goalSubscription; + + @override + List get subscriptions => [ + kpSubscription, + kiSubscription, + kdSubscription, + goalSubscription, + ]; + TextEditingController? kpTextController; TextEditingController? kiTextController; TextEditingController? kdTextController; @@ -62,6 +74,14 @@ class ProfiledPIDControllerModel extends NTWidgetModel { required super.jsonData, }) : super.fromJson(); + @override + void initializeSubscriptions() { + kpSubscription = ntConnection.subscribe(kpTopicName, super.period); + kiSubscription = ntConnection.subscribe(kiTopicName, super.period); + kdSubscription = ntConnection.subscribe(kdTopicName, super.period); + goalSubscription = ntConnection.subscribe(goalTopicName, super.period); + } + @override void resetSubscription() { _kpTopic = null; @@ -143,30 +163,6 @@ class ProfiledPIDControllerModel extends NTWidgetModel { ntConnection.updateDataFromTopic(_goalTopic!, data); } - - @override - List getCurrentData() { - double kP = tryCast(ntConnection.getLastAnnouncedValue(kpTopicName)) ?? 0.0; - double kI = tryCast(ntConnection.getLastAnnouncedValue(kiTopicName)) ?? 0.0; - double kD = tryCast(ntConnection.getLastAnnouncedValue(kdTopicName)) ?? 0.0; - double goal = - tryCast(ntConnection.getLastAnnouncedValue(goalTopicName)) ?? 0.0; - - return [ - kP, - kI, - kD, - goal, - _kpLastValue, - _kiLastValue, - _kdLastValue, - _goalLastValue, - kpTextController?.text ?? '', - kiTextController?.text ?? '', - kdTextController?.text ?? '', - goalTextController?.text ?? '', - ]; - } } class ProfiledPIDControllerWidget extends NTWidget { @@ -178,29 +174,37 @@ class ProfiledPIDControllerWidget extends NTWidget { Widget build(BuildContext context) { ProfiledPIDControllerModel model = cast(context.watch()); - return StreamBuilder( - stream: model.multiTopicPeriodicStream, - builder: (context, snapshot) { - double kP = tryCast(model.ntConnection - .getLastAnnouncedValue(model.kpTopicName)) ?? - 0.0; - double kI = tryCast(model.ntConnection - .getLastAnnouncedValue(model.kiTopicName)) ?? - 0.0; - double kD = tryCast(model.ntConnection - .getLastAnnouncedValue(model.kdTopicName)) ?? - 0.0; - double goal = tryCast(model.ntConnection - .getLastAnnouncedValue(model.goalTopicName)) ?? - 0.0; + return ListenableBuilder( + listenable: Listenable.merge([ + ...model.subscriptions, + model.kpTextController, + model.kiTextController, + model.kdTextController, + model.goalTextController, + ]), + builder: (context, child) { + double kP = tryCast(model.kpSubscription.value) ?? 0.0; + double kI = tryCast(model.kiSubscription.value) ?? 0.0; + double kD = tryCast(model.kdSubscription.value) ?? 0.0; + double goal = tryCast(model.goalSubscription.value) ?? 0.0; // Creates the text editing controllers if they are null + bool wasNull = model.kpTextController == null || + model.kiTextController == null || + model.kdTextController == null || + model.goalTextController == null; + model.kpTextController ??= TextEditingController(text: kP.toString()); model.kiTextController ??= TextEditingController(text: kI.toString()); model.kdTextController ??= TextEditingController(text: kD.toString()); model.goalTextController ??= TextEditingController(text: goal.toString()); + // Since they were null they're not being listened to when created during build + if (wasNull) { + model.refresh(); + } + // Updates the text of the text editing controller if the kp value has changed if (kP != model.kpLastValue) { model.kpTextController!.text = kP.toString(); @@ -236,6 +240,8 @@ class ProfiledPIDControllerWidget extends NTWidget { kD != double.tryParse(model.kdTextController!.text) || goal != double.tryParse(model.goalTextController!.text); + // The text fields can't be DialogTextInput since DialogTextInput + // manages its own state which causes setState() while build errors return Column( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ @@ -248,12 +254,19 @@ class ProfiledPIDControllerWidget extends NTWidget { const Spacer(), Flexible( flex: 5, - child: DialogTextInput( - textEditingController: model.kpTextController, - initialText: model.kpTextController!.text, - label: 'kP', - formatter: TextFormatterBuilder.decimalTextFormatter(), - onSubmit: (value) {}, + child: TextField( + controller: model.kpTextController, + textAlign: TextAlign.left, + inputFormatters: [ + TextFormatterBuilder.decimalTextFormatter() + ], + decoration: InputDecoration( + contentPadding: const EdgeInsets.fromLTRB(8, 4, 8, 4), + labelText: 'kP', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(4)), + ), + onSubmitted: (value) {}, ), ), const Spacer(), @@ -268,12 +281,19 @@ class ProfiledPIDControllerWidget extends NTWidget { const Spacer(), Flexible( flex: 5, - child: DialogTextInput( - textEditingController: model.kiTextController, - initialText: model.kiTextController!.text, - label: 'kI', - formatter: TextFormatterBuilder.decimalTextFormatter(), - onSubmit: (value) {}, + child: TextField( + controller: model.kiTextController, + textAlign: TextAlign.left, + inputFormatters: [ + TextFormatterBuilder.decimalTextFormatter() + ], + decoration: InputDecoration( + contentPadding: const EdgeInsets.fromLTRB(8, 4, 8, 4), + labelText: 'kI', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(4)), + ), + onSubmitted: (value) {}, ), ), const Spacer(), @@ -288,12 +308,19 @@ class ProfiledPIDControllerWidget extends NTWidget { const Spacer(), Flexible( flex: 5, - child: DialogTextInput( - textEditingController: model.kdTextController, - initialText: model.kdTextController!.text, - label: 'kD', - formatter: TextFormatterBuilder.decimalTextFormatter(), - onSubmit: (value) {}, + child: TextField( + controller: model.kdTextController, + textAlign: TextAlign.left, + inputFormatters: [ + TextFormatterBuilder.decimalTextFormatter() + ], + decoration: InputDecoration( + contentPadding: const EdgeInsets.fromLTRB(8, 4, 8, 4), + labelText: 'kD', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(4)), + ), + onSubmitted: (value) {}, ), ), const Spacer(), @@ -306,13 +333,19 @@ class ProfiledPIDControllerWidget extends NTWidget { const Spacer(), Flexible( flex: 5, - child: DialogTextInput( - textEditingController: model.goalTextController, - initialText: model.goalTextController!.text, - label: 'Goal', - formatter: TextFormatterBuilder.decimalTextFormatter( - allowNegative: true), - onSubmit: (value) {}, + child: TextField( + controller: model.goalTextController, + textAlign: TextAlign.left, + inputFormatters: [ + TextFormatterBuilder.decimalTextFormatter() + ], + decoration: InputDecoration( + contentPadding: const EdgeInsets.fromLTRB(8, 4, 8, 4), + labelText: 'Goal', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(4)), + ), + onSubmitted: (value) {}, ), ), const Spacer(), diff --git a/lib/widgets/nt_widgets/multi-topic/relay_widget.dart b/lib/widgets/nt_widgets/multi-topic/relay_widget.dart index 76b80172..669ee770 100644 --- a/lib/widgets/nt_widgets/multi-topic/relay_widget.dart +++ b/lib/widgets/nt_widgets/multi-topic/relay_widget.dart @@ -6,13 +6,17 @@ import 'package:provider/provider.dart'; import 'package:elastic_dashboard/services/nt4_client.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/nt_widget.dart'; -class RelayModel extends NTWidgetModel { +class RelayModel extends MultiTopicNTWidgetModel { @override String type = RelayWidget.widgetType; String get valueTopicName => '$topic/Value'; late NT4Subscription valueSubscription; + + @override + List get subscriptions => [valueSubscription]; + NT4Topic? valueTopic; final List selectedOptions = ['Off', 'On', 'Forward', 'Reverse']; @@ -32,17 +36,12 @@ class RelayModel extends NTWidgetModel { }) : super.fromJson(); @override - void init() { - super.init(); - + void initializeSubscriptions() { valueSubscription = ntConnection.subscribe(valueTopicName, super.period); } @override void resetSubscription() { - ntConnection.unSubscribe(valueSubscription); - - valueSubscription = ntConnection.subscribe(valueTopicName, super.period); valueTopic = null; super.resetSubscription(); @@ -50,10 +49,9 @@ class RelayModel extends NTWidgetModel { @override void unSubscribe() { - super.unSubscribe(); - - ntConnection.unSubscribe(valueSubscription); valueTopic = null; + + super.unSubscribe(); } } @@ -66,12 +64,10 @@ class RelayWidget extends NTWidget { Widget build(BuildContext context) { RelayModel model = cast(context.watch()); - return StreamBuilder( - stream: model.valueSubscription.periodicStream(yieldAll: false), - initialData: - model.ntConnection.getLastAnnouncedValue(model.valueTopicName), - builder: (context, snapshot) { - String selected = tryCast(snapshot.data) ?? 'Off'; + return ValueListenableBuilder( + valueListenable: model.valueSubscription, + builder: (context, data, child) { + String selected = tryCast(data) ?? 'Off'; if (!model.selectedOptions.contains(selected)) { selected = 'Off'; diff --git a/lib/widgets/nt_widgets/multi-topic/robot_preferences.dart b/lib/widgets/nt_widgets/multi-topic/robot_preferences.dart index ab8c49b4..a0d50b90 100644 --- a/lib/widgets/nt_widgets/multi-topic/robot_preferences.dart +++ b/lib/widgets/nt_widgets/multi-topic/robot_preferences.dart @@ -7,17 +7,26 @@ import 'package:searchable_listview/searchable_listview.dart'; import 'package:elastic_dashboard/services/nt4_client.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/nt_widget.dart'; -class RobotPreferencesModel extends NTWidgetModel { +class RobotPreferencesModel extends MultiTopicNTWidgetModel { @override String type = RobotPreferences.widgetType; final TextEditingController searchTextController = TextEditingController(); final List preferenceTopicNames = []; + final Map preferenceSubscriptions = {}; final Map preferenceTopics = {}; final Map preferenceTextControllers = {}; final Map previousValues = {}; + @override + List get subscriptions => + preferenceSubscriptions.values.toList(); + + late Function(NT4Topic topic) topicAnnounceListener; + + late Function(NT4Topic topic) topicUnannounceListener; + PreferenceSearch? searchWidget; RobotPreferencesModel({ @@ -33,84 +42,106 @@ class RobotPreferencesModel extends NTWidgetModel { required super.preferences, required super.jsonData, }) : super.fromJson(); -} -class RobotPreferences extends NTWidget { - static const String widgetType = 'RobotPreferences'; + @override + void init() { + topicAnnounceListener = (topic) { + if (!topic.name.contains(this.topic) || + preferenceTopicNames.contains(topic.name) || + topic.name.contains('.type')) { + return; + } - const RobotPreferences({super.key}) : super(); + Object? previousValue = ntConnection.getLastAnnouncedValue(topic.name); - @override - Widget build(BuildContext context) { - RobotPreferencesModel model = cast(context.watch()); + preferenceTopicNames.add(topic.name); + preferenceTopics.addAll({topic.name: topic}); + preferenceSubscriptions.addAll({ + topic.name: ntConnection.subscribe(topic.name, super.period), + }); + preferenceTextControllers.addAll({ + topic.name: TextEditingController() + ..text = previousValue?.toString() ?? '' + }); + previousValues.addAll({topic.name: previousValue}); - return StreamBuilder( - stream: model.subscription?.periodicStream(), - builder: (context, snapshot) { - bool rebuildWidget = model.searchWidget == null; + notifyListeners(); + }; - for (NT4Topic nt4Topic in model.ntConnection.announcedTopics().values) { - if (!nt4Topic.name.contains(model.topic) || - model.preferenceTopicNames.contains(nt4Topic.name) || - nt4Topic.name.contains('.type')) { - continue; - } + topicUnannounceListener = (topic) { + if (!preferenceTopicNames.contains(topic.name)) { + return; + } - Object? previousValue = - model.ntConnection.getLastAnnouncedValue(nt4Topic.name); + preferenceTopicNames.remove(topic.name); - model.preferenceTopicNames.add(nt4Topic.name); - model.preferenceTopics.addAll({nt4Topic.name: nt4Topic}); - model.preferenceTextControllers.addAll({ - nt4Topic.name: TextEditingController() - ..text = previousValue?.toString() ?? '' - }); - model.previousValues.addAll({nt4Topic.name: previousValue}); + preferenceTopics.remove(topic.name); - rebuildWidget = true; - } + if (preferenceSubscriptions.containsKey(topic.name)) { + ntConnection.unSubscribe(preferenceSubscriptions[topic.name]!); + } + preferenceSubscriptions.remove(topic.name); - Iterable announcedTopics = - model.ntConnection.announcedTopics().values.map( - (e) => e.name, - ); + preferenceTextControllers.remove(topic.name); - for (String topic in model.preferenceTopicNames) { - if (!announcedTopics.contains(topic)) { - model.preferenceTopics.remove(topic); + previousValues.remove(topic.name); - model.preferenceTextControllers.remove(topic); + notifyListeners(); + }; - model.previousValues.remove(topic); + ntConnection.addTopicAnnounceListener(topicAnnounceListener); + ntConnection.addTopicUnannounceListener(topicUnannounceListener); - rebuildWidget = true; + super.init(); + } - continue; - } + @override + void resetSubscription() { + for (NT4Subscription subscription in preferenceSubscriptions.values) { + ntConnection.unSubscribe(subscription); + } + preferenceSubscriptions.clear(); + + // Trigger the topics to get recalled to the listener and added to the preferences list + ntConnection.removeTopicAnnounceListener(topicAnnounceListener); + ntConnection.addTopicAnnounceListener(topicAnnounceListener); + } +} - if (model.ntConnection.getLastAnnouncedValue(topic).toString() != - model.previousValues[topic].toString()) { - model.preferenceTextControllers[topic]?.text = - model.ntConnection.getLastAnnouncedValue(topic).toString(); +class RobotPreferences extends NTWidget { + static const String widgetType = 'RobotPreferences'; - model.previousValues[topic] = - model.ntConnection.getLastAnnouncedValue(topic); - } - } + const RobotPreferences({super.key}) : super(); + + @override + Widget build(BuildContext context) { + RobotPreferencesModel model = cast(context.watch()); - model.preferenceTopicNames - .removeWhere((element) => !announcedTopics.contains(element)); + return ListenableBuilder( + listenable: Listenable.merge(model.subscriptions), + builder: (context, child) { + for (String topic in model.preferenceTopicNames) { + if (model.preferenceSubscriptions[topic]?.value.toString() != + model.previousValues[topic].toString()) { + model.preferenceTextControllers[topic]?.text = + model.preferenceSubscriptions[topic]?.value?.toString() ?? ''; + + model.previousValues[topic] = + model.preferenceSubscriptions[topic]?.value; + } + } - if (rebuildWidget) { - model.searchWidget = PreferenceSearch( + return PreferenceSearch( onSubmit: (String topic, String? data) { - if (data == null) { + NT4Topic? nt4Topic = model.preferenceTopics[topic]; + + if (nt4Topic == null || + !model.ntConnection.isTopicPublished(nt4Topic)) { return; } - NT4Topic? nt4Topic = model.preferenceTopics[topic]; - - if (nt4Topic == null) { + if (data == null) { + model.ntConnection.unpublishTopic(nt4Topic); return; } @@ -138,44 +169,31 @@ class RobotPreferences extends NTWidget { if (formattedData == null) { model.preferenceTextControllers[topic]?.text = model.previousValues[topic].toString(); + model.ntConnection.unpublishTopic(nt4Topic); return; } - model.ntConnection.publishTopic(nt4Topic); model.ntConnection.updateDataFromTopic(nt4Topic, formattedData); model.ntConnection.unpublishTopic(nt4Topic); model.preferenceTextControllers[topic]?.text = formattedData.toString(); }, - searchTextController: model.searchTextController, - preferenceTopicNames: model.preferenceTopicNames, - preferenceTextControllers: model.preferenceTextControllers, - preferenceTopics: model.preferenceTopics, + model: model, ); - } - - return model.searchWidget!; - }, - ); + }); } } class PreferenceSearch extends StatelessWidget { const PreferenceSearch({ super.key, + required this.model, required this.onSubmit, - required this.searchTextController, - required this.preferenceTopicNames, - required this.preferenceTextControllers, - required this.preferenceTopics, }); + final RobotPreferencesModel model; final Function(String topic, String? data) onSubmit; - final TextEditingController searchTextController; - final List preferenceTopicNames; - final Map preferenceTextControllers; - final Map preferenceTopics; @override Widget build(BuildContext context) { @@ -191,11 +209,11 @@ class PreferenceSearch extends StatelessWidget { borderRadius: BorderRadius.circular(8), ), ), - searchTextController: searchTextController, + searchTextController: model.searchTextController, seperatorBuilder: (context, _) => const Divider(height: 4.0), spaceBetweenSearchAndList: 15, filter: (query) { - return preferenceTopicNames + return model.preferenceTopicNames .where((element) => element .split('/') .last @@ -203,13 +221,23 @@ class PreferenceSearch extends StatelessWidget { .contains(query.toLowerCase())) .toList(); }, - initialList: preferenceTopicNames, + initialList: model.preferenceTopicNames, itemBuilder: (item) { - TextEditingController? textController = preferenceTextControllers[item]; + TextEditingController? textController = + model.preferenceTextControllers[item]; return _RobotPreference( label: item.split('/').last, textController: textController ?? TextEditingController(), + onFocusGained: () { + NT4Topic? nt4Topic = model.preferenceTopics[item]; + + if (nt4Topic == null) { + return; + } + + model.ntConnection.publishTopic(nt4Topic); + }, onSubmit: (data) { onSubmit.call(item, data); }, @@ -221,11 +249,13 @@ class PreferenceSearch extends StatelessWidget { class _RobotPreference extends StatelessWidget { final TextEditingController textController; + final Function() onFocusGained; final Function(String? data) onSubmit; final String label; const _RobotPreference({ required this.textController, + required this.onFocusGained, required this.onSubmit, required this.label, }); @@ -238,6 +268,7 @@ class _RobotPreference extends StatelessWidget { onFocusChange: (value) { // Don't consider the text submitted when focus is gained if (value) { + onFocusGained.call(); return; } String textValue = textController.text; diff --git a/lib/widgets/nt_widgets/multi-topic/split_button_chooser.dart b/lib/widgets/nt_widgets/multi-topic/split_button_chooser.dart index 229ab3a0..ee8f2bf7 100644 --- a/lib/widgets/nt_widgets/multi-topic/split_button_chooser.dart +++ b/lib/widgets/nt_widgets/multi-topic/split_button_chooser.dart @@ -7,7 +7,7 @@ import 'package:elastic_dashboard/services/nt4_client.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/multi-topic/combo_box_chooser.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/nt_widget.dart'; -class SplitButtonChooserModel extends NTWidgetModel { +class SplitButtonChooserModel extends MultiTopicNTWidgetModel { @override String type = SplitButtonChooser.widgetType; @@ -16,12 +16,24 @@ class SplitButtonChooserModel extends NTWidgetModel { String get activeTopicName => '$topic/active'; String get defaultTopicName => '$topic/default'; + late NT4Subscription optionsSubscription; + late NT4Subscription selectedSubscription; + late NT4Subscription activeSubscription; + late NT4Subscription defaultSubscription; + + @override + List get subscriptions => [ + optionsSubscription, + selectedSubscription, + activeSubscription, + defaultSubscription, + ]; + String? selectedChoice; StringChooserData? previousData; NT4Topic? _selectedTopic; - NT4Topic? _activeTopic; SplitButtonChooserModel({ required super.ntConnection, @@ -37,6 +49,17 @@ class SplitButtonChooserModel extends NTWidgetModel { required super.jsonData, }) : super.fromJson(); + @override + void initializeSubscriptions() { + optionsSubscription = + ntConnection.subscribe(optionsTopicName, super.period); + selectedSubscription = + ntConnection.subscribe(selectedTopicName, super.period); + activeSubscription = ntConnection.subscribe(activeTopicName, super.period); + defaultSubscription = + ntConnection.subscribe(defaultTopicName, super.period); + } + @override void resetSubscription() { _selectedTopic = null; @@ -54,47 +77,6 @@ class SplitButtonChooserModel extends NTWidgetModel { ntConnection.updateDataFromTopic(_selectedTopic!, selected); } - - void _publishActiveValue(String? active) { - if (active == null || !ntConnection.isNT4Connected) { - return; - } - - bool publishTopic = _activeTopic == null; - - _activeTopic ??= ntConnection.getTopicFromName(activeTopicName); - - if (_activeTopic == null) { - return; - } - - if (publishTopic) { - ntConnection.publishTopic(_activeTopic!); - } - - ntConnection.updateDataFromTopic(_activeTopic!, active); - } - - @override - List getCurrentData() { - List rawOptions = ntConnection - .getLastAnnouncedValue(optionsTopicName) - ?.tryCast>() ?? - []; - - List options = rawOptions.whereType().toList(); - - String active = - tryCast(ntConnection.getLastAnnouncedValue(activeTopicName)) ?? ''; - - String selected = - tryCast(ntConnection.getLastAnnouncedValue(selectedTopicName)) ?? ''; - - String defaultOption = - tryCast(ntConnection.getLastAnnouncedValue(defaultTopicName)) ?? ''; - - return [...options, active, selected, defaultOption]; - } } class SplitButtonChooser extends NTWidget { @@ -106,30 +88,25 @@ class SplitButtonChooser extends NTWidget { Widget build(BuildContext context) { SplitButtonChooserModel model = cast(context.watch()); - return StreamBuilder( - stream: model.multiTopicPeriodicStream, - builder: (context, snapshot) { - List rawOptions = model.ntConnection - .getLastAnnouncedValue(model.optionsTopicName) - ?.tryCast>() ?? - []; + return ListenableBuilder( + listenable: Listenable.merge(model.subscriptions), + builder: (context, child) { + List rawOptions = + model.optionsSubscription.value?.tryCast>() ?? []; List options = rawOptions.whereType().toList(); - String? active = tryCast( - model.ntConnection.getLastAnnouncedValue(model.activeTopicName)); + String? active = tryCast(model.activeSubscription.value); if (active != null && active == '') { active = null; } - String? selected = tryCast( - model.ntConnection.getLastAnnouncedValue(model.selectedTopicName)); + String? selected = tryCast(model.selectedSubscription.value); if (selected != null && selected == '') { selected = null; } - String? defaultOption = tryCast( - model.ntConnection.getLastAnnouncedValue(model.defaultTopicName)); + String? defaultOption = tryCast(model.defaultSubscription.value); if (defaultOption != null && defaultOption == '') { defaultOption = null; } diff --git a/lib/widgets/nt_widgets/multi-topic/subsystem_widget.dart b/lib/widgets/nt_widgets/multi-topic/subsystem_widget.dart index ba6cca45..ddf57f2c 100644 --- a/lib/widgets/nt_widgets/multi-topic/subsystem_widget.dart +++ b/lib/widgets/nt_widgets/multi-topic/subsystem_widget.dart @@ -3,15 +3,25 @@ import 'package:flutter/material.dart'; import 'package:dot_cast/dot_cast.dart'; import 'package:provider/provider.dart'; +import 'package:elastic_dashboard/services/nt4_client.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/nt_widget.dart'; -class SubsystemModel extends NTWidgetModel { +class SubsystemModel extends MultiTopicNTWidgetModel { @override String type = SubsystemWidget.widgetType; String get defaultCommandTopic => '$topic/.default'; String get currentCommandTopic => '$topic/.command'; + late NT4Subscription defaultCommandSubscription; + late NT4Subscription currentCommandSubscription; + + @override + List get subscriptions => [ + defaultCommandSubscription, + currentCommandSubscription, + ]; + SubsystemModel({ required super.ntConnection, required super.preferences, @@ -27,15 +37,11 @@ class SubsystemModel extends NTWidgetModel { }) : super.fromJson(); @override - List getCurrentData() { - String defaultCommand = - tryCast(ntConnection.getLastAnnouncedValue(defaultCommandTopic)) ?? - 'none'; - String currentCommand = - tryCast(ntConnection.getLastAnnouncedValue(currentCommandTopic)) ?? - 'none'; - - return [defaultCommand, currentCommand]; + void initializeSubscriptions() { + defaultCommandSubscription = + ntConnection.subscribe(defaultCommandTopic, super.period); + currentCommandSubscription = + ntConnection.subscribe(currentCommandTopic, super.period); } } @@ -48,27 +54,29 @@ class SubsystemWidget extends NTWidget { Widget build(BuildContext context) { SubsystemModel model = cast(context.watch()); - return StreamBuilder( - stream: model.multiTopicPeriodicStream, - builder: (context, snapshot) { - String defaultCommand = tryCast(model.ntConnection - .getLastAnnouncedValue(model.defaultCommandTopic)) ?? - 'none'; - String currentCommand = tryCast(model.ntConnection - .getLastAnnouncedValue(model.currentCommandTopic)) ?? - 'none'; + return Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + ValueListenableBuilder( + valueListenable: model.defaultCommandSubscription, + builder: (context, value, child) { + String defaultCommand = tryCast(value) ?? 'none'; + + return Text('Default Command: $defaultCommand', + overflow: TextOverflow.ellipsis); + }, + ), + const SizedBox(height: 5), + ValueListenableBuilder( + valueListenable: model.currentCommandSubscription, + builder: (context, value, child) { + String currentCommand = tryCast(value) ?? 'none'; - return Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Text('Default Command: $defaultCommand', - overflow: TextOverflow.ellipsis), - const SizedBox(height: 5), - Text('Current Command: $currentCommand', - overflow: TextOverflow.ellipsis), - ], - ); - }, + return Text('Current Command: $currentCommand', + overflow: TextOverflow.ellipsis); + }, + ), + ], ); } } diff --git a/lib/widgets/nt_widgets/multi-topic/three_axis_accelerometer.dart b/lib/widgets/nt_widgets/multi-topic/three_axis_accelerometer.dart index 27e60076..6e929309 100644 --- a/lib/widgets/nt_widgets/multi-topic/three_axis_accelerometer.dart +++ b/lib/widgets/nt_widgets/multi-topic/three_axis_accelerometer.dart @@ -3,9 +3,10 @@ import 'package:flutter/material.dart'; import 'package:dot_cast/dot_cast.dart'; import 'package:provider/provider.dart'; +import 'package:elastic_dashboard/services/nt4_client.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/nt_widget.dart'; -class ThreeAxisAccelerometerModel extends NTWidgetModel { +class ThreeAxisAccelerometerModel extends MultiTopicNTWidgetModel { @override String type = ThreeAxisAccelerometer.widgetType; @@ -13,6 +14,17 @@ class ThreeAxisAccelerometerModel extends NTWidgetModel { String get yTopic => '$topic/Y'; String get zTopic => '$topic/Z'; + late NT4Subscription xSubscription; + late NT4Subscription ySubscription; + late NT4Subscription zSubscription; + + @override + List get subscriptions => [ + xSubscription, + ySubscription, + zSubscription, + ]; + ThreeAxisAccelerometerModel({ required super.ntConnection, required super.preferences, @@ -28,12 +40,10 @@ class ThreeAxisAccelerometerModel extends NTWidgetModel { }) : super.fromJson(); @override - List getCurrentData() { - double xAccel = tryCast(ntConnection.getLastAnnouncedValue(xTopic)) ?? 0.0; - double yAccel = tryCast(ntConnection.getLastAnnouncedValue(yTopic)) ?? 0.0; - double zAccel = tryCast(ntConnection.getLastAnnouncedValue(zTopic)) ?? 0.0; - - return [xAccel, yAccel, zAccel]; + void initializeSubscriptions() { + xSubscription = ntConnection.subscribe(xTopic, super.period); + ySubscription = ntConnection.subscribe(yTopic, super.period); + zSubscription = ntConnection.subscribe(zTopic, super.period); } } @@ -46,122 +56,122 @@ class ThreeAxisAccelerometer extends NTWidget { Widget build(BuildContext context) { ThreeAxisAccelerometerModel model = cast(context.watch()); - return StreamBuilder( - stream: model.multiTopicPeriodicStream, - builder: (context, snapshot) { - double xAccel = - tryCast(model.ntConnection.getLastAnnouncedValue(model.xTopic)) ?? - 0.0; - double yAccel = - tryCast(model.ntConnection.getLastAnnouncedValue(model.yTopic)) ?? - 0.0; - double zAccel = - tryCast(model.ntConnection.getLastAnnouncedValue(model.zTopic)) ?? - 0.0; - - return Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - mainAxisSize: MainAxisSize.max, - children: [ - // X Acceleration - Flexible( - flex: 16, - child: Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('X'), - const SizedBox(width: 10), - Expanded( - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(5.0), - ), - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Text( - '${xAccel.toStringAsFixed(2)} g', - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - overflow: TextOverflow.ellipsis, - fontSize: 12.0, - ), - textAlign: TextAlign.center, - ), - ), + return Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + mainAxisSize: MainAxisSize.max, + children: [ + // X Acceleration + Flexible( + flex: 16, + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('X'), + const SizedBox(width: 10), + Expanded( + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(5.0), ), - ], + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: ValueListenableBuilder( + valueListenable: model.xSubscription, + builder: (context, value, child) { + double xAccel = tryCast(value) ?? 0.0; + return Text( + '${xAccel.toStringAsFixed(2)} g', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + overflow: TextOverflow.ellipsis, + fontSize: 12.0, + ), + textAlign: TextAlign.center, + ); + }), + ), ), - ), - const Flexible( - child: SizedBox(height: 4.0), - ), - // Y Acceleration - Flexible( - flex: 16, - child: Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('Y'), - const SizedBox(width: 10), - Expanded( - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(5.0), - ), - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Text( - '${yAccel.toStringAsFixed(2)} g', - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - overflow: TextOverflow.ellipsis, - fontSize: 12.0, - ), - textAlign: TextAlign.center, - ), - ), + ], + ), + ), + const Flexible( + child: SizedBox(height: 4.0), + ), + // Y Acceleration + Flexible( + flex: 16, + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('Y'), + const SizedBox(width: 10), + Expanded( + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(5.0), ), - ], + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: ValueListenableBuilder( + valueListenable: model.ySubscription, + builder: (context, value, child) { + double yAccel = tryCast(value) ?? 0.0; + return Text( + '${yAccel.toStringAsFixed(2)} g', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + overflow: TextOverflow.ellipsis, + fontSize: 12.0, + ), + textAlign: TextAlign.center, + ); + }), + ), ), - ), - const Flexible( - child: SizedBox(height: 4.0), - ), - // Z Acceleration - Flexible( - flex: 16, - child: Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('Z'), - const SizedBox(width: 10), - Expanded( - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(5.0), - ), - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Text( - '${zAccel.toStringAsFixed(2)} g', - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - overflow: TextOverflow.ellipsis, - fontSize: 12.0, - ), - textAlign: TextAlign.center, - ), - ), + ], + ), + ), + const Flexible( + child: SizedBox(height: 4.0), + ), + // Z Acceleration + Flexible( + flex: 16, + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('Z'), + const SizedBox(width: 10), + Expanded( + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(5.0), ), - ], + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: ValueListenableBuilder( + valueListenable: model.zSubscription, + builder: (context, value, child) { + double zAccel = tryCast(value) ?? 0.0; + return Text( + '${zAccel.toStringAsFixed(2)} g', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + overflow: TextOverflow.ellipsis, + fontSize: 12.0, + ), + textAlign: TextAlign.center, + ); + }), + ), ), - ), - ], - ); - }, + ], + ), + ), + ], ); } } diff --git a/lib/widgets/nt_widgets/multi-topic/ultrasonic.dart b/lib/widgets/nt_widgets/multi-topic/ultrasonic.dart index 70bfc321..c736b8d1 100644 --- a/lib/widgets/nt_widgets/multi-topic/ultrasonic.dart +++ b/lib/widgets/nt_widgets/multi-topic/ultrasonic.dart @@ -6,7 +6,7 @@ import 'package:provider/provider.dart'; import 'package:elastic_dashboard/services/nt4_client.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/nt_widget.dart'; -class UltrasonicModel extends NTWidgetModel { +class UltrasonicModel extends MultiTopicNTWidgetModel { @override String type = Ultrasonic.widgetType; @@ -14,6 +14,9 @@ class UltrasonicModel extends NTWidgetModel { late NT4Subscription valueSubscription; + @override + List get subscriptions => [valueSubscription]; + UltrasonicModel({ required super.ntConnection, required super.preferences, @@ -29,27 +32,9 @@ class UltrasonicModel extends NTWidgetModel { }) : super.fromJson(); @override - void init() { - super.init(); - + void initializeSubscriptions() { valueSubscription = ntConnection.subscribe(valueTopic, super.period); } - - @override - void resetSubscription() { - ntConnection.unSubscribe(valueSubscription); - - valueSubscription = ntConnection.subscribe(valueTopic, super.period); - - super.resetSubscription(); - } - - @override - void unSubscribe() { - ntConnection.unSubscribe(valueSubscription); - - super.unSubscribe(); - } } class Ultrasonic extends NTWidget { @@ -61,39 +46,37 @@ class Ultrasonic extends NTWidget { Widget build(BuildContext context) { UltrasonicModel model = cast(context.watch()); - return StreamBuilder( - stream: model.valueSubscription.periodicStream(yieldAll: false), - initialData: model.ntConnection.getLastAnnouncedValue(model.valueTopic), - builder: (context, snapshot) { - double value = tryCast(snapshot.data) ?? 0.0; - - return Row( - children: [ - const Text('Range'), - const SizedBox(width: 10), - Expanded( - child: Container( - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: Colors.grey.shade700, - width: 1.5, - ), - ), + return Row( + children: [ + const Text('Range'), + const SizedBox(width: 10), + Expanded( + child: Container( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Colors.grey.shade700, + width: 1.5, ), - child: SelectableText( + ), + ), + child: ValueListenableBuilder( + valueListenable: model.valueSubscription, + builder: (context, data, child) { + double value = tryCast(data) ?? 0.0; + return SelectableText( '${value.toStringAsPrecision(5)} in', maxLines: 1, showCursor: true, style: Theme.of(context).textTheme.bodyLarge?.copyWith( overflow: TextOverflow.ellipsis, ), - ), - ), + ); + }, ), - ], - ); - }, + ), + ), + ], ); } } diff --git a/lib/widgets/nt_widgets/multi-topic/yagsl_swerve_drive.dart b/lib/widgets/nt_widgets/multi-topic/yagsl_swerve_drive.dart index 29b436a4..04c4185f 100644 --- a/lib/widgets/nt_widgets/multi-topic/yagsl_swerve_drive.dart +++ b/lib/widgets/nt_widgets/multi-topic/yagsl_swerve_drive.dart @@ -6,10 +6,13 @@ import 'package:dot_cast/dot_cast.dart'; import 'package:provider/provider.dart'; import 'package:vector_math/vector_math_64.dart' show radians; +import 'package:elastic_dashboard/services/nt4_client.dart'; +import 'package:elastic_dashboard/services/text_formatter_builder.dart'; +import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_text_input.dart'; import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_toggle_switch.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/nt_widget.dart'; -class YAGSLSwerveDriveModel extends NTWidgetModel { +class YAGSLSwerveDriveModel extends MultiTopicNTWidgetModel { @override String type = YAGSLSwerveDrive.widgetType; @@ -21,33 +24,63 @@ class YAGSLSwerveDriveModel extends NTWidgetModel { String get robotLengthTopic => '$topic/sizeFrontBack'; String get rotationUnitTopic => '$topic/rotationUnit'; + late NT4Subscription measuredStatesSubscription; + late NT4Subscription desiredStatesSubscription; + late NT4Subscription robotRotationSubscription; + late NT4Subscription maxSpeedSubscription; + late NT4Subscription robotWidthSubscription; + late NT4Subscription robotLengthSubscription; + late NT4Subscription rotationUnitSubscription; + + @override + List get subscriptions => [ + measuredStatesSubscription, + desiredStatesSubscription, + robotRotationSubscription, + maxSpeedSubscription, + robotWidthSubscription, + robotLengthSubscription, + rotationUnitSubscription, + ]; + bool _showRobotRotation = true; bool _showDesiredStates = true; + double _angleOffset = + 0; // Modifiable angle offset to allow all kinds of swerve libraries setups - get showRobotRotation => _showRobotRotation; + bool get showRobotRotation => _showRobotRotation; set showRobotRotation(value) { _showRobotRotation = value; refresh(); } - get showDesiredStates => _showDesiredStates; + bool get showDesiredStates => _showDesiredStates; set showDesiredStates(value) { _showDesiredStates = value; refresh(); } + double get angleOffset => _angleOffset; + + set angleOffset(double value) { + _angleOffset = value; + refresh(); + } + YAGSLSwerveDriveModel({ required super.ntConnection, required super.preferences, required super.topic, bool showRobotRotation = true, bool showDesiredStates = true, + double angleOffset = 0.0, super.dataType, super.period, }) : _showDesiredStates = showDesiredStates, _showRobotRotation = showRobotRotation, + _angleOffset = angleOffset, super(); YAGSLSwerveDriveModel.fromJson({ @@ -57,6 +90,24 @@ class YAGSLSwerveDriveModel extends NTWidgetModel { }) : super.fromJson(jsonData: jsonData) { _showRobotRotation = tryCast(jsonData['show_robot_rotation']) ?? true; _showDesiredStates = tryCast(jsonData['show_desired_states']) ?? true; + _angleOffset = tryCast(jsonData['angle_offset']) ?? 0.0; + } + + @override + void initializeSubscriptions() { + measuredStatesSubscription = + ntConnection.subscribe(measuredStatesTopic, super.period); + desiredStatesSubscription = + ntConnection.subscribe(desiredStatesTopic, super.period); + robotRotationSubscription = + ntConnection.subscribe(robotRotationTopic, super.period); + maxSpeedSubscription = ntConnection.subscribe(maxSpeedTopic, super.period); + robotWidthSubscription = + ntConnection.subscribe(robotWidthTopic, super.period); + robotLengthSubscription = + ntConnection.subscribe(robotLengthTopic, super.period); + rotationUnitSubscription = + ntConnection.subscribe(rotationUnitTopic, super.period); } @override @@ -65,6 +116,7 @@ class YAGSLSwerveDriveModel extends NTWidgetModel { ...super.toJson(), 'show_robot_rotation': _showRobotRotation, 'show_desired_states': _showDesiredStates, + 'angle_offset': _angleOffset, }; } @@ -93,43 +145,26 @@ class YAGSLSwerveDriveModel extends NTWidgetModel { ), ], ), - ]; - } - - @override - List getCurrentData() { - List measuredStatesRaw = - tryCast(ntConnection.getLastAnnouncedValue(measuredStatesTopic)) ?? []; - List desiredStatesRaw = - tryCast(ntConnection.getLastAnnouncedValue(desiredStatesTopic)) ?? []; - - List measuredStates = - measuredStatesRaw.whereType().toList(); - List desiredStates = desiredStatesRaw.whereType().toList(); - - double width = - tryCast(ntConnection.getLastAnnouncedValue(robotWidthTopic)) ?? 1.0; - double length = - tryCast(ntConnection.getLastAnnouncedValue(robotLengthTopic)) ?? width; - - String rotationUnit = - tryCast(ntConnection.getLastAnnouncedValue(rotationUnitTopic)) ?? - 'radians'; - - double robotAngle = - tryCast(ntConnection.getLastAnnouncedValue(robotRotationTopic)) ?? 0.0; - - double maxSpeed = - tryCast(ntConnection.getLastAnnouncedValue(maxSpeedTopic)) ?? 4.5; - - return [ - ...measuredStates, - ...desiredStates, - width, - length, - rotationUnit, - robotAngle, - maxSpeed, + const SizedBox(height: 5), + Row( + children: [ + Flexible( + child: DialogTextInput( + initialText: angleOffset.toString(), + label: 'Angle Offset (degrees)', + onSubmit: (String value) { + double? doubleValue = double.tryParse(value); + + if (doubleValue != null) { + angleOffset = doubleValue; + } + }, + formatter: TextFormatterBuilder.decimalTextFormatter( + allowNegative: true), + ), + ), + ], + ), ]; } } @@ -143,27 +178,21 @@ class YAGSLSwerveDrive extends NTWidget { Widget build(BuildContext context) { YAGSLSwerveDriveModel model = cast(context.watch()); - return StreamBuilder( - stream: model.multiTopicPeriodicStream, + return ListenableBuilder( + listenable: Listenable.merge(model.subscriptions), builder: (context, snapshot) { - List measuredStatesRaw = tryCast(model.ntConnection - .getLastAnnouncedValue(model.measuredStatesTopic)) ?? - []; - List desiredStatesRaw = tryCast(model.ntConnection - .getLastAnnouncedValue(model.desiredStatesTopic)) ?? - []; + List measuredStatesRaw = + tryCast(model.measuredStatesSubscription.value) ?? []; + List desiredStatesRaw = + tryCast(model.desiredStatesSubscription.value) ?? []; List measuredStates = measuredStatesRaw.whereType().toList(); List desiredStates = desiredStatesRaw.whereType().toList(); - double width = tryCast(model.ntConnection - .getLastAnnouncedValue(model.robotWidthTopic)) ?? - 1.0; - double length = tryCast(model.ntConnection - .getLastAnnouncedValue(model.robotLengthTopic)) ?? - width; + double width = tryCast(model.robotWidthSubscription.value) ?? 1.0; + double length = tryCast(model.robotLengthSubscription.value) ?? width; if (width <= 0.0) { width = 1.0; @@ -175,13 +204,11 @@ class YAGSLSwerveDrive extends NTWidget { double sizeRatio = min(length, width) / max(length, width); double lengthWidthRatio = length / width; - String rotationUnit = tryCast(model.ntConnection - .getLastAnnouncedValue(model.rotationUnitTopic)) ?? - 'radians'; + String rotationUnit = + tryCast(model.rotationUnitSubscription.value) ?? 'radians'; - double robotAngle = tryCast(model.ntConnection - .getLastAnnouncedValue(model.robotRotationTopic)) ?? - 0.0; + double robotAngle = + tryCast(model.robotRotationSubscription.value) ?? 0.0; if (rotationUnit == 'degrees') { robotAngle = radians(robotAngle); @@ -189,9 +216,9 @@ class YAGSLSwerveDrive extends NTWidget { robotAngle *= 2 * pi; } - double maxSpeed = tryCast(model.ntConnection - .getLastAnnouncedValue(model.maxSpeedTopic)) ?? - 4.5; + robotAngle -= radians(model.angleOffset); + + double maxSpeed = tryCast(model.maxSpeedSubscription.value) ?? 4.5; if (maxSpeed <= 0.0) { maxSpeed = 4.5; diff --git a/lib/widgets/nt_widgets/nt_widget.dart b/lib/widgets/nt_widgets/nt_widget.dart index 8e814320..aceefc50 100644 --- a/lib/widgets/nt_widgets/nt_widget.dart +++ b/lib/widgets/nt_widgets/nt_widget.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:collection/collection.dart'; import 'package:dot_cast/dot_cast.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -20,15 +19,16 @@ import 'package:elastic_dashboard/widgets/nt_widgets/single_topic/toggle_button. import 'package:elastic_dashboard/widgets/nt_widgets/single_topic/toggle_switch.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/single_topic/voltage_view.dart'; -class NTWidgetModel extends ChangeNotifier { +abstract class NTWidgetModel extends ChangeNotifier { + String get type; + final NTConnection ntConnection; final SharedPreferences preferences; - String _typeOverride = 'NTWidget'; - String get type => _typeOverride; + late double _period; late String _topic; - late double _period; + get topic => _topic; set topic(value) => _topic = value; @@ -37,11 +37,6 @@ class NTWidgetModel extends ChangeNotifier { set period(value) => _period = value; - String dataType = 'Unknown'; - - NT4Subscription? subscription; - NT4Topic? ntTopic; - bool _disposed = false; bool _forceDispose = false; @@ -49,7 +44,6 @@ class NTWidgetModel extends ChangeNotifier { required this.ntConnection, required this.preferences, required String topic, - this.dataType = 'Unknown', double? period, }) : _topic = topic { this.period = period ?? @@ -59,58 +53,119 @@ class NTWidgetModel extends ChangeNotifier { init(); } - NTWidgetModel.createDefault({ - required this.ntConnection, - required this.preferences, - required String type, - required String topic, - this.dataType = 'Unknown', - double? period, - }) : _typeOverride = type, - _topic = topic { - this.period = period ?? - preferences.getDouble(PrefKeys.defaultPeriod) ?? - Defaults.defaultPeriod; - - init(); - } - NTWidgetModel.fromJson({ required this.ntConnection, required this.preferences, required Map jsonData, }) { _topic = tryCast(jsonData['topic']) ?? ''; + _period = tryCast(jsonData['period']) ?? preferences.getDouble(PrefKeys.defaultPeriod) ?? Defaults.defaultPeriod; - dataType = tryCast(jsonData['data_type']) ?? dataType; init(); } @mustCallSuper Map toJson() { - if (dataType == 'Unknown' && ntConnection.isNT4Connected) { - createTopicIfNull(); - dataType = ntTopic?.type ?? dataType; - } return { - 'topic': _topic, + 'topic': topic, 'period': period, - if (dataType != 'Unknown') 'data_type': dataType, }; } + void init(); + + void unSubscribe(); + + void disposeWidget({bool deleting = false}); + + void resetSubscription(); + + List getAvailableDisplayTypes(); + List getEditProperties(BuildContext context) { return const []; } - List getAvailableDisplayTypes() { - if (type == 'ComboBox Chooser' || type == 'Split Button Chooser') { - return ['ComboBox Chooser', 'Split Button Chooser']; + void forceDispose() { + _forceDispose = true; + dispose(); + } + + @override + void dispose() { + if (!hasListeners || _forceDispose) { + super.dispose(); + + _disposed = true; } + } + + @override + void notifyListeners() { + if (!_disposed) { + super.notifyListeners(); + } + } + + void refresh() { + Future(() => notifyListeners()); + } +} + +class SingleTopicNTWidgetModel extends NTWidgetModel { + String _typeOverride = 'NTWidget'; + @override + String get type => _typeOverride; + String dataType = 'Unknown'; + + NT4Subscription? subscription; + NT4Topic? ntTopic; + + SingleTopicNTWidgetModel({ + required super.ntConnection, + required super.preferences, + required super.topic, + this.dataType = 'Unknown', + super.period, + }) : super(); + + SingleTopicNTWidgetModel.createDefault({ + required super.ntConnection, + required super.preferences, + required String type, + required super.topic, + this.dataType = 'Unknown', + super.period, + }) : _typeOverride = type, + super(); + + SingleTopicNTWidgetModel.fromJson({ + required super.ntConnection, + required super.preferences, + required Map jsonData, + }) : super.fromJson(jsonData: jsonData) { + dataType = tryCast(jsonData['data_type']) ?? dataType; + } + + @override + @mustCallSuper + Map toJson() { + if (dataType == 'Unknown' && ntConnection.isNT4Connected) { + createTopicIfNull(); + dataType = ntTopic?.type ?? dataType; + } + return { + ...super.toJson(), + if (dataType != 'Unknown') 'data_type': dataType, + }; + } + + @override + List getAvailableDisplayTypes() { createTopicIfNull(); dataType = ntTopic?.type ?? dataType; @@ -130,7 +185,7 @@ class NTWidgetModel extends ChangeNotifier { NumberBar.widgetType, NumberSlider.widgetType, VoltageView.widgetType, - RadialGauge.widgetType, + RadialGaugeWidget.widgetType, GraphWidget.widgetType, MatchTimeWidget.widgetType, ]; @@ -156,15 +211,17 @@ class NTWidgetModel extends ChangeNotifier { return [type]; } + @override @mustCallSuper void init() async { - subscription = ntConnection.subscribe(_topic, _period); + subscription = ntConnection.subscribe(topic, period); } void createTopicIfNull() { - ntTopic ??= ntConnection.getTopicFromName(_topic); + ntTopic ??= ntConnection.getTopicFromName(topic); } + @override void unSubscribe() { if (subscription != null) { ntConnection.unSubscribe(subscription!); @@ -172,11 +229,13 @@ class NTWidgetModel extends ChangeNotifier { refresh(); } + @override void disposeWidget({bool deleting = false}) {} + @override void resetSubscription() { if (subscription == null) { - subscription = ntConnection.subscribe(_topic, _period); + subscription = ntConnection.subscribe(topic, period); ntTopic = null; @@ -187,7 +246,7 @@ class NTWidgetModel extends ChangeNotifier { bool resetDataType = subscription!.topic != topic; ntConnection.unSubscribe(subscription!); - subscription = ntConnection.subscribe(_topic, _period); + subscription = ntConnection.subscribe(topic, period); ntTopic = null; @@ -202,61 +261,64 @@ class NTWidgetModel extends ChangeNotifier { refresh(); } +} - static final Function listEquals = const DeepCollectionEquality().equals; +class MultiTopicNTWidgetModel extends NTWidgetModel { + @override + String get type => 'NTWidget'; - @protected - List getCurrentData() { - return []; - } + List get subscriptions => []; - Stream get multiTopicPeriodicStream async* { - final Duration delayTime = Duration( - microseconds: ((subscription?.options.periodicRateSeconds ?? - preferences.getDouble(PrefKeys.defaultPeriod) ?? - Defaults.defaultPeriod) * - 1e6) - .round()); + MultiTopicNTWidgetModel({ + required super.ntConnection, + required super.preferences, + required super.topic, + String dataType = '', // To allow for stubbing in NTWidgetBuilder + super.period, + }) : super(); - int previousHash = Object.hashAll(getCurrentData()); + MultiTopicNTWidgetModel.fromJson({ + required super.ntConnection, + required super.preferences, + required super.jsonData, + }) : super.fromJson(); - while (true) { - int currentHash = Object.hashAll(getCurrentData()); + @override + @mustCallSuper + void init() { + initializeSubscriptions(); + } - if (previousHash != currentHash) { - yield Object(); - previousHash = currentHash; - } + void initializeSubscriptions() {} - await Future.delayed(delayTime); + @override + void unSubscribe() { + for (NT4Subscription subscription in subscriptions) { + ntConnection.unSubscribe(subscription); } } - void forceDispose() { - disposeWidget(deleting: true); - _forceDispose = true; - dispose(); - } - @override - void dispose() { - if (!hasListeners || _forceDispose) { - super.dispose(); - - _disposed = true; + void resetSubscription() { + for (NT4Subscription subscription in subscriptions) { + ntConnection.unSubscribe(subscription); } + + initializeSubscriptions(); + refresh(); } @override - void notifyListeners() { - if (!_disposed) { - super.notifyListeners(); + List getAvailableDisplayTypes() { + if (type == 'ComboBox Chooser' || type == 'Split Button Chooser') { + return ['ComboBox Chooser', 'Split Button Chooser']; } - } - void refresh() { - Future(() => notifyListeners()); + return [type]; } + + @override + void disposeWidget({bool deleting = false}) {} } abstract class NTWidget extends StatelessWidget { diff --git a/lib/widgets/nt_widgets/single_topic/boolean_box.dart b/lib/widgets/nt_widgets/single_topic/boolean_box.dart index 207a9f7a..c42e29b8 100644 --- a/lib/widgets/nt_widgets/single_topic/boolean_box.dart +++ b/lib/widgets/nt_widgets/single_topic/boolean_box.dart @@ -7,7 +7,7 @@ import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_color_picker.dar import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_dropdown_chooser.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/nt_widget.dart'; -class BooleanBoxModel extends NTWidgetModel { +class BooleanBoxModel extends SingleTopicNTWidgetModel { @override String type = BooleanBox.widgetType; @@ -140,6 +140,7 @@ class BooleanBoxModel extends NTWidgetModel { }, label: 'True Color', initialColor: _trueColor, + defaultColor: Colors.green, ), const SizedBox(width: 10), DialogColorPicker( @@ -148,6 +149,7 @@ class BooleanBoxModel extends NTWidgetModel { }, label: 'False Color', initialColor: _falseColor, + defaultColor: Colors.red, ), ], ), @@ -202,11 +204,10 @@ class BooleanBox extends NTWidget { Widget build(BuildContext context) { BooleanBoxModel model = cast(context.watch()); - return StreamBuilder( - stream: model.subscription?.periodicStream(yieldAll: false), - initialData: model.ntConnection.getLastAnnouncedValue(model.topic), - builder: (context, snapshot) { - bool value = tryCast(snapshot.data) ?? false; + return ValueListenableBuilder( + valueListenable: model.subscription!, + builder: (context, data, child) { + bool value = tryCast(data) ?? false; Widget defaultWidget() => Container( decoration: BoxDecoration( @@ -246,13 +247,6 @@ class BooleanBox extends NTWidget { } return widgetToDisplay ?? defaultWidget(); - - // return Container( - // decoration: BoxDecoration( - // borderRadius: BorderRadius.circular(15.0), - // color: (value) ? model.trueColor : model.falseColor, - // ), - // ); }, ); } diff --git a/lib/widgets/nt_widgets/single_topic/graph.dart b/lib/widgets/nt_widgets/single_topic/graph.dart index ac350ab3..7822cfd0 100644 --- a/lib/widgets/nt_widgets/single_topic/graph.dart +++ b/lib/widgets/nt_widgets/single_topic/graph.dart @@ -12,7 +12,7 @@ import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_color_picker.dar import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_text_input.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/nt_widget.dart'; -class GraphModel extends NTWidgetModel { +class GraphModel extends SingleTopicNTWidgetModel { @override String type = GraphWidget.widgetType; @@ -113,11 +113,13 @@ class GraphModel extends NTWidgetModel { children: [ Flexible( child: DialogColorPicker( - onColorPicked: (color) { - mainColor = color; - }, - label: 'Graph Color', - initialColor: _mainColor), + onColorPicked: (color) { + mainColor = color; + }, + label: 'Graph Color', + initialColor: _mainColor, + defaultColor: Colors.cyan, + ), ), Flexible( child: DialogTextInput( diff --git a/lib/widgets/nt_widgets/single_topic/match_time.dart b/lib/widgets/nt_widgets/single_topic/match_time.dart index 4df92a37..51fee696 100644 --- a/lib/widgets/nt_widgets/single_topic/match_time.dart +++ b/lib/widgets/nt_widgets/single_topic/match_time.dart @@ -8,7 +8,7 @@ import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_dropdown_chooser import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_text_input.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/nt_widget.dart'; -class MatchTimeModel extends NTWidgetModel { +class MatchTimeModel extends SingleTopicNTWidgetModel { @override String type = MatchTimeWidget.widgetType; @@ -180,11 +180,10 @@ class MatchTimeWidget extends NTWidget { Widget build(BuildContext context) { MatchTimeModel model = cast(context.watch()); - return StreamBuilder( - stream: model.subscription?.periodicStream(yieldAll: false), - initialData: model.ntConnection.getLastAnnouncedValue(model.topic), - builder: (context, snapshot) { - double time = tryCast(snapshot.data) ?? -1.0; + return ValueListenableBuilder( + valueListenable: model.subscription!, + builder: (context, data, child) { + double time = tryCast(data) ?? -1.0; time = time.floorToDouble(); String timeDisplayString; diff --git a/lib/widgets/nt_widgets/single_topic/multi_color_view.dart b/lib/widgets/nt_widgets/single_topic/multi_color_view.dart index 8c428a2d..13fb32ed 100644 --- a/lib/widgets/nt_widgets/single_topic/multi_color_view.dart +++ b/lib/widgets/nt_widgets/single_topic/multi_color_view.dart @@ -12,14 +12,12 @@ class MultiColorView extends NTWidget { @override Widget build(BuildContext context) { - NTWidgetModel model = context.watch(); - - return StreamBuilder( - stream: model.subscription?.periodicStream(yieldAll: false), - initialData: model.ntConnection.getLastAnnouncedValue(model.topic), - builder: (context, snapshot) { - List hexStringsRaw = - snapshot.data?.tryCast>() ?? []; + SingleTopicNTWidgetModel model = cast(context.watch()); + + return ValueListenableBuilder( + valueListenable: model.subscription!, + builder: (context, data, child) { + List hexStringsRaw = data?.tryCast>() ?? []; List hexStrings = hexStringsRaw.whereType().toList(); List colors = []; diff --git a/lib/widgets/nt_widgets/single_topic/number_bar.dart b/lib/widgets/nt_widgets/single_topic/number_bar.dart index 43a7e7aa..291573df 100644 --- a/lib/widgets/nt_widgets/single_topic/number_bar.dart +++ b/lib/widgets/nt_widgets/single_topic/number_bar.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:dot_cast/dot_cast.dart'; +import 'package:geekyants_flutter_gauges/geekyants_flutter_gauges.dart'; import 'package:provider/provider.dart'; -import 'package:syncfusion_flutter_gauges/gauges.dart'; import 'package:elastic_dashboard/services/nt4_client.dart'; import 'package:elastic_dashboard/services/text_formatter_builder.dart'; @@ -12,7 +12,7 @@ import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_text_input.dart' import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_toggle_switch.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/nt_widget.dart'; -class NumberBarModel extends NTWidgetModel { +class NumberBarModel extends SingleTopicNTWidgetModel { @override String type = NumberBar.widgetType; @@ -206,11 +206,13 @@ class NumberBar extends NTWidget { Widget build(BuildContext context) { NumberBarModel model = cast(context.watch()); - return StreamBuilder( - stream: model.subscription?.periodicStream(yieldAll: false), - initialData: model.ntConnection.getLastAnnouncedValue(model.topic), - builder: (context, snapshot) { - double value = tryCast(snapshot.data)?.toDouble() ?? 0.0; + String formatLabel(num input) => + input.toStringAsFixed(input.truncateToDouble() == input ? 0 : 2); + + return ValueListenableBuilder( + valueListenable: model.subscription!, + builder: (context, data, child) { + double value = tryCast(data)?.toDouble() ?? 0.0; double clampedValue = value.clamp(model.minValue, model.maxValue); @@ -220,10 +222,14 @@ class NumberBar extends NTWidget { int fractionDigits = (model.dataType == NT4TypeStr.kInt) ? 0 : 2; - LinearGaugeOrientation gaugeOrientation = - (model.orientation == 'vertical') - ? LinearGaugeOrientation.vertical - : LinearGaugeOrientation.horizontal; + GaugeOrientation gaugeOrientation = (model.orientation == 'vertical') + ? GaugeOrientation.vertical + : GaugeOrientation.horizontal; + + RulerPosition rulerPosition = + (gaugeOrientation == GaugeOrientation.vertical) + ? RulerPosition.right + : RulerPosition.bottom; List children = [ Text( @@ -234,29 +240,43 @@ class NumberBar extends NTWidget { const Flexible( child: SizedBox(width: 5.0, height: 5.0), ), - SfLinearGauge( + LinearGauge( key: UniqueKey(), - maximum: model.maxValue, - minimum: model.minValue, - barPointers: [ - LinearBarPointer( + rulers: RulerStyle( + rulerPosition: rulerPosition, + inverseRulers: model.inverted, + showLabel: true, + textStyle: Theme.of(context).textTheme.bodyMedium, + primaryRulerColor: Colors.grey, + secondaryRulerColor: Colors.grey, + ), + gaugeOrientation: gaugeOrientation, + valueBar: [ + ValueBar( + color: Theme.of(context).colorScheme.primary, value: clampedValue, + borderRadius: 5, + valueBarThickness: 7.5, + enableAnimation: false, animationDuration: 0, - thickness: 7.5, - edgeStyle: LinearEdgeStyle.bothCurve, ), ], - axisTrackStyle: const LinearAxisTrackStyle( - thickness: 7.5, - edgeStyle: LinearEdgeStyle.bothCurve, - ), - orientation: gaugeOrientation, - isAxisInversed: model.inverted, - interval: divisionInterval, + customLabels: [ + if (model.divisions != null) + for (int i = 0; i < model.divisions!; i++) + CustomRulerLabel( + text: formatLabel(model.minValue + divisionInterval! * i), + value: model.minValue + divisionInterval * i, + ), + ], + enableGaugeAnimation: false, + start: model.minValue, + end: model.maxValue, + steps: divisionInterval, ), ]; - if (gaugeOrientation == LinearGaugeOrientation.vertical) { + if (gaugeOrientation == GaugeOrientation.vertical) { return Row( mainAxisAlignment: MainAxisAlignment.center, children: children, diff --git a/lib/widgets/nt_widgets/single_topic/number_slider.dart b/lib/widgets/nt_widgets/single_topic/number_slider.dart index 4b5f6f8a..adbf5456 100644 --- a/lib/widgets/nt_widgets/single_topic/number_slider.dart +++ b/lib/widgets/nt_widgets/single_topic/number_slider.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:dot_cast/dot_cast.dart'; +import 'package:geekyants_flutter_gauges/geekyants_flutter_gauges.dart'; import 'package:provider/provider.dart'; -import 'package:syncfusion_flutter_gauges/gauges.dart'; import 'package:elastic_dashboard/services/nt4_client.dart'; import 'package:elastic_dashboard/services/text_formatter_builder.dart'; @@ -11,7 +11,7 @@ import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_text_input.dart' import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_toggle_switch.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/nt_widget.dart'; -class NumberSliderModel extends NTWidgetModel { +class NumberSliderModel extends SingleTopicNTWidgetModel { @override String type = NumberSlider.widgetType; @@ -20,9 +20,8 @@ class NumberSliderModel extends NTWidgetModel { int _divisions = 5; bool _updateContinuously = false; - double _currentValue = 0.0; - - bool _dragging = false; + ValueNotifier displayValue = ValueNotifier(0.0); + ValueNotifier dragging = ValueNotifier(false); double get minValue => _minValue; @@ -49,14 +48,6 @@ class NumberSliderModel extends NTWidgetModel { set updateContinuously(value) => _updateContinuously = value; - double get currentValue => _currentValue; - - set currentValue(value) => _currentValue = value; - - bool get dragging => _dragging; - - set dragging(value) => _dragging = value; - NumberSliderModel({ required super.ntConnection, required super.preferences, @@ -207,16 +198,20 @@ class NumberSlider extends NTWidget { Widget build(BuildContext context) { NumberSliderModel model = cast(context.watch()); - return StreamBuilder( - stream: model.subscription?.periodicStream(), - initialData: model.ntConnection.getLastAnnouncedValue(model.topic), - builder: (context, snapshot) { - double value = tryCast(snapshot.data)?.toDouble() ?? 0.0; + return ListenableBuilder( + listenable: Listenable.merge([ + model.subscription!, + model.displayValue, + model.dragging, + ]), + builder: (context, child) { + double value = + tryCast(model.subscription!.value)?.toDouble() ?? 0.0; double clampedValue = value.clamp(model.minValue, model.maxValue); - if (!model.dragging) { - model.currentValue = clampedValue; + if (!model.dragging.value) { + model.displayValue.value = clampedValue; } double divisionSeparation = @@ -227,52 +222,64 @@ class NumberSlider extends NTWidget { return Column( children: [ Text( - model.currentValue.toStringAsFixed(fractionDigits), + model.displayValue.value.toStringAsFixed(fractionDigits), style: Theme.of(context).textTheme.bodyLarge, overflow: TextOverflow.ellipsis, ), Expanded( - child: SfLinearGauge( - key: UniqueKey(), - minimum: model.minValue, - maximum: model.maxValue, - labelPosition: LinearLabelPosition.inside, - tickPosition: LinearElementPosition.cross, - interval: divisionSeparation, - axisTrackStyle: const LinearAxisTrackStyle( - edgeStyle: LinearEdgeStyle.bothCurve, + child: LinearGauge( + rulers: RulerStyle( + rulerPosition: RulerPosition.bottom, + showLabel: true, + textStyle: Theme.of(context).textTheme.bodyMedium, + primaryRulerColor: Colors.grey, + secondaryRulerColor: Colors.grey, + ), + extendLinearGauge: 1, + linearGaugeBoxDecoration: const LinearGaugeBoxDecoration( + backgroundColor: Color.fromRGBO(87, 87, 87, 1), + thickness: 5, ), - markerPointers: [ - LinearShapePointer( - value: model.currentValue, + pointers: [ + Pointer( color: Theme.of(context).colorScheme.primary, - height: 15.0, - width: 15.0, - animationDuration: 0, - shapeType: LinearShapePointerType.circle, - position: LinearElementPosition.cross, - dragBehavior: LinearMarkerDragBehavior.free, - onChangeStart: (_) { - model.dragging = true; + value: model.displayValue.value, + shape: PointerShape.circle, + enableAnimation: false, + height: 15, + isInteractive: true, + onChangeStart: () { + model.dragging.value = true; }, onChanged: (value) { if (model.dataType == NT4TypeStr.kInt) { - model.currentValue = value.roundToDouble(); + model.displayValue.value = value.roundToDouble(); } else { - model.currentValue = value; + model.displayValue.value = value; } if (model.updateContinuously) { - model.publishValue(model.currentValue); + model.publishValue(model.displayValue.value); } }, - onChangeEnd: (value) { - model.publishValue(model.currentValue); - - model.dragging = false; + onChangeEnd: () { + model.publishValue(model.displayValue.value); + model.dragging.value = false; }, ), ], + customLabels: [ + for (int i = 0; i < model.divisions; i++) + CustomRulerLabel( + text: (model.minValue + divisionSeparation * i) + .toStringAsFixed(2), + value: model.minValue + divisionSeparation * i, + ), + ], + enableGaugeAnimation: false, + start: model.minValue, + end: model.maxValue, + steps: divisionSeparation, ), ), ], diff --git a/lib/widgets/nt_widgets/single_topic/radial_gauge.dart b/lib/widgets/nt_widgets/single_topic/radial_gauge.dart index 2f1d0a77..d417cdbd 100644 --- a/lib/widgets/nt_widgets/single_topic/radial_gauge.dart +++ b/lib/widgets/nt_widgets/single_topic/radial_gauge.dart @@ -1,9 +1,11 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:dot_cast/dot_cast.dart'; +import 'package:geekyants_flutter_gauges/geekyants_flutter_gauges.dart'; import 'package:provider/provider.dart'; -import 'package:syncfusion_flutter_gauges/gauges.dart'; import 'package:elastic_dashboard/services/nt4_client.dart'; import 'package:elastic_dashboard/services/text_formatter_builder.dart'; @@ -11,9 +13,9 @@ import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_text_input.dart' import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_toggle_switch.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/nt_widget.dart'; -class RadialGaugeModel extends NTWidgetModel { +class RadialGaugeModel extends SingleTopicNTWidgetModel { @override - String type = RadialGauge.widgetType; + String type = RadialGaugeWidget.widgetType; double _startAngle = -140.0; double _endAngle = 140.0; @@ -275,10 +277,10 @@ class RadialGaugeModel extends NTWidgetModel { } } -class RadialGauge extends NTWidget { +class RadialGaugeWidget extends NTWidget { static const String widgetType = 'Radial Gauge'; - const RadialGauge({super.key}); + const RadialGaugeWidget({super.key}); static double _getWrappedValue(double value, double min, double max) { if (value >= min && value <= max) { @@ -301,74 +303,92 @@ class RadialGauge extends NTWidget { Widget build(BuildContext context) { RadialGaugeModel model = cast(context.watch()); - return StreamBuilder( - stream: model.subscription?.periodicStream(yieldAll: false), - initialData: model.ntConnection.getLastAnnouncedValue(model.topic), - builder: (context, snapshot) { - double value = tryCast(snapshot.data)?.toDouble() ?? 0.0; + return ValueListenableBuilder( + valueListenable: model.subscription!, + builder: (context, data, child) { + double value = tryCast(data)?.toDouble() ?? 0.0; if (model.wrapValue) { value = _getWrappedValue(value, model.minValue, model.maxValue); } + value = value.clamp(model.minValue, model.maxValue); + int fractionDigits = (model.dataType == NT4TypeStr.kInt) ? 0 : 2; - return SfRadialGauge( - axes: [ - RadialAxis( - startAngle: model.startAngle - 90.0, - endAngle: model.endAngle - 90.0, - minimum: model.minValue, - maximum: model.maxValue, - showTicks: model.showTicks, - showLabels: model.numberOfLabels != 0, - interval: (model.numberOfLabels != 0) - ? (model.maxValue - model.minValue) / model.numberOfLabels - : null, - showLastLabel: _getWrappedValue( - model.endAngle - model.startAngle, -180.0, 180.0) != - 0.0, - canScaleToFit: true, - annotations: [ - GaugeAnnotation( - horizontalAlignment: GaugeAlignment.center, - verticalAlignment: GaugeAlignment.center, - angle: 90.0, - positionFactor: (model.showPointer) ? 0.35 : 0.05, - widget: Text( - value.toStringAsFixed(fractionDigits), - style: TextStyle( - fontSize: (model.showPointer) ? 18.0 : 28.0, + return LayoutBuilder( + builder: (context, constraints) { + double squareSide = + min(constraints.maxWidth, constraints.maxHeight); + + return Stack( + alignment: Alignment.center, + children: [ + RadialGauge( + track: RadialTrack( + start: model.minValue, + end: model.maxValue, + startAngle: model.startAngle + 90, + endAngle: model.endAngle + 90, + steps: model.numberOfLabels, + color: const Color.fromRGBO(97, 97, 97, 1), + trackStyle: TrackStyle( + primaryRulerColor: Colors.grey, + secondaryRulerColor: Colors.grey, + showPrimaryRulers: model.showTicks, + showSecondaryRulers: model.showTicks, + labelStyle: Theme.of(context).textTheme.bodySmall, + primaryRulersHeight: model.showTicks ? 10 : 0, + secondaryRulersHeight: model.showTicks ? 8 : 0, + rulersOffset: -5, + labelOffset: -10, + showLastLabel: _getWrappedValue( + model.endAngle - model.startAngle, + -180.0, + 180.0) != + 0.0, ), - textAlign: TextAlign.center, + trackLabelFormater: (value) => + num.parse(value.toStringAsFixed(2)).toString(), ), - ), - ], - pointers: [ - RangePointer( - enableAnimation: false, - enableDragging: false, - color: Theme.of(context).colorScheme.primaryContainer, - value: value, + needlePointer: [ + if (model.showPointer) + NeedlePointer( + needleWidth: squareSide * 0.02, + needleEndWidth: squareSide * 0.004, + needleHeight: squareSide * 0.25, + tailColor: Colors.grey, + tailRadius: squareSide * 0.075, + value: value, + ), + ], + valueBar: [ + RadialValueBar( + color: Theme.of(context).colorScheme.primaryContainer, + value: value, + startPosition: (model.minValue < 0.0) ? 0.0 : null, + ), + ], ), if (model.showPointer) - NeedlePointer( - enableAnimation: false, - enableDragging: false, - needleColor: Colors.red, - needleEndWidth: 3.5, - needleStartWidth: 0.5, - needleLength: 0.5, - knobStyle: const KnobStyle( - borderColor: Colors.grey, - borderWidth: 0.025, - knobRadius: 0.05, + Container( + width: squareSide * 0.05, + height: squareSide * 0.05, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.grey[300]!, ), - value: value, ), + Positioned( + bottom: squareSide * 0.3, + child: Text( + value.toStringAsFixed(fractionDigits), + style: Theme.of(context).textTheme.bodyLarge, + ), + ), ], - ), - ], + ); + }, ); }, ); diff --git a/lib/widgets/nt_widgets/single_topic/single_color_view.dart b/lib/widgets/nt_widgets/single_topic/single_color_view.dart index ba1cc780..34ce3499 100644 --- a/lib/widgets/nt_widgets/single_topic/single_color_view.dart +++ b/lib/widgets/nt_widgets/single_topic/single_color_view.dart @@ -12,13 +12,12 @@ class SingleColorView extends NTWidget { @override Widget build(BuildContext context) { - NTWidgetModel model = context.watch(); + SingleTopicNTWidgetModel model = cast(context.watch()); - return StreamBuilder( - stream: model.subscription?.periodicStream(yieldAll: false), - initialData: model.ntConnection.getLastAnnouncedValue(model.topic), - builder: (context, snapshot) { - String hexString = tryCast(snapshot.data) ?? ''; + return ValueListenableBuilder( + valueListenable: model.subscription!, + builder: (context, data, child) { + String hexString = tryCast(data) ?? ''; hexString = hexString.toUpperCase().replaceAll('#', ''); diff --git a/lib/widgets/nt_widgets/single_topic/text_display.dart b/lib/widgets/nt_widgets/single_topic/text_display.dart index b71ff55e..24670668 100644 --- a/lib/widgets/nt_widgets/single_topic/text_display.dart +++ b/lib/widgets/nt_widgets/single_topic/text_display.dart @@ -10,7 +10,7 @@ import 'package:elastic_dashboard/services/nt4_client.dart'; import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_toggle_switch.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/nt_widget.dart'; -class TextDisplayModel extends NTWidgetModel { +class TextDisplayModel extends SingleTopicNTWidgetModel { @override String type = TextDisplay.widgetType; @@ -140,11 +140,13 @@ class TextDisplay extends NTWidget { Widget build(BuildContext context) { TextDisplayModel model = cast(context.watch()); - return StreamBuilder( - stream: model.subscription?.periodicStream(), - initialData: model.ntConnection.getLastAnnouncedValue(model.topic), - builder: (context, snapshot) { - Object? data = snapshot.data; + return ListenableBuilder( + listenable: Listenable.merge([ + model.subscription!, + model.controller, + ]), + builder: (context, child) { + Object? data = model.subscription!.value; if (data?.toString() != model.previousValue?.toString()) { // Needed to prevent errors diff --git a/lib/widgets/nt_widgets/single_topic/toggle_button.dart b/lib/widgets/nt_widgets/single_topic/toggle_button.dart index 5ec5c3e4..994e1cbe 100644 --- a/lib/widgets/nt_widgets/single_topic/toggle_button.dart +++ b/lib/widgets/nt_widgets/single_topic/toggle_button.dart @@ -12,13 +12,12 @@ class ToggleButton extends NTWidget { @override Widget build(BuildContext context) { - NTWidgetModel model = context.watch(); + SingleTopicNTWidgetModel model = cast(context.watch()); - return StreamBuilder( - stream: model.subscription?.periodicStream(yieldAll: false), - initialData: model.ntConnection.getLastAnnouncedValue(model.topic), - builder: (context, snapshot) { - bool value = tryCast(snapshot.data) ?? false; + return ValueListenableBuilder( + valueListenable: model.subscription!, + builder: (context, data, child) { + bool value = tryCast(data) ?? false; String buttonText = model.topic.substring(model.topic.lastIndexOf('/') + 1); diff --git a/lib/widgets/nt_widgets/single_topic/toggle_switch.dart b/lib/widgets/nt_widgets/single_topic/toggle_switch.dart index 0b38758d..cb45c376 100644 --- a/lib/widgets/nt_widgets/single_topic/toggle_switch.dart +++ b/lib/widgets/nt_widgets/single_topic/toggle_switch.dart @@ -12,13 +12,12 @@ class ToggleSwitch extends NTWidget { @override Widget build(BuildContext context) { - NTWidgetModel model = context.watch(); + SingleTopicNTWidgetModel model = cast(context.watch()); - return StreamBuilder( - stream: model.subscription?.periodicStream(yieldAll: false), - initialData: model.ntConnection.getLastAnnouncedValue(model.topic), - builder: (context, snapshot) { - bool value = tryCast(snapshot.data) ?? false; + return ValueListenableBuilder( + valueListenable: model.subscription!, + builder: (context, data, child) { + bool value = tryCast(data) ?? false; return Switch( value: value, diff --git a/lib/widgets/nt_widgets/single_topic/voltage_view.dart b/lib/widgets/nt_widgets/single_topic/voltage_view.dart index 7d8ad29c..3d07c69f 100644 --- a/lib/widgets/nt_widgets/single_topic/voltage_view.dart +++ b/lib/widgets/nt_widgets/single_topic/voltage_view.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:dot_cast/dot_cast.dart'; +import 'package:geekyants_flutter_gauges/geekyants_flutter_gauges.dart'; import 'package:provider/provider.dart'; -import 'package:syncfusion_flutter_gauges/gauges.dart'; import 'package:elastic_dashboard/services/nt4_client.dart'; import 'package:elastic_dashboard/services/text_formatter_builder.dart'; @@ -12,7 +12,7 @@ import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_text_input.dart' import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_toggle_switch.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/nt_widget.dart'; -class VoltageViewModel extends NTWidgetModel { +class VoltageViewModel extends SingleTopicNTWidgetModel { @override String type = VoltageView.widgetType; @@ -206,11 +206,13 @@ class VoltageView extends NTWidget { Widget build(BuildContext context) { VoltageViewModel model = cast(context.watch()); - return StreamBuilder( - stream: model.subscription?.periodicStream(yieldAll: false), - initialData: model.ntConnection.getLastAnnouncedValue(model.topic), - builder: (context, snapshot) { - double voltage = tryCast(snapshot.data)?.toDouble() ?? 0.0; + String formatLabel(num input) => + input.toStringAsFixed(input.truncateToDouble() == input ? 0 : 2); + + return ValueListenableBuilder( + valueListenable: model.subscription!, + builder: (context, data, child) { + double voltage = tryCast(data)?.toDouble() ?? 0.0; double clampedVoltage = voltage.clamp(model.minValue, model.maxValue); @@ -220,10 +222,14 @@ class VoltageView extends NTWidget { int fractionDigits = (model.dataType == NT4TypeStr.kInt) ? 0 : 2; - LinearGaugeOrientation gaugeOrientation = - (model.orientation == 'vertical') - ? LinearGaugeOrientation.vertical - : LinearGaugeOrientation.horizontal; + GaugeOrientation gaugeOrientation = (model.orientation == 'vertical') + ? GaugeOrientation.vertical + : GaugeOrientation.horizontal; + + RulerPosition rulerPosition = + (gaugeOrientation == GaugeOrientation.vertical) + ? RulerPosition.right + : RulerPosition.bottom; List children = [ Text( @@ -234,29 +240,44 @@ class VoltageView extends NTWidget { const Flexible( child: SizedBox(width: 5.0, height: 5.0), ), - SfLinearGauge( + LinearGauge( key: UniqueKey(), - maximum: model.maxValue, - minimum: model.minValue, - barPointers: [ - LinearBarPointer( + rulers: RulerStyle( + rulerPosition: rulerPosition, + inverseRulers: model.inverted, + showLabel: true, + textStyle: Theme.of(context).textTheme.bodyMedium, + primaryRulerColor: Colors.grey, + secondaryRulerColor: Colors.grey, + ), + gaugeOrientation: gaugeOrientation, + valueBar: [ + ValueBar( + color: Colors.yellow, value: clampedVoltage, - color: Colors.yellow.shade600, + borderRadius: 5, + valueBarThickness: 7.5, + enableAnimation: false, animationDuration: 0, - thickness: 7.5, ), ], - axisTrackStyle: const LinearAxisTrackStyle( - thickness: 7.5, - ), - labelFormatterCallback: (value) => '$value V', - orientation: gaugeOrientation, - isAxisInversed: model.inverted, - interval: divisionInterval, + customLabels: [ + if (model.divisions != null) + for (int i = 0; i < model.divisions!; i++) + CustomRulerLabel( + text: + '${formatLabel(model.minValue + divisionInterval! * i)} V', + value: model.minValue + divisionInterval * i, + ), + ], + enableGaugeAnimation: false, + start: model.minValue, + end: model.maxValue, + steps: divisionInterval, ), ]; - if (gaugeOrientation == LinearGaugeOrientation.vertical) { + if (gaugeOrientation == GaugeOrientation.vertical) { return Row( mainAxisAlignment: MainAxisAlignment.center, children: children, diff --git a/lib/widgets/settings_dialog.dart b/lib/widgets/settings_dialog.dart index 9a6d4742..7ebbe2cd 100644 --- a/lib/widgets/settings_dialog.dart +++ b/lib/widgets/settings_dialog.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:collection/collection.dart'; -import 'package:dot_cast/dot_cast.dart'; import 'package:flex_seed_scheme/flex_seed_scheme.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -40,25 +39,26 @@ class SettingsDialog extends StatefulWidget { final Function(String? value)? onDefaultPeriodChanged; final Function(String? value)? onDefaultGraphPeriodChanged; final Function(FlexSchemeVariant variant)? onThemeVariantChanged; + final Function()? onOpenAssetsFolderPressed; - const SettingsDialog({ - super.key, - required this.ntConnection, - required this.preferences, - this.onTeamNumberChanged, - this.onIPAddressModeChanged, - this.onIPAddressChanged, - this.onColorChanged, - this.onGridToggle, - this.onGridSizeChanged, - this.onCornerRadiusChanged, - this.onResizeToDSChanged, - this.onRememberWindowPositionChanged, - this.onLayoutLock, - this.onDefaultPeriodChanged, - this.onDefaultGraphPeriodChanged, - this.onThemeVariantChanged, - }); + const SettingsDialog( + {super.key, + required this.ntConnection, + required this.preferences, + this.onTeamNumberChanged, + this.onIPAddressModeChanged, + this.onIPAddressChanged, + this.onColorChanged, + this.onGridToggle, + this.onGridSizeChanged, + this.onCornerRadiusChanged, + this.onResizeToDSChanged, + this.onRememberWindowPositionChanged, + this.onLayoutLock, + this.onDefaultPeriodChanged, + this.onDefaultGraphPeriodChanged, + this.onThemeVariantChanged, + this.onOpenAssetsFolderPressed}); @override State createState() => _SettingsDialogState(); @@ -70,37 +70,82 @@ class _SettingsDialogState extends State { return AlertDialog( title: const Text('Settings'), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15.0)), - content: Container( - constraints: const BoxConstraints( - maxHeight: 350, - maxWidth: 725, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - ..._generalSettings(), - const Divider(), - ..._gridSettings(), + content: DefaultTabController( + length: 3, + child: SizedBox( + width: 450, + height: 400, + child: Column( + children: [ + const TabBar( + tabs: [ + Tab( + icon: Icon( + Icons.wifi_outlined, + ), + child: Text('Network'), + ), + Tab( + icon: Icon( + Icons.color_lens_outlined, + ), + child: Text('Appearance'), + ), + Tab( + icon: Icon( + Icons.code, + ), + child: Text('Developer'), + ), ], ), - ), - const VerticalDivider(), - Flexible( - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - ..._ipAddressSettings(), - const Divider(), - ..._networkTablesSettings(), - ], + const SizedBox(height: 10), + Expanded( + child: TabBarView( + children: [ + // Network Tab + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + ..._ipAddressSettings(), + const Divider(), + ..._networkTablesSettings(), + ], + ), + ), + // Style Preferences Tab + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 350), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + ..._themeSettings(), + const Divider(), + ..._gridSettings(), + ], + ), + ), + ), + ), + // Advanced Settings Tab + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: Column( + children: [ + ..._advancedSettings(), + ], + ), + ), + ], + ), ), - ), - ], + ], + ), ), ), actions: [ @@ -112,7 +157,46 @@ class _SettingsDialogState extends State { ); } - List _generalSettings() { + List _advancedSettings() { + return [ + Row( + children: [ + const Icon(Icons.warning, color: Colors.yellow), + const SizedBox(width: 5), + Flexible( + child: Text( + 'WARNING: These are advanced settings that could cause issues if changed incorrectly. It is advised to not change anything here unless if you know what you are doing.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + maxLines: 3, + ), + ), + const SizedBox(width: 5), + const Icon( + Icons.warning, + color: Colors.yellow, + ), + ], + ), + const Divider(), + Row( + children: [ + TextButton.icon( + onPressed: () { + widget.onOpenAssetsFolderPressed?.call(); + }, + icon: const Icon(Icons.folder_outlined), + label: const Text('Open Assets Folder'), + ), + const Spacer(), + ], + ), + ]; + } + + List _themeSettings() { Color currentColor = Color(widget.preferences.getInt(PrefKeys.teamColor) ?? Colors.blueAccent.value); @@ -129,59 +213,54 @@ class _SettingsDialogState extends State { } return [ - Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Row( - children: [ - Expanded( - child: DialogTextInput( - initialText: widget.preferences - .getInt(PrefKeys.teamNumber) - ?.toString() ?? - Defaults.teamNumber.toString(), - label: 'Team Number', - onSubmit: (data) async { - await widget.onTeamNumberChanged?.call(data); - setState(() {}); - }, - formatter: FilteringTextInputFormatter.digitsOnly, - ), - ), - Expanded( + const Align( + alignment: Alignment.topLeft, + child: Text('Theme Settings'), + ), + IntrinsicHeight( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Flexible( + flex: 2, + child: UnconstrainedBox( + constrainedAxis: Axis.horizontal, child: DialogColorPicker( onColorPicked: (color) => widget.onColorChanged?.call(color), label: 'Team Color', initialColor: currentColor, + defaultColor: Colors.blueAccent, + rowSize: MainAxisSize.max, ), ), - ], - ), - Row( - children: [ - const Text('Theme Variant'), - const SizedBox(width: 5), - Flexible( - child: DialogDropdownChooser( - onSelectionChanged: (variantName) { - if (variantName == null) return; - FlexSchemeVariant variant = FlexSchemeVariant.values - .firstWhereOrNull( - (e) => e.variantName == variantName) ?? - FlexSchemeVariant.material3Legacy; + ), + const VerticalDivider(), + Flexible( + flex: 4, + child: Column( + children: [ + const Text('Theme Variant'), + DialogDropdownChooser( + onSelectionChanged: (variantName) { + if (variantName == null) return; + FlexSchemeVariant variant = FlexSchemeVariant.values + .firstWhereOrNull( + (e) => e.variantName == variantName) ?? + FlexSchemeVariant.material3Legacy; - widget.onThemeVariantChanged?.call(variant); - setState(() {}); - }, - choices: - themeVariantsOverride ?? SettingsDialog.themeVariants, - initialValue: - widget.preferences.getString(PrefKeys.themeVariant) ?? - Defaults.defaultVariantName), + widget.onThemeVariantChanged?.call(variant); + setState(() {}); + }, + choices: + themeVariantsOverride ?? SettingsDialog.themeVariants, + initialValue: + widget.preferences.getString(PrefKeys.themeVariant) ?? + Defaults.defaultVariantName), + ], ), - ], - ), - ], + ), + ], + ), ), ]; } @@ -190,46 +269,73 @@ class _SettingsDialogState extends State { return [ const Align( alignment: Alignment.topLeft, - child: Text('IP Address Settings'), + child: Text('Connection Settings'), ), const SizedBox(height: 5), - const Text('IP Address Mode'), - DialogDropdownChooser( - onSelectionChanged: (mode) { - if (mode == null) { - return; - } - - widget.onIPAddressModeChanged?.call(mode); - - setState(() {}); - }, - choices: IPAddressMode.values, - initialValue: IPAddressMode.fromIndex( - widget.preferences.getInt(PrefKeys.ipAddressMode)), + Row( + children: [ + Flexible( + flex: 2, + child: DialogTextInput( + initialText: + widget.preferences.getInt(PrefKeys.teamNumber)?.toString() ?? + Defaults.teamNumber.toString(), + label: 'Team Number', + onSubmit: (data) async { + await widget.onTeamNumberChanged?.call(data); + setState(() {}); + }, + formatter: FilteringTextInputFormatter.digitsOnly, + ), + ), + Flexible( + flex: 3, + child: ValueListenableBuilder( + valueListenable: widget.ntConnection.dsConnected, + builder: (context, connected, child) { + return DialogTextInput( + enabled: widget.preferences.getInt(PrefKeys.ipAddressMode) == + IPAddressMode.custom.index || + (widget.preferences.getInt(PrefKeys.ipAddressMode) == + IPAddressMode.driverStation.index && + !connected), + initialText: + widget.preferences.getString(PrefKeys.ipAddress) ?? + Defaults.ipAddress, + label: 'IP Address', + onSubmit: (String? data) async { + await widget.onIPAddressChanged?.call(data); + setState(() {}); + }, + ); + }, + ), + ), + ], ), const SizedBox(height: 5), - StreamBuilder( - stream: widget.ntConnection.dsConnectionStatus(), - initialData: widget.ntConnection.isDSConnected, - builder: (context, snapshot) { - bool dsConnected = tryCast(snapshot.data) ?? false; + Row( + children: [ + const Text('IP Address Mode'), + const SizedBox(width: 5), + Flexible( + child: DialogDropdownChooser( + onSelectionChanged: (mode) { + if (mode == null) { + return; + } + + widget.onIPAddressModeChanged?.call(mode); - return DialogTextInput( - enabled: widget.preferences.getInt(PrefKeys.ipAddressMode) == - IPAddressMode.custom.index || - (widget.preferences.getInt(PrefKeys.ipAddressMode) == - IPAddressMode.driverStation.index && - !dsConnected), - initialText: widget.preferences.getString(PrefKeys.ipAddress) ?? - Defaults.ipAddress, - label: 'IP Address', - onSubmit: (String? data) async { - await widget.onIPAddressChanged?.call(data); setState(() {}); }, - ); - }) + choices: IPAddressMode.values, + initialValue: IPAddressMode.fromIndex( + widget.preferences.getInt(PrefKeys.ipAddressMode)), + ), + ), + ], + ), ]; } diff --git a/lib/widgets/tab_grid.dart b/lib/widgets/tab_grid.dart index d12e3e02..cfa23f6f 100644 --- a/lib/widgets/tab_grid.dart +++ b/lib/widgets/tab_grid.dart @@ -34,10 +34,11 @@ class TabGridModel extends ChangeNotifier { final VoidCallback onAddWidgetPressed; - TabGridModel( - {required this.ntConnection, - required this.preferences, - required this.onAddWidgetPressed}); + TabGridModel({ + required this.ntConnection, + required this.preferences, + required this.onAddWidgetPressed, + }); TabGridModel.fromJson({ required this.ntConnection, @@ -47,13 +48,125 @@ class TabGridModel extends ChangeNotifier { Function(String message)? onJsonLoadingWarning, }) { if (jsonData['containers'] != null) { - loadContainersFromJson(jsonData, - onJsonLoadingWarning: onJsonLoadingWarning); + loadContainersFromJson( + jsonData, + onJsonLoadingWarning: onJsonLoadingWarning, + ); } if (jsonData['layouts'] != null) { loadLayoutsFromJson(jsonData, onJsonLoadingWarning: onJsonLoadingWarning); } + + for (WidgetContainerModel model in _widgetModels) { + model.addListener(notifyListeners); + } + } + + void mergeFromJson({ + required Map jsonData, + Function(String message)? onJsonLoadingWarning, + }) { + if (jsonData['containers'] != null) { + for (Map widgetData in jsonData['containers']) { + Rect newWidgetLocation = Rect.fromLTWH( + tryCast(widgetData['x']) ?? 0.0, + tryCast(widgetData['y']) ?? 0.0, + tryCast(widgetData['width']) ?? 0.0, + tryCast(widgetData['height']) ?? 0.0, + ); + + bool valid = true; + + for (WidgetContainerModel container in _widgetModels) { + String? title = container.title; + if (container is NTWidgetContainerModel) { + String? type = container.childModel.type; + String? topic = container.childModel.topic; + + if (title == widgetData['title'] && + type == widgetData['type'] && + topic == widgetData['properties']['topic']) { + valid = false; + break; + } + } + bool validLocation = isValidLocation(newWidgetLocation); + + if (!validLocation) { + valid = false; + break; + } + } + + if (valid) { + addWidget( + NTWidgetContainerModel.fromJson( + ntConnection: ntConnection, + jsonData: widgetData, + preferences: preferences, + enabled: ntConnection.isNT4Connected, + onJsonLoadingWarning: onJsonLoadingWarning, + ), + ); + } + } + } + + if (jsonData['layouts'] != null) { + for (Map widgetData in jsonData['layouts']) { + Rect newWidgetLocation = Rect.fromLTWH( + tryCast(widgetData['x']) ?? 0.0, + tryCast(widgetData['y']) ?? 0.0, + tryCast(widgetData['width']) ?? 0.0, + tryCast(widgetData['height']) ?? 0.0, + ); + + bool valid = true; + + for (WidgetContainerModel container in _widgetModels) { + String? title = container.title; + if (container is ListLayoutModel) { + String type = container.type; + if (title == widgetData['title'] && type == widgetData['type']) { + valid = false; + break; + } + } + bool validLocation = isValidLocation(newWidgetLocation); + + if (!validLocation) { + valid = false; + break; + } + } + + if (valid && widgetData['type'] == 'List Layout') { + addWidget( + ListLayoutModel.fromJson( + jsonData: widgetData, + preferences: preferences, + ntWidgetBuilder: (preferences, jsonData, enabled, + {onJsonLoadingWarning}) => + NTWidgetContainerModel.fromJson( + ntConnection: ntConnection, + jsonData: jsonData, + preferences: preferences, + onJsonLoadingWarning: onJsonLoadingWarning, + ), + enabled: ntConnection.isNT4Connected, + dragOutFunctions: ( + dragOutUpdate: layoutDragOutUpdate, + dragOutEnd: layoutDragOutEnd, + ), + onDragCancel: _layoutContainerOnDragCancel, + minWidth: 128.0 * 2, + minHeight: 128.0 * 2, + ), + ); + } + } + } } void loadContainersFromJson(Map jsonData, @@ -96,7 +209,10 @@ class TabGridModel extends ChangeNotifier { onJsonLoadingWarning: onJsonLoadingWarning, ), enabled: ntConnection.isNT4Connected, - tabGrid: this, + dragOutFunctions: ( + dragOutUpdate: layoutDragOutUpdate, + dragOutEnd: layoutDragOutEnd, + ), onDragCancel: _layoutContainerOnDragCancel, minWidth: 128.0 * 2, minHeight: 128.0 * 2, @@ -201,16 +317,16 @@ class TabGridModel extends ChangeNotifier { void onWidgetResizeEnd(WidgetContainerModel model) { if (model.validLocation) { - model.setDraggingRect(model.previewRect); + model.draggingRect = model.previewRect; } else { - model.setDraggingRect(model.dragStartLocation); + model.draggingRect = model.dragStartLocation; } - model.setDisplayRect(model.draggingRect); + model.displayRect = model.draggingRect; - model.setPreviewRect(model.draggingRect); - model.setPreviewVisible(false); - model.setValidLocation(true); + model.previewRect = model.draggingRect; + model.previewVisible = false; + model.validLocation = true; if (model is NTWidgetContainerModel && model.childModel is FieldWidgetModel) { @@ -222,16 +338,16 @@ class TabGridModel extends ChangeNotifier { void onWidgetDragEnd(WidgetContainerModel model) { if (model.validLocation) { - model.setDraggingRect(model.previewRect); + model.draggingRect = model.previewRect; } else { - model.setDraggingRect(model.dragStartLocation); + model.draggingRect = model.dragStartLocation; } - model.setDisplayRect(model.draggingRect); + model.displayRect = model.draggingRect; - model.setPreviewRect(model.draggingRect); - model.setPreviewVisible(false); - model.setValidLocation(true); + model.previewRect = model.draggingRect; + model.previewVisible = false; + model.validLocation = true; model.disposeModel(deleting: false); } @@ -241,16 +357,16 @@ class TabGridModel extends ChangeNotifier { return; } - model.setDraggingRect(model.dragStartLocation); - model.setDisplayRect(model.draggingRect); - model.setPreviewRect(model.draggingRect); + model.draggingRect = model.dragStartLocation; + model.displayRect = model.draggingRect; + model.previewRect = model.draggingRect; - model.setPreviewVisible(false); - model.setValidLocation(true); + model.previewVisible = false; + model.validLocation = true; - model.setDragging(false); - model.setResizing(false); - model.setDraggingIntoLayout(false); + model.dragging = false; + model.resizing = false; + model.draggingIntoLayout = false; model.disposeModel(); } @@ -333,37 +449,31 @@ class TabGridModel extends ChangeNotifier { Rect preview = Rect.fromLTWH(previewX, previewY, previewWidth, previewHeight); - model.setDraggingRect(constrainedRect); - model.setPreviewRect(preview); - model.setPreviewVisible(true); + model.draggingRect = constrainedRect; + model.previewRect = preview; + model.previewVisible = true; bool validLocation = isValidMoveLocation(model, preview); if (validLocation) { - model.setValidLocation(true); - - model.setDraggingIntoLayout(false); + model.validLocation = true; + model.draggingIntoLayout = false; } else { validLocation = isValidLayoutLocation(model.cursorGlobalLocation) && model is! LayoutContainerModel && !model.resizing; - model.setDraggingIntoLayout(validLocation); - - model.setValidLocation(validLocation); + model.draggingIntoLayout = validLocation; + model.validLocation = validLocation; } } void _ntContainerOnUpdate( WidgetContainerModel widget, Rect newRect, TransformResult result) { onWidgetUpdate(widget, newRect, result); - - refresh(); } - void _ntContainerOnDragBegin(WidgetContainerModel widget) { - refresh(); - } + void _ntContainerOnDragBegin(WidgetContainerModel widget) {} void _ntContainerOnDragEnd(WidgetContainerModel model, Rect releaseRect, {Offset? globalPosition}) { @@ -377,117 +487,92 @@ class TabGridModel extends ChangeNotifier { if (layoutModel != null) { layoutModel.addWidget(ntContainer); _widgetModels.remove(ntContainer); + ntContainer.removeListener(notifyListeners); } } - refresh(); } void _ntContainerOnDragCancel(WidgetContainerModel widget) { onWidgetDragCancel(widget); - - refresh(); } - void _ntContainerOnResizeBegin(WidgetContainerModel widget) { - refresh(); - } + void _ntContainerOnResizeBegin(WidgetContainerModel widget) {} void _ntContainerOnResizeEnd(WidgetContainerModel widget, Rect releaseRect) { onWidgetResizeEnd(widget); - - refresh(); } void _layoutContainerOnUpdate( WidgetContainerModel widget, Rect newRect, TransformResult result) { onWidgetUpdate(widget, newRect, result); - - refresh(); } - void _layoutContainerOnDragBegin(WidgetContainerModel widget) { - refresh(); - } + void _layoutContainerOnDragBegin(WidgetContainerModel widget) {} void _layoutContainerOnDragEnd(WidgetContainerModel widget, Rect releaseRect, {Offset? globalPosition}) { onWidgetDragEnd(widget); - - refresh(); } void _layoutContainerOnDragCancel(WidgetContainerModel widget) { onWidgetDragCancel(widget); - - refresh(); } - void _layoutContainerOnResizeBegin(WidgetContainerModel widget) { - refresh(); - } + void _layoutContainerOnResizeBegin(WidgetContainerModel widget) {} void _layoutContainerOnResizeEnd( WidgetContainerModel widget, Rect releaseRect) { onWidgetResizeEnd(widget); - - refresh(); } - void layoutDragOutEnd(WidgetContainerModel widget) { + bool layoutDragOutEnd(WidgetContainerModel widget) { if (widget is NTWidgetContainerModel) { - placeDragInWidget(widget, true); + return placeDragInWidget(widget, true); } + return false; } void layoutDragOutUpdate(WidgetContainerModel model, Offset globalPosition) { Offset localPosition = getLocalPosition(globalPosition); - model.setDraggingRect( - Rect.fromLTWH( - localPosition.dx, - localPosition.dy, - model.draggingRect.width, - model.draggingRect.height, - ), + model.draggingRect = Rect.fromLTWH( + localPosition.dx, + localPosition.dy, + model.draggingRect.width, + model.draggingRect.height, ); _containerDraggingIn = MapEntry(model, globalPosition); - refresh(); + notifyListeners(); } void onNTConnect() { for (WidgetContainerModel model in _widgetModels) { - model.setEnabled(true); + model.enabled = true; } - - refresh(); } void onNTDisconnect() { for (WidgetContainerModel container in _widgetModels) { - container.setEnabled(false); + container.enabled = false; } - - refresh(); } void addDragInWidget(WidgetContainerModel widget, Offset globalPosition) { Offset localPosition = getLocalPosition(globalPosition); - widget.setDraggingRect( - Rect.fromLTWH( - localPosition.dx, - localPosition.dy, - widget.draggingRect.width, - widget.draggingRect.height, - ), + widget.draggingRect = Rect.fromLTWH( + localPosition.dx, + localPosition.dy, + widget.draggingRect.width, + widget.draggingRect.height, ); _containerDraggingIn = MapEntry(widget, globalPosition); - refresh(); + notifyListeners(); } - void placeDragInWidget(WidgetContainerModel widget, + bool placeDragInWidget(WidgetContainerModel widget, [bool fromLayout = false]) { if (_containerDraggingIn == null) { - return; + return false; } Offset globalPosition = _containerDraggingIn!.value; @@ -505,10 +590,10 @@ class TabGridModel extends ChangeNotifier { double height = widget.displayRect.height; Rect previewLocation = Rect.fromLTWH(previewX, previewY, width, height); - widget.setPreviewRect(previewLocation); + widget.previewRect = previewLocation; widget.tryCast()?.updateMinimumSize(); - widget.setEnabled(ntConnection.isNT4Connected); + widget.enabled = ntConnection.isNT4Connected; // If dragging into layout if (widget is NTWidgetContainerModel && @@ -518,6 +603,15 @@ class TabGridModel extends ChangeNotifier { if (layoutContainer.willAcceptWidget(widget)) { layoutContainer.addWidget(widget); + } else { + widget.disposeModel(deleting: !fromLayout); + if (!fromLayout) { + widget.unSubscribe(); + widget.forceDispose(); + } + + notifyListeners(); + return false; } } else if (!isValidMoveLocation(widget, previewLocation)) { _containerDraggingIn = null; @@ -528,8 +622,8 @@ class TabGridModel extends ChangeNotifier { widget.forceDispose(); } - refresh(); - return; + notifyListeners(); + return false; } else { widget.displayRect = previewLocation; widget.draggingRect = Rect.fromLTWH(previewX, previewY, width, height); @@ -540,7 +634,9 @@ class TabGridModel extends ChangeNotifier { _containerDraggingIn = null; widget.disposeModel(); - refresh(); + notifyListeners(); + + return true; } ListLayoutModel createListLayout( @@ -559,100 +655,42 @@ class TabGridModel extends ChangeNotifier { children: children, minWidth: 128.0, minHeight: 128.0, - tabGrid: this, + dragOutFunctions: ( + dragOutUpdate: layoutDragOutUpdate, + dragOutEnd: layoutDragOutEnd, + ), onDragCancel: _layoutContainerOnDragCancel, ); } void addWidget(WidgetContainerModel widget) { _widgetModels.add(widget); - } - - void addWidgetFromTabJson(Map widgetData) { - Rect newWidgetLocation = Rect.fromLTWH( - tryCast(widgetData['x']) ?? 0.0, - tryCast(widgetData['y']) ?? 0.0, - tryCast(widgetData['width']) ?? 0.0, - tryCast(widgetData['height']) ?? 0.0, - ); - // If the widget is already in the tab, don't add it - if (!widgetData['layout']) { - for (NTWidgetContainerModel container - in _widgetModels.whereType()) { - String? title = container.title; - String? type = container.childModel.type; - String? topic = container.childModel.topic; - bool validLocation = isValidLocation(newWidgetLocation); - - if (title == widgetData['title'] && - type == widgetData['type'] && - topic == widgetData['properties']['topic'] && - !validLocation) { - return; - } - } - } else { - for (LayoutContainerModel container - in _widgetModels.whereType()) { - String? title = container.title; - String type = container.type; - bool validLocation = isValidLocation(newWidgetLocation); - - if (title == widgetData['title'] && - type == widgetData['type'] && - !validLocation) { - return; - } - } - } - - if (widgetData['layout']) { - switch (widgetData['type']) { - case 'List Layout': - _widgetModels.add( - ListLayoutModel.fromJson( - preferences: preferences, - jsonData: widgetData, - ntWidgetBuilder: (preferences, jsonData, enabled, - {onJsonLoadingWarning}) => - NTWidgetContainerModel.fromJson( - ntConnection: ntConnection, - jsonData: jsonData, - preferences: preferences, - onJsonLoadingWarning: onJsonLoadingWarning, - ), - enabled: ntConnection.isNT4Connected, - tabGrid: this, - onDragCancel: _layoutContainerOnDragCancel, - minWidth: 128.0 * 2, - minHeight: 128.0 * 2, - ), - ); - break; - } - } else { - _widgetModels.add( - NTWidgetContainerModel.fromJson( - ntConnection: ntConnection, - preferences: preferences, - enabled: ntConnection.isNT4Connected, - jsonData: widgetData, - ), - ); - } - - refresh(); + widget.addListener(notifyListeners); + notifyListeners(); } void removeWidget(WidgetContainerModel widget) { + widget.removeListener(notifyListeners); widget.disposeModel(deleting: true); widget.unSubscribe(); widget.forceDispose(); _widgetModels.remove(widget); - refresh(); + notifyListeners(); } - void clearWidgets(BuildContext context) { + @visibleForTesting + void clearWidgets() { + for (WidgetContainerModel container in _widgetModels) { + container.removeListener(notifyListeners); + container.disposeModel(deleting: true); + container.unSubscribe(); + container.forceDispose(); + } + _widgetModels.clear(); + notifyListeners(); + } + + void confirmClearWidgets(BuildContext context) { showDialog( context: context, builder: (context) => AlertDialog( @@ -664,13 +702,7 @@ class TabGridModel extends ChangeNotifier { onPressed: () { Navigator.of(context).pop(); - for (WidgetContainerModel container in _widgetModels) { - container.disposeModel(deleting: true); - container.unSubscribe(); - container.forceDispose(); - } - _widgetModels.clear(); - refresh(); + clearWidgets(); }, child: const Text('Confirm'), ), @@ -691,18 +723,19 @@ class TabGridModel extends ChangeNotifier { void lockLayout() { for (WidgetContainerModel container in _widgetModels) { - container.setDraggable(false); + container.draggable = false; } } void unlockLayout() { for (WidgetContainerModel container in _widgetModels) { - container.setDraggable(true); + container.draggable = true; } } void onDestroy() { for (WidgetContainerModel container in _widgetModels) { + container.removeListener(notifyListeners); container.disposeModel(deleting: true); container.unSubscribe(); container.forceDispose(); @@ -714,7 +747,7 @@ class TabGridModel extends ChangeNotifier { for (WidgetContainerModel widget in _widgetModels) { widget.updateGridSize(oldSize, newSize); } - refresh(); + notifyListeners(); } void refreshAllContainers() { @@ -724,10 +757,6 @@ class TabGridModel extends ChangeNotifier { } }); } - - void refresh() { - notifyListeners(); - } } class TabGrid extends StatelessWidget { @@ -970,7 +999,7 @@ class TabGrid extends StatelessWidget { MenuItem( label: 'Clear Layout', icon: Icons.clear, - onSelected: () => model.clearWidgets(context), + onSelected: () => model.confirmClearWidgets(context), ), ]; @@ -1043,8 +1072,7 @@ class TabGrid extends StatelessWidget { WidgetContainerModel copiedWidget = createWidgetFromJson(grid, widgetJson); - grid._widgetModels.add(copiedWidget); - grid.refresh(); + grid.addWidget(copiedWidget); } } @@ -1054,7 +1082,10 @@ class TabGrid extends StatelessWidget { return ListLayoutModel.fromJson( preferences: grid.preferences, jsonData: json, - tabGrid: grid, + dragOutFunctions: ( + dragOutUpdate: grid.layoutDragOutUpdate, + dragOutEnd: grid.layoutDragOutEnd, + ), ntWidgetBuilder: (preferences, jsonData, enabled, {onJsonLoadingWarning}) => NTWidgetContainerModel.fromJson( diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index ca3d0df8..020803dd 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -7,7 +7,7 @@ project(runner LANGUAGES CXX) 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") +set(APPLICATION_ID "com.gold872.elastic_dashboard") # Explicitly opt in to modern CMake behaviors to avoid warnings with recent # versions of CMake. diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 9e24aa10..b5b275c2 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -384,7 +384,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.elasticDashboard.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.gold872.elasticDashboard.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/elastic_dashboard.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/elastic_dashboard"; @@ -398,7 +398,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.elasticDashboard.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.gold872.elasticDashboard.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/elastic_dashboard.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/elastic_dashboard"; @@ -412,7 +412,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.elasticDashboard.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.gold872.elasticDashboard.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/elastic_dashboard.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/elastic_dashboard"; diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig index e2749b14..a240c1c5 100644 --- a/macos/Runner/Configs/AppInfo.xcconfig +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -8,7 +8,7 @@ PRODUCT_NAME = elastic_dashboard // The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = com.example.elasticDashboard +PRODUCT_BUNDLE_IDENTIFIER = com.gold872.elasticDashboard // The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2023 com.example. All rights reserved. +PRODUCT_COPYRIGHT = Copyright © 2023 com.gold872. All rights reserved. diff --git a/pubspec.lock b/pubspec.lock index 61bb595c..3d92364e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -406,14 +406,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.6.0" - flutter_hooks: - dependency: "direct main" - description: - name: flutter_hooks - sha256: cde36b12f7188c85286fba9b38cc5a902e7279f36dd676967106c041dc9dde70 - url: "https://pub.dev" - source: hosted - version: "0.20.5" flutter_launcher_icons: dependency: "direct main" description: @@ -456,6 +448,15 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + geekyants_flutter_gauges: + dependency: "direct main" + description: + path: "." + ref: elastic-version + resolved-ref: "141b95f6dd606204d47134107c1ddc2bbe4879cb" + url: "https://github.com/Gold872/GaugesFlutter.git" + source: git + version: "1.0.4" github: dependency: "direct main" description: @@ -1013,14 +1014,6 @@ packages: url: "https://pub.dev" source: hosted version: "23.2.7" - syncfusion_flutter_gauges: - dependency: "direct main" - description: - name: syncfusion_flutter_gauges - sha256: a559712b476b05ad2506925b5031dbba3c9e7ce83f482191a8185a3de054e7e6 - url: "https://pub.dev" - source: hosted - version: "23.2.7" term_glyph: dependency: transitive description: @@ -1081,10 +1074,10 @@ packages: dependency: "direct main" description: name: url_launcher - sha256: "6ce1e04375be4eed30548f10a315826fd933c1e493206eab82eed01f438c8d2e" + sha256: "21b704ce5fa560ea9f3b525b43601c678728ba46725bab9b01187b4831377ed3" url: "https://pub.dev" source: hosted - version: "6.2.6" + version: "6.3.0" url_launcher_android: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4f9e7ee0..107e33f3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: elastic_dashboard description: A simple and modern dashboard for FRC. publish_to: 'none' -version: 2024.2.0 +version: 2025.0.0-beta-5 environment: sdk: '>=3.0.2 <4.0.0' @@ -21,8 +21,11 @@ dependencies: flutter_colorpicker: ^1.1.0 flutter_context_menu: ^0.1.3 flutter_fancy_tree_view: ^1.1.1 - flutter_hooks: ^0.20.0 flutter_launcher_icons: ^0.13.1 + geekyants_flutter_gauges: + git: + url: https://github.com/Gold872/GaugesFlutter.git + ref: elastic-version github: ^9.17.0 http: ^1.2.0 import_sorter: ^4.6.0 @@ -40,10 +43,9 @@ dependencies: searchable_listview: ^2.7.0 shared_preferences: ^2.1.2 syncfusion_flutter_charts: ^23.1.44 - syncfusion_flutter_gauges: ^23.1.44 titlebar_buttons: ^1.0.0 transitioned_indexed_stack: ^1.0.2 - url_launcher: ^6.1.14 + url_launcher: ^6.3.0 uuid: ^4.2.2 vector_math: ^2.1.4 version: ^3.0.2 diff --git a/screenshots/adding_widgets/adding_voltage.gif b/screenshots/adding_widgets/adding_voltage.gif index 4908b73b..67a06d02 100644 Binary files a/screenshots/adding_widgets/adding_voltage.gif and b/screenshots/adding_widgets/adding_voltage.gif differ diff --git a/screenshots/adding_widgets/context_menu.png b/screenshots/adding_widgets/context_menu.png index 77794df9..c1bbb913 100644 Binary files a/screenshots/adding_widgets/context_menu.png and b/screenshots/adding_widgets/context_menu.png differ diff --git a/screenshots/adding_widgets/dialog_collapsed.png b/screenshots/adding_widgets/dialog_collapsed.png index 6d27fb75..c36aadd5 100644 Binary files a/screenshots/adding_widgets/dialog_collapsed.png and b/screenshots/adding_widgets/dialog_collapsed.png differ diff --git a/screenshots/adding_widgets/dialog_dragging.png b/screenshots/adding_widgets/dialog_dragging.png index 3ba8209b..e4f5b50d 100644 Binary files a/screenshots/adding_widgets/dialog_dragging.png and b/screenshots/adding_widgets/dialog_dragging.png differ diff --git a/screenshots/adding_widgets/dialog_expanded.png b/screenshots/adding_widgets/dialog_expanded.png index 433f4b60..5b7e26a6 100644 Binary files a/screenshots/adding_widgets/dialog_expanded.png and b/screenshots/adding_widgets/dialog_expanded.png differ diff --git a/screenshots/adding_widgets/dialog_resizing.png b/screenshots/adding_widgets/dialog_resizing.png index 4ab2b74d..35ec84cb 100644 Binary files a/screenshots/adding_widgets/dialog_resizing.png and b/screenshots/adding_widgets/dialog_resizing.png differ diff --git a/screenshots/adding_widgets/dialog_search.png b/screenshots/adding_widgets/dialog_search.png new file mode 100644 index 00000000..237975ab Binary files /dev/null and b/screenshots/adding_widgets/dialog_search.png differ diff --git a/screenshots/adding_widgets/properties_menu.png b/screenshots/adding_widgets/properties_menu.png index aa5ac9cd..df4a1fa3 100644 Binary files a/screenshots/adding_widgets/properties_menu.png and b/screenshots/adding_widgets/properties_menu.png differ diff --git a/screenshots/example_layout.png b/screenshots/example_layout.png index 6a9df170..c9bee700 100644 Binary files a/screenshots/example_layout.png and b/screenshots/example_layout.png differ diff --git a/screenshots/example_layout_auto.png b/screenshots/example_layout_auto.png index 238e2b09..0d56aa6c 100644 Binary files a/screenshots/example_layout_auto.png and b/screenshots/example_layout_auto.png differ diff --git a/screenshots/robot_notifications/error_notification.png b/screenshots/robot_notifications/error_notification.png new file mode 100644 index 00000000..ae6f3933 Binary files /dev/null and b/screenshots/robot_notifications/error_notification.png differ diff --git a/screenshots/robot_notifications/info_notification.png b/screenshots/robot_notifications/info_notification.png new file mode 100644 index 00000000..4dcbedb2 Binary files /dev/null and b/screenshots/robot_notifications/info_notification.png differ diff --git a/screenshots/robot_notifications/warning_notification.png b/screenshots/robot_notifications/warning_notification.png new file mode 100644 index 00000000..6ca7b5cf Binary files /dev/null and b/screenshots/robot_notifications/warning_notification.png differ diff --git a/screenshots/widget_copy_paste/copying_widget.png b/screenshots/widget_copy_paste/copying_widget.png new file mode 100644 index 00000000..7359239f Binary files /dev/null and b/screenshots/widget_copy_paste/copying_widget.png differ diff --git a/screenshots/widget_copy_paste/pasting_widget.png b/screenshots/widget_copy_paste/pasting_widget.png new file mode 100644 index 00000000..bc588f3e Binary files /dev/null and b/screenshots/widget_copy_paste/pasting_widget.png differ diff --git a/screenshots/widget_copy_paste/widget_after_pasting.png b/screenshots/widget_copy_paste/widget_after_pasting.png new file mode 100644 index 00000000..b79e0d0f Binary files /dev/null and b/screenshots/widget_copy_paste/widget_after_pasting.png differ diff --git a/test/pages/dashboard_page_test.dart b/test/pages/dashboard_page_test.dart index 3f465c4e..306ed354 100644 --- a/test/pages/dashboard_page_test.dart +++ b/test/pages/dashboard_page_test.dart @@ -7,16 +7,21 @@ import 'package:flutter/services.dart'; import 'package:elegant_notification/elegant_notification.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart'; import 'package:mockito/mockito.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:titlebar_buttons/titlebar_buttons.dart'; import 'package:elastic_dashboard/pages/dashboard_page.dart'; +import 'package:elastic_dashboard/services/elastic_layout_downloader.dart'; import 'package:elastic_dashboard/services/field_images.dart'; import 'package:elastic_dashboard/services/hotkey_manager.dart'; +import 'package:elastic_dashboard/services/ip_address_util.dart'; import 'package:elastic_dashboard/services/nt4_client.dart'; +import 'package:elastic_dashboard/services/nt_connection.dart'; import 'package:elastic_dashboard/services/settings.dart'; import 'package:elastic_dashboard/widgets/custom_appbar.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/dialog_widgets/layout_drag_tile.dart'; import 'package:elastic_dashboard/widgets/draggable_containers/draggable_list_layout.dart'; @@ -25,9 +30,11 @@ import 'package:elastic_dashboard/widgets/draggable_dialog.dart'; import 'package:elastic_dashboard/widgets/editable_tab_bar.dart'; import 'package:elastic_dashboard/widgets/network_tree/networktables_tree.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/multi-topic/combo_box_chooser.dart'; +import 'package:elastic_dashboard/widgets/nt_widgets/multi-topic/gyro.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/single_topic/boolean_box.dart'; import 'package:elastic_dashboard/widgets/settings_dialog.dart'; import 'package:elastic_dashboard/widgets/tab_grid.dart'; +import '../services/elastic_layout_downloader_test.dart'; import '../test_util.dart'; import '../test_util.mocks.dart'; @@ -45,7 +52,9 @@ void main() { await FieldImages.loadFields('assets/fields/'); jsonString = jsonEncode(jsonDecode(File(jsonFilePath).readAsStringSync())); + }); + setUp(() async { SharedPreferences.setMockInitialValues({ PrefKeys.layout: jsonString, PrefKeys.teamNumber: 353, @@ -59,451 +68,1228 @@ void main() { hotKeyManager.tearDown(); }); - testWidgets('Dashboard page loading offline', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + group('[Loading and Saving]:', () { + testWidgets('offline loading', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - version: '0.0.0.0', + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(find.textContaining('Network Tables: Disconnected'), findsOneWidget); - expect(find.textContaining('Network Tables: Connected'), findsNothing); - expect(find.textContaining('(10.3.53.2)'), findsNothing); - expect(find.text('Team 353'), findsOneWidget); + expect( + find.textContaining('Network Tables: Disconnected'), findsOneWidget); + expect(find.textContaining('Network Tables: Connected'), findsNothing); + expect(find.textContaining('(10.3.53.2)'), findsNothing); + expect(find.text('Team 353'), findsOneWidget); - expect(find.text('Teleoperated'), findsOneWidget); - expect(find.text('Autonomous'), findsOneWidget); - }); + expect(find.text('Teleoperated'), findsOneWidget); + expect(find.text('Autonomous'), findsOneWidget); + }); - testWidgets('Dashboard page loading online', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + testWidgets('online loading', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOnlineNT4(), - preferences: preferences, - version: '0.0.0.0', + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOnlineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(find.textContaining('Network Tables: Disconnected'), findsNothing); - expect(find.textContaining('Network Tables: Connected'), findsWidgets); - expect(find.textContaining('(10.3.53.2)'), findsWidgets); - expect(find.text('Team 353'), findsOneWidget); + expect(find.textContaining('Network Tables: Disconnected'), findsNothing); + expect(find.textContaining('Network Tables: Connected'), findsWidgets); + expect(find.textContaining('(10.3.53.2)'), findsWidgets); + expect(find.text('Team 353'), findsOneWidget); - expect(find.text('Teleoperated'), findsOneWidget); - expect(find.text('Autonomous'), findsOneWidget); - }); + expect(find.text('Teleoperated'), findsOneWidget); + expect(find.text('Autonomous'), findsOneWidget); + }); - testWidgets('Save layout (button)', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + testWidgets('Save layout (button)', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - version: '0.0.0.0', + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - final fileButton = find.widgetWithText(SubmenuButton, 'File'); + final fileButton = find.widgetWithText(SubmenuButton, 'File'); - expect(fileButton, findsOneWidget); + expect(fileButton, findsOneWidget); - await widgetTester.tap(fileButton); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(fileButton); + await widgetTester.pumpAndSettle(); - final saveButton = find.widgetWithText(MenuItemButton, 'Save'); + final saveButton = find.widgetWithText(MenuItemButton, 'Save'); - expect(saveButton, findsOneWidget); + expect(saveButton, findsOneWidget); - await widgetTester.tap(saveButton); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(saveButton); + await widgetTester.pumpAndSettle(); - expect(jsonString, preferences.getString(PrefKeys.layout)); - }); + expect(jsonString, preferences.getString(PrefKeys.layout)); + }); - testWidgets('Save layout (shortcut)', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + testWidgets('Save layout (shortcut)', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - version: '0.0.0.0', + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.control); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.control); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.keyS); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.keyS); - await widgetTester.pumpAndSettle(); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.keyS); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.keyS); + await widgetTester.pumpAndSettle(); - expect(jsonString, preferences.getString(PrefKeys.layout)); + expect(jsonString, preferences.getString(PrefKeys.layout)); + }); }); - testWidgets('Add widget dialog (widgets)', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; - createMockOnlineNT4(); + group('[Adding Widgets]:', () { + testWidgets('Add widget dialog search', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOnlineNT4(), - preferences: preferences, - version: '0.0.0.0', + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOnlineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - final addWidget = find.widgetWithText(MenuItemButton, 'Add Widget'); + final addWidget = find.widgetWithText(MenuItemButton, 'Add Widget'); - expect(addWidget, findsOneWidget); - expect(find.widgetWithText(DraggableDialog, 'Add Widget'), findsNothing); + expect(addWidget, findsOneWidget); + expect(find.widgetWithText(DraggableDialog, 'Add Widget'), findsNothing); - MenuItemButton addWidgetButton = - addWidget.evaluate().first.widget as MenuItemButton; + // widgetTester.tap() doesn't work :shrug: + MenuItemButton addWidgetButton = + addWidget.evaluate().first.widget as MenuItemButton; - addWidgetButton.onPressed?.call(); + addWidgetButton.onPressed?.call(); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(find.widgetWithText(DraggableDialog, 'Add Widget'), findsOneWidget); + expect( + find.widgetWithText(DraggableDialog, 'Add Widget'), findsOneWidget); - final smartDashboardTile = find.widgetWithText(TreeTile, 'SmartDashboard'); + final smartDashboardTile = + find.widgetWithText(TreeTile, 'SmartDashboard'); - expect(smartDashboardTile, findsOneWidget); + expect(smartDashboardTile, findsOneWidget); - await widgetTester.tap(smartDashboardTile); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(smartDashboardTile); + await widgetTester.pumpAndSettle(); - final testValueTile = find.widgetWithText(TreeTile, 'Test Value 1'); - final testValueContainer = - find.widgetWithText(WidgetContainer, 'Test Value 1'); + final searchQuery = find.widgetWithText(DialogTextInput, 'Search'); + expect(searchQuery, findsOneWidget); - expect(testValueTile, findsOneWidget); - expect(find.widgetWithText(TreeTile, 'Test Value 2'), findsOneWidget); + final testValueOne = find.widgetWithText(TreeTile, 'Test Value 1'); + final testValueTwo = find.widgetWithText(TreeTile, 'Test Value 2'); - await widgetTester.drag(testValueTile, const Offset(100, 100), - kind: PointerDeviceKind.mouse); - await widgetTester.pumpAndSettle(); + expect(testValueOne, findsOneWidget); + expect(testValueTwo, findsOneWidget); - expect(testValueContainer, findsNothing); + // Both match + await widgetTester.enterText(searchQuery, 'Test Value'); + await widgetTester.testTextInput.receiveAction(TextInputAction.done); - await widgetTester.drag(testValueTile, const Offset(300, -150), - kind: PointerDeviceKind.mouse); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(testValueContainer, findsOneWidget); + expect(testValueOne, findsOneWidget); + expect(testValueTwo, findsOneWidget); - final dialogDragHandle = find.byIcon(Icons.drag_handle); + // One match + await widgetTester.enterText(searchQuery, 'Test Value 1'); + await widgetTester.testTextInput.receiveAction(TextInputAction.done); - expect(dialogDragHandle, findsOneWidget); + await widgetTester.pumpAndSettle(); - await widgetTester.drag(dialogDragHandle, const Offset(100, 0)); - await widgetTester.pumpAndSettle(); - }); + expect(testValueOne, findsOneWidget); + expect(testValueTwo, findsNothing); + expect(smartDashboardTile, findsOneWidget); - testWidgets('Add widget dialog (layouts)', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + // No matches + await widgetTester.enterText(searchQuery, 'no match'); + await widgetTester.testTextInput.receiveAction(TextInputAction.done); - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOnlineNT4(), - preferences: preferences, - version: '0.0.0.0', + await widgetTester.pumpAndSettle(); + + expect(testValueOne, findsNothing); + expect(testValueTwo, findsNothing); + expect(smartDashboardTile, findsNothing); + + // Match only smart dashboard tile (all should show) + await widgetTester.enterText(searchQuery, 'Smart'); + await widgetTester.testTextInput.receiveAction(TextInputAction.done); + + await widgetTester.pumpAndSettle(); + + expect(testValueOne, findsOneWidget); + expect(testValueTwo, findsOneWidget); + expect(smartDashboardTile, findsOneWidget); + + // Empty text (both should be visible) + await widgetTester.enterText(searchQuery, ''); + await widgetTester.testTextInput.receiveAction(TextInputAction.done); + + await widgetTester.pumpAndSettle(); + + expect(testValueOne, findsOneWidget); + expect(testValueTwo, findsOneWidget); + expect(smartDashboardTile, findsOneWidget); + }); + + testWidgets('Add widget dialog (widgets)', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOnlineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - final addWidget = find.widgetWithText(MenuItemButton, 'Add Widget'); + final addWidget = find.widgetWithText(MenuItemButton, 'Add Widget'); - expect(addWidget, findsOneWidget); - expect(find.widgetWithText(DraggableDialog, 'Add Widget'), findsNothing); + expect(addWidget, findsOneWidget); + expect(find.widgetWithText(DraggableDialog, 'Add Widget'), findsNothing); - MenuItemButton addWidgetButton = - addWidget.evaluate().first.widget as MenuItemButton; + MenuItemButton addWidgetButton = + addWidget.evaluate().first.widget as MenuItemButton; - addWidgetButton.onPressed?.call(); + addWidgetButton.onPressed?.call(); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(find.widgetWithText(DraggableDialog, 'Add Widget'), findsOneWidget); + expect( + find.widgetWithText(DraggableDialog, 'Add Widget'), findsOneWidget); - final layoutsTab = find.text('Layouts'); - expect(layoutsTab, findsOneWidget); + final smartDashboardTile = + find.widgetWithText(TreeTile, 'SmartDashboard'); - await widgetTester.tap(layoutsTab); - await widgetTester.pumpAndSettle(); + expect(smartDashboardTile, findsOneWidget); - final listLayoutContainer = - find.widgetWithText(WidgetContainer, 'List Layout'); - expect(listLayoutContainer, findsNothing); + await widgetTester.tap(smartDashboardTile); + await widgetTester.pumpAndSettle(); - final listLayoutTile = find.widgetWithText(LayoutDragTile, 'List Layout'); - expect(listLayoutTile, findsOneWidget); + final testValueTile = find.widgetWithText(TreeTile, 'Test Value 1'); + final testValueContainer = + find.widgetWithText(WidgetContainer, 'Test Value 1'); - await widgetTester.drag(listLayoutTile, const Offset(100, 100), - kind: PointerDeviceKind.mouse); - await widgetTester.pumpAndSettle(); + expect(testValueTile, findsOneWidget); + expect(find.widgetWithText(TreeTile, 'Test Value 2'), findsOneWidget); - expect(listLayoutContainer, findsNothing); + await widgetTester.drag(testValueTile, const Offset(100, 100), + kind: PointerDeviceKind.mouse); + await widgetTester.pumpAndSettle(); - await widgetTester.drag(listLayoutTile, const Offset(300, -150), - kind: PointerDeviceKind.mouse); - await widgetTester.pumpAndSettle(); + expect(testValueContainer, findsNothing); - expect(listLayoutContainer, findsOneWidget); - }); + await widgetTester.drag(testValueTile, const Offset(300, -150), + kind: PointerDeviceKind.mouse); + await widgetTester.pumpAndSettle(); - testWidgets('List Layouts', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + expect(testValueContainer, findsOneWidget); - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOnlineNT4(), - preferences: preferences, - version: '0.0.0.0', + final dialogDragHandle = find.byIcon(Icons.drag_handle); + + expect(dialogDragHandle, findsOneWidget); + + await widgetTester.drag(dialogDragHandle, const Offset(100, 0)); + await widgetTester.pumpAndSettle(); + }); + + testWidgets('Add widget dialog (layouts)', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOnlineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - final addWidget = find.widgetWithText(MenuItemButton, 'Add Widget'); + final addWidget = find.widgetWithText(MenuItemButton, 'Add Widget'); - expect(addWidget, findsOneWidget); - expect(find.widgetWithText(DraggableDialog, 'Add Widget'), findsNothing); + expect(addWidget, findsOneWidget); + expect(find.widgetWithText(DraggableDialog, 'Add Widget'), findsNothing); - MenuItemButton addWidgetButton = - addWidget.evaluate().first.widget as MenuItemButton; + MenuItemButton addWidgetButton = + addWidget.evaluate().first.widget as MenuItemButton; - addWidgetButton.onPressed?.call(); + addWidgetButton.onPressed?.call(); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(find.widgetWithText(DraggableDialog, 'Add Widget'), findsOneWidget); + expect( + find.widgetWithText(DraggableDialog, 'Add Widget'), findsOneWidget); - final layoutsTab = find.text('Layouts'); - expect(layoutsTab, findsOneWidget); + final layoutsTab = find.text('Layouts'); + expect(layoutsTab, findsOneWidget); - await widgetTester.tap(layoutsTab); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(layoutsTab); + await widgetTester.pumpAndSettle(); - final listLayoutContainer = - find.widgetWithText(WidgetContainer, 'List Layout'); - expect(listLayoutContainer, findsNothing); + final listLayoutContainer = + find.widgetWithText(WidgetContainer, 'List Layout'); + expect(listLayoutContainer, findsNothing); - final listLayoutTile = find.widgetWithText(LayoutDragTile, 'List Layout'); - expect(listLayoutTile, findsOneWidget); + final listLayoutTile = find.widgetWithText(LayoutDragTile, 'List Layout'); + expect(listLayoutTile, findsOneWidget); - await widgetTester.drag(listLayoutTile, const Offset(100, 100), - kind: PointerDeviceKind.mouse); - await widgetTester.pumpAndSettle(); + await widgetTester.drag(listLayoutTile, const Offset(100, 100), + kind: PointerDeviceKind.mouse); + await widgetTester.pumpAndSettle(); - expect(listLayoutContainer, findsNothing); + expect(listLayoutContainer, findsNothing); - await widgetTester.drag(listLayoutTile, const Offset(300, -150), - kind: PointerDeviceKind.mouse); - await widgetTester.pumpAndSettle(); + await widgetTester.drag(listLayoutTile, const Offset(300, -150), + kind: PointerDeviceKind.mouse); + await widgetTester.pumpAndSettle(); - expect(listLayoutContainer, findsOneWidget); + expect(listLayoutContainer, findsOneWidget); + }); - final closeButton = find.widgetWithText(TextButton, 'Close'); - expect(closeButton, findsOneWidget); + testWidgets('Add widget dialog (list layout sub-table)', + (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - await widgetTester.tap(closeButton); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOnlineNT4( + virtualTopics: [ + NT4Topic( + name: '/Non-Typed/Value 1', + type: NT4TypeStr.kInt, + properties: {}, + ), + NT4Topic( + name: '/Non-Typed/Value 2', + type: NT4TypeStr.kInt, + properties: {}, + ), + NT4Topic( + name: '/Non-Typed/Value 3', + type: NT4TypeStr.kInt, + properties: {}, + ), + ], + ), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), + ), + ); - final listLayout = find.ancestor( - of: find.widgetWithText(WidgetContainer, 'List Layout'), - matching: find.byType(DraggableListLayout)); - expect(listLayout, findsOneWidget); + await widgetTester.pumpAndSettle(); - final testBooleanContainer = - find.widgetWithText(WidgetContainer, 'Test Boolean'); - expect(testBooleanContainer, findsOneWidget); + final addWidget = find.widgetWithText(MenuItemButton, 'Add Widget'); - final testBooleanInLayout = find.descendant( - of: find.widgetWithText(WidgetContainer, 'List Layout'), - matching: find.byType(BooleanBox)); + expect(addWidget, findsOneWidget); + expect(find.widgetWithText(DraggableDialog, 'Add Widget'), findsNothing); - expect(testBooleanInLayout, findsNothing); + MenuItemButton addWidgetButton = + addWidget.evaluate().first.widget as MenuItemButton; - // Drag into layout - await widgetTester.timedDrag(testBooleanContainer, const Offset(250, 32), - const Duration(milliseconds: 500)); - await widgetTester.pumpAndSettle(); + addWidgetButton.onPressed?.call(); - expect(testBooleanInLayout, findsOneWidget); + await widgetTester.pumpAndSettle(); - // Drag out of layout - await widgetTester.timedDrag(testBooleanInLayout, const Offset(-200, -60), - const Duration(milliseconds: 500)); - await widgetTester.pumpAndSettle(); + expect( + find.widgetWithText(DraggableDialog, 'Add Widget'), findsOneWidget); + + final nonTypedTile = find.widgetWithText(TreeTile, 'Non-Typed'); + + expect(nonTypedTile, findsOneWidget); + + final nonTypedContainer = + find.widgetWithText(WidgetContainer, 'Non-Typed'); + expect(nonTypedContainer, findsNothing); + + await widgetTester.drag( + nonTypedTile, + const Offset(250, 0), + kind: PointerDeviceKind.mouse, + ); + await widgetTester.pumpAndSettle(); + + expect(nonTypedContainer, findsOneWidget); + }); + + testWidgets('Add widget dialog (unregistered sendable)', + (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOnlineNT4( + virtualTopics: [ + NT4Topic( + name: '/Non-Registered/.type', + type: NT4TypeStr.kString, + properties: {}, + ), + NT4Topic( + name: '/Non-Registered/Value 1', + type: NT4TypeStr.kInt, + properties: {}, + ), + NT4Topic( + name: '/Non-Registered/Value 2', + type: NT4TypeStr.kInt, + properties: {}, + ), + NT4Topic( + name: '/Non-Registered/Value 3', + type: NT4TypeStr.kInt, + properties: {}, + ), + ], + virtualValues: { + '/Non-Registered/.type': 'Non Registered Type', + }, + ), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), + ), + ); + + await widgetTester.pumpAndSettle(); + + final addWidget = find.widgetWithText(MenuItemButton, 'Add Widget'); + + expect(addWidget, findsOneWidget); + expect(find.widgetWithText(DraggableDialog, 'Add Widget'), findsNothing); + + MenuItemButton addWidgetButton = + addWidget.evaluate().first.widget as MenuItemButton; + + addWidgetButton.onPressed?.call(); + + await widgetTester.pumpAndSettle(); + + expect( + find.widgetWithText(DraggableDialog, 'Add Widget'), findsOneWidget); + + final nonRegisteredTile = find.widgetWithText(TreeTile, 'Non-Registered'); + + expect(nonRegisteredTile, findsOneWidget); + + final nonRegistered = + find.widgetWithText(WidgetContainer, 'Non-Registered'); + expect(nonRegistered, findsNothing); + + await widgetTester.drag( + nonRegisteredTile, + const Offset(250, 0), + kind: PointerDeviceKind.mouse, + ); + await widgetTester.pumpAndSettle(); + + expect(nonRegistered, findsOneWidget); + }); + + testWidgets('List Layouts', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOnlineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), + ), + ); + + await widgetTester.pumpAndSettle(); - expect(testBooleanInLayout, findsNothing); + final addWidget = find.widgetWithText(MenuItemButton, 'Add Widget'); + + expect(addWidget, findsOneWidget); + expect(find.widgetWithText(DraggableDialog, 'Add Widget'), findsNothing); + + MenuItemButton addWidgetButton = + addWidget.evaluate().first.widget as MenuItemButton; + + addWidgetButton.onPressed?.call(); + + await widgetTester.pumpAndSettle(); + + expect( + find.widgetWithText(DraggableDialog, 'Add Widget'), findsOneWidget); + + final layoutsTab = find.text('Layouts'); + expect(layoutsTab, findsOneWidget); + + await widgetTester.tap(layoutsTab); + await widgetTester.pumpAndSettle(); + + final listLayoutContainer = + find.widgetWithText(WidgetContainer, 'List Layout'); + expect(listLayoutContainer, findsNothing); + + final listLayoutTile = find.widgetWithText(LayoutDragTile, 'List Layout'); + expect(listLayoutTile, findsOneWidget); + + await widgetTester.drag(listLayoutTile, const Offset(100, 100), + kind: PointerDeviceKind.mouse); + await widgetTester.pumpAndSettle(); + + expect(listLayoutContainer, findsNothing); + + await widgetTester.drag(listLayoutTile, const Offset(300, -150), + kind: PointerDeviceKind.mouse); + await widgetTester.pumpAndSettle(); + + expect(listLayoutContainer, findsOneWidget); + + final closeButton = find.widgetWithText(TextButton, 'Close'); + expect(closeButton, findsOneWidget); + + await widgetTester.tap(closeButton); + await widgetTester.pumpAndSettle(); + + final listLayout = find.ancestor( + of: find.widgetWithText(WidgetContainer, 'List Layout'), + matching: find.byType(DraggableListLayout)); + expect(listLayout, findsOneWidget); + + final testBooleanContainer = + find.widgetWithText(WidgetContainer, 'Test Boolean'); + expect(testBooleanContainer, findsOneWidget); + + final testBooleanInLayout = find.descendant( + of: find.widgetWithText(WidgetContainer, 'List Layout'), + matching: find.byType(BooleanBox)); + + expect(testBooleanInLayout, findsNothing); + + // Drag into layout + await widgetTester.timedDrag(testBooleanContainer, const Offset(250, 32), + const Duration(milliseconds: 500)); + await widgetTester.pumpAndSettle(); + + expect(testBooleanInLayout, findsOneWidget); + + // Drag out of layout + await widgetTester.timedDrag(testBooleanInLayout, const Offset(-200, -60), + const Duration(milliseconds: 500)); + await widgetTester.pumpAndSettle(); + + expect(testBooleanInLayout, findsNothing); + }); }); - testWidgets('Adding widgets from shuffleboard api', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + group('Shuffleboard API', () { + testWidgets('adding widgets', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + List fakeAnnounceCallbacks = []; + + // A custom mock is set up to reproduce behavior when actually running + final mockNT4Connection = MockNTConnection(); + final mockSubscription = MockNT4Subscription(); + + when(mockNT4Connection.isNT4Connected).thenReturn(true); + when(mockNT4Connection.ntConnected).thenReturn(ValueNotifier(true)); + when(mockNT4Connection.connectionStatus()) + .thenAnswer((_) => Stream.value(true)); + when(mockNT4Connection.latencyStream()) + .thenAnswer((_) => Stream.value(0)); + + when(mockSubscription.periodicStream()) + .thenAnswer((_) => Stream.value(null)); + + when(mockSubscription.listen(any)).thenAnswer((realInvocation) {}); + + when(mockNT4Connection.addTopicAnnounceListener(any)) + .thenAnswer((realInvocation) { + fakeAnnounceCallbacks.add(realInvocation.positionalArguments[0]); + }); + + when(mockNT4Connection.getLastAnnouncedValue(any)).thenReturn(null); + + when(mockNT4Connection.subscribe(any, any)).thenReturn(mockSubscription); + + when(mockNT4Connection.subscribe(any)).thenReturn(mockSubscription); + + when(mockNT4Connection.subscribeAll(any, any)) + .thenReturn(mockSubscription); + + when(mockNT4Connection.subscribeAll(any)).thenReturn(mockSubscription); + + when(mockNT4Connection.subscribeAndRetrieveData>( + '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Number/Position')) + .thenAnswer((realInvocation) => Future.value([2.0, 0.0])); + + when(mockNT4Connection.subscribeAndRetrieveData>( + '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Number/Size')) + .thenAnswer((realInvocation) => Future.value([2.0, 2.0])); + + when(mockNT4Connection.subscribeAndRetrieveData>( + '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Layout/Position')) + .thenAnswer((realInvocation) => Future.value([0.0, 0.0])); + + when(mockNT4Connection.subscribeAndRetrieveData>( + '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Layout/Size')) + .thenAnswer((realInvocation) => Future.value([2.0, 3.0])); + + when(mockNT4Connection.subscribeAndRetrieveData( + '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Layout/PreferredComponent')) + .thenAnswer((realInvocation) => Future.value('List Layout')); + + when(mockNT4Connection.subscribeAndRetrieveData( + '/Shuffleboard/Test-Tab/Shuffleboard Test Layout/.type')) + .thenAnswer((realInvocation) => Future.value('ShuffleboardLayout')); + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: mockNT4Connection, + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), + ), + ); + await widgetTester.pumpAndSettle(); + + await widgetTester.runAsync(() async { + for (final callback in fakeAnnounceCallbacks) { + callback.call(NT4Topic( + name: + '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Number/Position', + type: NT4TypeStr.kFloat32Arr, + properties: {}, + )); + callback.call(NT4Topic( + name: + '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Number/Size', + type: NT4TypeStr.kFloat32Arr, + properties: {}, + )); + callback.call(NT4Topic( + name: '/Shuffleboard/Test-Tab/Shuffleboard Test Number', + type: NT4TypeStr.kInt, + properties: {}, + )); + + callback.call(NT4Topic( + name: + '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Layout/Position', + type: NT4TypeStr.kFloat32Arr, + properties: {}, + )); + callback.call(NT4Topic( + name: + '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Layout/Size', + type: NT4TypeStr.kFloat32Arr, + properties: {}, + )); + callback.call(NT4Topic( + name: + '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Layout/PreferredComponent', + type: NT4TypeStr.kString, + properties: {}, + )); + callback.call(NT4Topic( + name: '/Shuffleboard/Test-Tab/Shuffleboard Test Layout', + type: NT4TypeStr.kInt, + properties: {}, + )); + } + + // Gives enough time for the widgets to be placed automatically + // It has to be done this way since the listener runs the functions asynchronously + await Future.delayed(const Duration(seconds: 3)); + }); + + await widgetTester.pumpAndSettle(); + + expect( + find.widgetWithText(AnimatedContainer, 'Test-Tab'), findsOneWidget); + expect( + find.widgetWithText(WidgetContainer, 'Shuffleboard Test Number', + skipOffstage: false), + findsOneWidget); + expect( + find.widgetWithText(WidgetContainer, 'Shuffleboard Test Layout', + skipOffstage: false), + findsOneWidget); + expect(find.bySubtype(skipOffstage: false), + findsOneWidget); + }); + + testWidgets('switching tabs', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + NTConnection ntConnection = createMockOnlineNT4( + virtualTopics: [ + NT4Topic( + name: '/Shuffleboard/.metadata/Selected', + type: NT4TypeStr.kString, + properties: {}, + ), + ], + ); + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: ntConnection, + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), + ), + ); + + await widgetTester.pumpAndSettle(); + + final editableTabBar = find.byType(EditableTabBar); + + expect(editableTabBar, findsOneWidget); + + editableTabBarWidget() => + (editableTabBar.evaluate().first.widget as EditableTabBar); + + ntConnection.updateDataFromTopicName( + '/Shuffleboard/.metadata/Selected', 'Autonomous'); + + await widgetTester.pumpAndSettle(); + + expect(editableTabBarWidget().currentIndex, 1); + + ntConnection.updateDataFromTopicName( + '/Shuffleboard/.metadata/Selected', 'Random Name'); + + await widgetTester.pumpAndSettle(); + + expect(editableTabBarWidget().currentIndex, 1, + reason: + 'Tab index should not change since selected tab doesn\'t exist'); + + ntConnection.updateDataFromTopicName( + '/Shuffleboard/.metadata/Selected', '0'); + + await widgetTester.pumpAndSettle(); + + expect(editableTabBarWidget().currentIndex, 0); + }); + }); + + group('[Remote Layouts]:', () { + setUp(() async { + SharedPreferences.setMockInitialValues({ + PrefKeys.ipAddress: '127.0.0.1', + PrefKeys.layoutLocked: false, + }); + + preferences = await SharedPreferences.getInstance(); + }); + + testWidgets('Shows list of layouts', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + Client mockClient = createHttpClient( + mockGetResponses: { + 'http://127.0.0.1:5800/?format=json': + Response(jsonEncode(layoutFiles), 200) + }, + ); + + ElasticLayoutDownloader layoutDownloader = + ElasticLayoutDownloader(mockClient); + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOnlineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + layoutDownloader: layoutDownloader, + ), + ), + ); + + await widgetTester.pumpAndSettle(); + + expect(find.text('File'), findsOneWidget); + await widgetTester.tap(find.text('File')); + await widgetTester.pumpAndSettle(); + + expect(find.text('Download From Robot'), findsOneWidget); + await widgetTester.tap(find.text('Download From Robot')); + await widgetTester.pumpAndSettle(); + + expect(find.text('Select Layout'), findsOneWidget); + expect(find.byType(DialogDropdownChooser), findsOneWidget); + + await widgetTester.tap(find.byType(DialogDropdownChooser)); + await widgetTester.pumpAndSettle(); + + expect(find.text('elastic-layout 1'), findsOneWidget); + expect(find.text('elastic-layout 2'), findsOneWidget); + }); + + group('Download layout', () { + testWidgets('without merges', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + Client mockClient = createHttpClient( + mockGetResponses: { + 'http://127.0.0.1:5800/?format=json': + Response(jsonEncode(layoutFiles), 200), + 'http://127.0.0.1:5800/${Uri.encodeComponent('elastic-layout 1.json')}': + Response(jsonEncode(layoutOne), 200), + }, + ); + + ElasticLayoutDownloader layoutDownloader = + ElasticLayoutDownloader(mockClient); + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOnlineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + layoutDownloader: layoutDownloader, + ), + ), + ); + + await widgetTester.pumpAndSettle(); + + expect(find.text('File'), findsOneWidget); + await widgetTester.tap(find.text('File')); + await widgetTester.pumpAndSettle(); + + expect(find.text('Download From Robot'), findsOneWidget); + await widgetTester.tap(find.text('Download From Robot')); + await widgetTester.pumpAndSettle(); + + expect(find.text('Select Layout'), findsOneWidget); + expect(find.byType(DialogDropdownChooser), findsOneWidget); + + await widgetTester.tap(find.byType(DialogDropdownChooser)); + await widgetTester.pumpAndSettle(); + + expect(find.text('elastic-layout 1'), findsOneWidget); + + await widgetTester.tap(find.text('elastic-layout 1')); + await widgetTester.pumpAndSettle(); + + expect(find.text('Download'), findsOneWidget); + await widgetTester.tap(find.text('Download')); + await widgetTester.pump(Duration.zero); + + expect( + find.widgetWithText( + ElegantNotification, 'Successfully Downloaded Layout'), + findsOneWidget); + + await widgetTester.pumpAndSettle(); + + expect(find.text('Test Tab'), findsOneWidget); + await widgetTester.tap(find.text('Test Tab')); + await widgetTester.pumpAndSettle(); + + expect(find.byType(Gyro), findsNWidgets(2)); + }); + + testWidgets('with merges', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + Client mockClient = createHttpClient( + mockGetResponses: { + 'http://127.0.0.1:5800/?format=json': + Response(jsonEncode(layoutFiles), 200), + 'http://127.0.0.1:5800/${Uri.encodeComponent('elastic-layout 1.json')}': + Response(jsonEncode(layoutOne), 200), + }, + ); + + ElasticLayoutDownloader layoutDownloader = + ElasticLayoutDownloader(mockClient); + + SharedPreferences.setMockInitialValues({ + PrefKeys.layout: jsonEncode({ + 'version': 1.0, + 'grid_size': 128.0, + 'tabs': [ + { + 'name': 'Test Tab', + 'grid_layout': { + 'layouts': [], + 'containers': [ + { + 'title': 'Blocking Widget', + 'x': 384.0, + 'y': 128.0, + 'width': 256.0, + 'height': 256.0, + 'type': 'Text Display', + 'properties': { + 'topic': '/Test Tab/Blocking Widget', + 'period': 0.06, + }, + } + ], + }, + }, + ], + }), + PrefKeys.ipAddress: '127.0.0.1', + }); + + SharedPreferences preferences = await SharedPreferences.getInstance(); + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOnlineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + layoutDownloader: layoutDownloader, + ), + ), + ); + + await widgetTester.pumpAndSettle(); + + expect(find.text('File'), findsOneWidget); + await widgetTester.tap(find.text('File')); + await widgetTester.pumpAndSettle(); + + expect(find.text('Download From Robot'), findsOneWidget); + await widgetTester.tap(find.text('Download From Robot')); + await widgetTester.pumpAndSettle(); + + expect(find.text('Select Layout'), findsOneWidget); + expect(find.byType(DialogDropdownChooser), findsOneWidget); + + await widgetTester.tap(find.byType(DialogDropdownChooser)); + await widgetTester.pumpAndSettle(); + + expect(find.text('elastic-layout 1'), findsOneWidget); - List fakeAnnounceCallbacks = []; + await widgetTester.tap(find.text('elastic-layout 1')); + await widgetTester.pumpAndSettle(); - // A custom mock is set up to reproduce behavior when actually running - final mockNT4Connection = MockNTConnection(); - final mockSubscription = MockNT4Subscription(); + expect(find.text('Download'), findsOneWidget); + await widgetTester.tap(find.text('Download')); + await widgetTester.pump(Duration.zero); - when(mockNT4Connection.isNT4Connected).thenReturn(true); - when(mockNT4Connection.connectionStatus()) - .thenAnswer((_) => Stream.value(true)); - when(mockNT4Connection.latencyStream()).thenAnswer((_) => Stream.value(0)); + expect( + find.widgetWithText( + ElegantNotification, 'Successfully Downloaded Layout'), + findsOneWidget); - when(mockSubscription.periodicStream()) - .thenAnswer((_) => Stream.value(null)); + await widgetTester.pumpAndSettle(); - when(mockSubscription.listen(any)).thenAnswer((realInvocation) {}); + expect(find.text('Test Tab'), findsOneWidget); + await widgetTester.tap(find.text('Test Tab')); + await widgetTester.pumpAndSettle(); - when(mockNT4Connection.addTopicAnnounceListener(any)) - .thenAnswer((realInvocation) { - fakeAnnounceCallbacks.add(realInvocation.positionalArguments[0]); + expect(find.text('Blocking Widget'), findsOneWidget); + expect(find.byType(Gyro), findsOneWidget); + }); }); - when(mockNT4Connection.getLastAnnouncedValue(any)).thenReturn(null); + group('Shows error when', () { + testWidgets('network tables is disconnected', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - when(mockNT4Connection.subscribe(any, any)).thenReturn(mockSubscription); + Client mockClient = createHttpClient(); - when(mockNT4Connection.subscribe(any)).thenReturn(mockSubscription); + ElasticLayoutDownloader layoutDownloader = + ElasticLayoutDownloader(mockClient); - when(mockNT4Connection.subscribeAll(any, any)).thenReturn(mockSubscription); + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + layoutDownloader: layoutDownloader, + ), + ), + ); + + await widgetTester.pumpAndSettle(); + + expect(find.text('File'), findsOneWidget); + await widgetTester.tap(find.text('File')); + await widgetTester.pumpAndSettle(); + + expect(find.text('Download From Robot'), findsOneWidget); + await widgetTester.tap(find.text('Download From Robot')); + await widgetTester.pump(Duration.zero); + await widgetTester.pump(Duration.zero); + + expect( + find.widgetWithText( + ElegantNotification, + 'Cannot fetch remote layouts while disconnected from the robot', + ), + findsOneWidget); + }); + + testWidgets('layout fetching is not a json', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + Client mockClient = createHttpClient( + mockGetResponses: { + 'http://127.0.0.1:5800/?format=json': Response('[1, 2, 3]', 200), + }, + ); + + ElasticLayoutDownloader layoutDownloader = + ElasticLayoutDownloader(mockClient); + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOnlineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + layoutDownloader: layoutDownloader, + ), + ), + ); + + await widgetTester.pumpAndSettle(); + + expect(find.text('File'), findsOneWidget); + await widgetTester.tap(find.text('File')); + await widgetTester.pumpAndSettle(); + + expect(find.text('Download From Robot'), findsOneWidget); + await widgetTester.tap(find.text('Download From Robot')); + await widgetTester.pump(Duration.zero); + await widgetTester.pump(Duration.zero); + + expect( + find.widgetWithText( + ElegantNotification, + 'Response was not a json object', + ), + findsOneWidget); + }); + + testWidgets('layout json does not list files', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + Client mockClient = createHttpClient( + mockGetResponses: { + 'http://127.0.0.1:5800/?format=json': Response('{}', 200), + }, + ); + + ElasticLayoutDownloader layoutDownloader = + ElasticLayoutDownloader(mockClient); + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOnlineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + layoutDownloader: layoutDownloader, + ), + ), + ); + + await widgetTester.pumpAndSettle(); + + expect(find.text('File'), findsOneWidget); + await widgetTester.tap(find.text('File')); + await widgetTester.pumpAndSettle(); + + expect(find.text('Download From Robot'), findsOneWidget); + await widgetTester.tap(find.text('Download From Robot')); + await widgetTester.pump(Duration.zero); + await widgetTester.pump(Duration.zero); + + expect( + find.widgetWithText( + ElegantNotification, + 'Response json does not contain files list', + ), + findsOneWidget); + }); + + testWidgets('layout json has empty files list', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + Client mockClient = createHttpClient( + mockGetResponses: { + 'http://127.0.0.1:5800/?format=json': + Response(jsonEncode({'files': []}), 200), + }, + ); + + ElasticLayoutDownloader layoutDownloader = + ElasticLayoutDownloader(mockClient); + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOnlineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + layoutDownloader: layoutDownloader, + ), + ), + ); + + await widgetTester.pumpAndSettle(); + + expect(find.text('File'), findsOneWidget); + await widgetTester.tap(find.text('File')); + await widgetTester.pumpAndSettle(); + + expect(find.text('Download From Robot'), findsOneWidget); + await widgetTester.tap(find.text('Download From Robot')); + await widgetTester.pump(Duration.zero); + await widgetTester.pump(Duration.zero); + + expect( + find.widgetWithText( + ElegantNotification, + 'No layouts were found, ensure a valid layout json file is placed in the root directory of your deploy directory.', + ), + findsOneWidget); + }); + + testWidgets('selected file was not found', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + Client mockClient = createHttpClient( + mockGetResponses: { + 'http://127.0.0.1:5800/?format=json': + Response(jsonEncode(layoutFiles), 200), + 'http://127.0.0.1:5800/${Uri.encodeComponent('elastic-layout 1.json')}': + Response('', 404), + }, + ); + + ElasticLayoutDownloader layoutDownloader = + ElasticLayoutDownloader(mockClient); + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOnlineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + layoutDownloader: layoutDownloader, + ), + ), + ); - when(mockNT4Connection.subscribeAll(any)).thenReturn(mockSubscription); + await widgetTester.pumpAndSettle(); - when(mockNT4Connection.subscribeAndRetrieveData>( - '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Number/Position')) - .thenAnswer((realInvocation) => Future.value([2.0, 0.0])); + expect(find.text('File'), findsOneWidget); + await widgetTester.tap(find.text('File')); + await widgetTester.pumpAndSettle(); - when(mockNT4Connection.subscribeAndRetrieveData>( - '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Number/Size')) - .thenAnswer((realInvocation) => Future.value([2.0, 2.0])); + expect(find.text('Download From Robot'), findsOneWidget); + await widgetTester.tap(find.text('Download From Robot')); + await widgetTester.pumpAndSettle(); - when(mockNT4Connection.subscribeAndRetrieveData>( - '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Layout/Position')) - .thenAnswer((realInvocation) => Future.value([0.0, 0.0])); + expect(find.text('Select Layout'), findsOneWidget); + expect(find.byType(DialogDropdownChooser), findsOneWidget); - when(mockNT4Connection.subscribeAndRetrieveData>( - '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Layout/Size')) - .thenAnswer((realInvocation) => Future.value([2.0, 3.0])); + await widgetTester.tap(find.byType(DialogDropdownChooser)); + await widgetTester.pumpAndSettle(); - when(mockNT4Connection.subscribeAndRetrieveData( - '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Layout/PreferredComponent')) - .thenAnswer((realInvocation) => Future.value('List Layout')); + expect(find.text('elastic-layout 1'), findsOneWidget); - when(mockNT4Connection.subscribeAndRetrieveData( - '/Shuffleboard/Test-Tab/Shuffleboard Test Layout/.type')) - .thenAnswer((realInvocation) => Future.value('ShuffleboardLayout')); + await widgetTester.tap(find.text('elastic-layout 1')); + await widgetTester.pumpAndSettle(); - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: mockNT4Connection, - preferences: preferences, - version: '0.0.0.0', - ), - ), - ); - await widgetTester.pumpAndSettle(); + expect(find.text('Download'), findsOneWidget); + await widgetTester.tap(find.text('Download')); + await widgetTester.pump(Duration.zero); + await widgetTester.pump(Duration.zero); - await widgetTester.runAsync(() async { - for (final callback in fakeAnnounceCallbacks) { - callback.call(NT4Topic( - name: - '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Number/Position', - type: NT4TypeStr.kFloat32Arr, - properties: {}, - )); - callback.call(NT4Topic( - name: - '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Number/Size', - type: NT4TypeStr.kFloat32Arr, - properties: {}, - )); - callback.call(NT4Topic( - name: '/Shuffleboard/Test-Tab/Shuffleboard Test Number', - type: NT4TypeStr.kInt, - properties: {}, - )); - - callback.call(NT4Topic( - name: - '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Layout/Position', - type: NT4TypeStr.kFloat32Arr, - properties: {}, - )); - callback.call(NT4Topic( - name: - '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Layout/Size', - type: NT4TypeStr.kFloat32Arr, - properties: {}, - )); - callback.call(NT4Topic( - name: - '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Layout/PreferredComponent', - type: NT4TypeStr.kString, - properties: {}, - )); - callback.call(NT4Topic( - name: '/Shuffleboard/Test-Tab/Shuffleboard Test Layout', - type: NT4TypeStr.kInt, - properties: {}, - )); - } - - // Gives enough time for the widgets to be placed automatically - // It has to be done this way since the listener runs the functions asynchronously - await Future.delayed(const Duration(seconds: 3)); + expect( + find.widgetWithText( + ElegantNotification, + 'File "elastic-layout 1.json" was not found', + ), + findsOneWidget); + }); }); - - await widgetTester.pumpAndSettle(); - - expect(find.widgetWithText(AnimatedContainer, 'Test-Tab'), findsOneWidget); - expect( - find.widgetWithText(WidgetContainer, 'Shuffleboard Test Number', - skipOffstage: false), - findsOneWidget); - expect( - find.widgetWithText(WidgetContainer, 'Shuffleboard Test Layout', - skipOffstage: false), - findsOneWidget); - expect(find.bySubtype(skipOffstage: false), - findsOneWidget); }); testWidgets('About dialog', (widgetTester) async { @@ -515,6 +1301,7 @@ void main() { ntConnection: createMockOfflineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -538,648 +1325,798 @@ void main() { expect(find.byType(AboutDialog), findsOneWidget); }); - testWidgets('Changing tabs', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + group('[Tab Manipulation]:', () { + testWidgets('Changing tabs', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - version: '0.0.0.0', + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(find.byType(ComboBoxChooser), findsNothing); + expect(find.byType(ComboBoxChooser), findsNothing); - expect(find.byType(EditableTabBar), findsOneWidget); + expect(find.byType(EditableTabBar), findsOneWidget); - final autonomousTab = find.widgetWithText(AnimatedContainer, 'Autonomous'); + final autonomousTab = + find.widgetWithText(AnimatedContainer, 'Autonomous'); - expect(autonomousTab, findsOneWidget); + expect(autonomousTab, findsOneWidget); - await widgetTester.tap(autonomousTab); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(autonomousTab); + await widgetTester.pumpAndSettle(); - expect(find.byType(ComboBoxChooser), findsOneWidget); - }); + expect(find.byType(ComboBoxChooser), findsOneWidget); + }); - testWidgets('Creating new tab', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + testWidgets('Creating new tab', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - version: '0.0.0.0', + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(2)); + expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(2)); - expect(find.byType(EditableTabBar), findsOneWidget); + expect(find.byType(EditableTabBar), findsOneWidget); - final createNewTabButton = find.descendant( - of: find.byType(EditableTabBar), matching: find.byIcon(Icons.add)); + final createNewTabButton = find.descendant( + of: find.byType(EditableTabBar), matching: find.byIcon(Icons.add)); - expect(createNewTabButton, findsOneWidget); + expect(createNewTabButton, findsOneWidget); - await widgetTester.tap(createNewTabButton); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(createNewTabButton); + await widgetTester.pumpAndSettle(); - expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(3)); - }); + expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(3)); + }); - testWidgets('Creating new tab (shortcut)', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + testWidgets('Creating new tab (shortcut)', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - version: '0.0.0.0', + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(2)); + expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(2)); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.control); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.keyT); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.keyT); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.control); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.keyT); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.keyT); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(3)); - }); + expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(3)); + }); - testWidgets('Closing tab', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + testWidgets('Closing tab', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - version: '0.0.0.0', + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(2)); + expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(2)); - expect(find.byType(EditableTabBar), findsOneWidget); + expect(find.byType(EditableTabBar), findsOneWidget); - final closeTabButton = find - .descendant( - of: find.byType(EditableTabBar), matching: find.byIcon(Icons.close)) - .last; + final closeTabButton = find + .descendant( + of: find.byType(EditableTabBar), + matching: find.byIcon(Icons.close)) + .last; - expect(closeTabButton, findsOneWidget); + expect(closeTabButton, findsOneWidget); - await widgetTester.tap(closeTabButton); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(closeTabButton); + await widgetTester.pumpAndSettle(); - expect(find.text('Confirm Tab Close', skipOffstage: false), findsOneWidget); + expect( + find.text('Confirm Tab Close', skipOffstage: false), findsOneWidget); - final confirmButton = - find.widgetWithText(TextButton, 'OK', skipOffstage: false); + final confirmButton = + find.widgetWithText(TextButton, 'OK', skipOffstage: false); - expect(confirmButton, findsOneWidget); + expect(confirmButton, findsOneWidget); - await widgetTester.tap(confirmButton); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(confirmButton); + await widgetTester.pumpAndSettle(); - expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(1)); - }); + expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(1)); + }); - testWidgets('Closing tab (shortcut)', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + testWidgets('Closing tab (shortcut)', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - version: '0.0.0.0', + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(2)); + expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(2)); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.control); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.keyW); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.keyW); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.control); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.keyW); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.keyW); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(find.text('Confirm Tab Close', skipOffstage: false), findsOneWidget); + expect( + find.text('Confirm Tab Close', skipOffstage: false), findsOneWidget); - final confirmButton = - find.widgetWithText(TextButton, 'OK', skipOffstage: false); + final confirmButton = + find.widgetWithText(TextButton, 'OK', skipOffstage: false); - expect(confirmButton, findsOneWidget); + expect(confirmButton, findsOneWidget); - await widgetTester.tap(confirmButton); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(confirmButton); + await widgetTester.pumpAndSettle(); - expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(1)); - }); + expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(1)); + }); - testWidgets('Reordering tabs', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + testWidgets('Reordering tabs', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - version: '0.0.0.0', + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(2)); + expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(2)); - final editableTabBar = find.byType(EditableTabBar); + final editableTabBar = find.byType(EditableTabBar); - expect(editableTabBar, findsOneWidget); + expect(editableTabBar, findsOneWidget); - final tabLeftButton = - find.descendant(of: editableTabBar, matching: find.byIcon(Icons.west)); - final tabRightButton = - find.descendant(of: editableTabBar, matching: find.byIcon(Icons.east)); + final tabLeftButton = find.descendant( + of: editableTabBar, matching: find.byIcon(Icons.west)); + final tabRightButton = find.descendant( + of: editableTabBar, matching: find.byIcon(Icons.east)); - expect(tabLeftButton, findsOneWidget); - expect(tabRightButton, findsOneWidget); + expect(tabLeftButton, findsOneWidget); + expect(tabRightButton, findsOneWidget); - editableTabBarWidget() => - (editableTabBar.evaluate().first.widget as EditableTabBar); + editableTabBarWidget() => + (editableTabBar.evaluate().first.widget as EditableTabBar); - expect(editableTabBarWidget().currentIndex, 0); + expect(editableTabBarWidget().currentIndex, 0); - await widgetTester.tap(tabLeftButton); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(tabLeftButton); + await widgetTester.pumpAndSettle(); - expect(editableTabBarWidget().currentIndex, 0, - reason: 'Tab index should not change since index is 0'); + expect(editableTabBarWidget().currentIndex, 0, + reason: 'Tab index should not change since index is 0'); - await widgetTester.tap(tabRightButton); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(tabRightButton); + await widgetTester.pumpAndSettle(); - expect(editableTabBarWidget().currentIndex, 1); + expect(editableTabBarWidget().currentIndex, 1); - await widgetTester.tap(tabRightButton); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(tabRightButton); + await widgetTester.pumpAndSettle(); - expect(editableTabBarWidget().currentIndex, 1, - reason: - 'Tab index should not change since index is equal to number of tabs'); + expect(editableTabBarWidget().currentIndex, 1, + reason: + 'Tab index should not change since index is equal to number of tabs'); - await widgetTester.tap(tabLeftButton); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(tabLeftButton); + await widgetTester.pumpAndSettle(); - expect(editableTabBarWidget().currentIndex, 0); - }); + expect(editableTabBarWidget().currentIndex, 0); + }); - testWidgets('Reordering tabs (shortcut)', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + testWidgets('Reordering tabs (shortcut)', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - version: '0.0.0.0', + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(2)); + expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(2)); - final editableTabBar = find.byType(EditableTabBar); + final editableTabBar = find.byType(EditableTabBar); - expect(editableTabBar, findsOneWidget); + expect(editableTabBar, findsOneWidget); - editableTabBarWidget() => - (editableTabBar.evaluate().first.widget as EditableTabBar); + editableTabBarWidget() => + (editableTabBar.evaluate().first.widget as EditableTabBar); - expect(editableTabBarWidget().currentIndex, 0); + expect(editableTabBarWidget().currentIndex, 0); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.control); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.control); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.arrowLeft); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.arrowLeft); - await widgetTester.pumpAndSettle(); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.arrowLeft); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.arrowLeft); + await widgetTester.pumpAndSettle(); - expect(editableTabBarWidget().currentIndex, 0, - reason: 'Tab index should not change since index is 0'); + expect(editableTabBarWidget().currentIndex, 0, + reason: 'Tab index should not change since index is 0'); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight); - await widgetTester.pumpAndSettle(); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight); + await widgetTester.pumpAndSettle(); - expect(editableTabBarWidget().currentIndex, 1); + expect(editableTabBarWidget().currentIndex, 1); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight); - await widgetTester.pumpAndSettle(); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight); + await widgetTester.pumpAndSettle(); - expect(editableTabBarWidget().currentIndex, 1, - reason: - 'Tab index should not change since index is equal to number of tabs'); + expect(editableTabBarWidget().currentIndex, 1, + reason: + 'Tab index should not change since index is equal to number of tabs'); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.arrowLeft); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.arrowLeft); - await widgetTester.pumpAndSettle(); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.arrowLeft); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.arrowLeft); + await widgetTester.pumpAndSettle(); - expect(editableTabBarWidget().currentIndex, 0); - }); + expect(editableTabBarWidget().currentIndex, 0); + }); + + testWidgets('Navigate tabs left right (shortcut)', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), + ), + ); + + await widgetTester.pumpAndSettle(); + + expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(2)); + + final editableTabBar = find.byType(EditableTabBar); + + expect(editableTabBar, findsOneWidget); + + editableTabBarWidget() => + (editableTabBar.evaluate().first.widget as EditableTabBar); + + expect(editableTabBarWidget().currentIndex, 0); + + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.control); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.shift); + + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.tab); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.tab); + await widgetTester.pumpAndSettle(); + + expect(editableTabBarWidget().currentIndex, 1, + reason: 'Tab index should roll over'); + + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.shift); + + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.tab); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.tab); + await widgetTester.pumpAndSettle(); + + expect(editableTabBarWidget().currentIndex, 0, + reason: 'Tab index should roll back over to 0'); + + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.tab); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.tab); + await widgetTester.pumpAndSettle(); + + expect(editableTabBarWidget().currentIndex, 1, + reason: 'Tab index should increase to 1 (no rollover)'); + + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.tab); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.tab); + await widgetTester.pumpAndSettle(); + + expect(editableTabBarWidget().currentIndex, 0); + }); + + testWidgets('Navigate to specific tabs', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), + ), + ); + + await widgetTester.pumpAndSettle(); + + expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(2)); + + final editableTabBar = find.byType(EditableTabBar); + + expect(editableTabBar, findsOneWidget); + + editableTabBarWidget() => + (editableTabBar.evaluate().first.widget as EditableTabBar); + + expect(editableTabBarWidget().currentIndex, 0); + + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.control); + + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.digit1); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.digit1); + await widgetTester.pumpAndSettle(); + + expect(editableTabBarWidget().currentIndex, 0, + reason: 'Tab index should remain at 0'); + + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.digit2); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.digit2); + await widgetTester.pumpAndSettle(); + + expect(editableTabBarWidget().currentIndex, 1); + + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.digit5); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.digit5); + await widgetTester.pumpAndSettle(); + + expect(editableTabBarWidget().currentIndex, 1, + reason: + 'Tab index should remain at 1 since there is no tab at index 4'); + }); - testWidgets('Navigate tabs left right (shortcut)', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + testWidgets('Renaming tab', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - version: '0.0.0.0', + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); - - await widgetTester.pumpAndSettle(); + ); - expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(2)); + await widgetTester.pumpAndSettle(); - final editableTabBar = find.byType(EditableTabBar); + final teleopTab = find.widgetWithText(AnimatedContainer, 'Teleoperated'); - expect(editableTabBar, findsOneWidget); + expect(teleopTab, findsOneWidget); - editableTabBarWidget() => - (editableTabBar.evaluate().first.widget as EditableTabBar); + await widgetTester.tap(teleopTab, buttons: kSecondaryButton); + await widgetTester.pumpAndSettle(); - expect(editableTabBarWidget().currentIndex, 0); + final renameButton = find.text('Rename'); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.control); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.shift); + expect(renameButton, findsOneWidget); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.tab); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.tab); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(renameButton); + await widgetTester.pumpAndSettle(); - expect(editableTabBarWidget().currentIndex, 1, - reason: 'Tab index should roll over'); + expect(find.text('Rename Tab'), findsOneWidget); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.shift); + final nameTextField = find.widgetWithText(DialogTextInput, 'Name'); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.tab); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.tab); - await widgetTester.pumpAndSettle(); + expect(nameTextField, findsOneWidget); - expect(editableTabBarWidget().currentIndex, 0, - reason: 'Tab index should roll back over to 0'); + await widgetTester.enterText(nameTextField, 'New Tab Name!'); + await widgetTester.testTextInput.receiveAction(TextInputAction.done); + await widgetTester.pump(); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.tab); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.tab); - await widgetTester.pumpAndSettle(); + final saveButton = find.widgetWithText(TextButton, 'Save'); - expect(editableTabBarWidget().currentIndex, 1, - reason: 'Tab index should increase to 1 (no rollover)'); + expect(saveButton, findsOneWidget); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.shift); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.tab); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.tab); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(saveButton); + await widgetTester.pumpAndSettle(); - expect(editableTabBarWidget().currentIndex, 0); - }); + expect( + find.widgetWithText(AnimatedContainer, 'Teleoperated'), findsNothing); + expect(find.widgetWithText(AnimatedContainer, 'New Tab Name!'), + findsOneWidget); + }); - testWidgets('Navigate to specific tabs', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + testWidgets('Duplicating tab', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - version: '0.0.0.0', + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(2)); + final teleopTab = find.widgetWithText(AnimatedContainer, 'Teleoperated'); - final editableTabBar = find.byType(EditableTabBar); + expect(teleopTab, findsOneWidget); - expect(editableTabBar, findsOneWidget); + await widgetTester.tap(teleopTab, buttons: kSecondaryButton); + await widgetTester.pumpAndSettle(); - editableTabBarWidget() => - (editableTabBar.evaluate().first.widget as EditableTabBar); + final duplicateButton = find.text('Duplicate'); - expect(editableTabBarWidget().currentIndex, 0); + expect(duplicateButton, findsOneWidget); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.control); + await widgetTester.tap(duplicateButton); + await widgetTester.pumpAndSettle(); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.digit1); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.digit1); - await widgetTester.pumpAndSettle(); + expect(find.text('Teleoperated (Copy)'), findsOneWidget); + }); + }); - expect(editableTabBarWidget().currentIndex, 0, - reason: 'Tab index should remain at 0'); + group('[Window Manipulation]:', () { + testWidgets('Minimizing window', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.digit2); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.digit2); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), + ), + ); - expect(editableTabBarWidget().currentIndex, 1); + await widgetTester.pumpAndSettle(); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.digit5); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.digit5); - await widgetTester.pumpAndSettle(); + final minimizeButton = find.ancestor( + of: find.byType(DecoratedMinimizeButton), + matching: find.byType(InkWell)); - expect(editableTabBarWidget().currentIndex, 1, - reason: - 'Tab index should remain at 1 since there is no tab at index 4'); - }); + expect(minimizeButton, findsOneWidget); - testWidgets('Renaming tab', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + await widgetTester.tap(minimizeButton); + }); - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - version: '0.0.0.0', + testWidgets('Maximizing/unmaximizing window', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - final teleopTab = find.widgetWithText(AnimatedContainer, 'Teleoperated'); + final appBar = find.byType(CustomAppBar); - expect(teleopTab, findsOneWidget); + expect(appBar, findsOneWidget); - await widgetTester.tap(teleopTab, buttons: kSecondaryButton); - await widgetTester.pumpAndSettle(); + await widgetTester.tapAt(const Offset(250, 0)); + await widgetTester.pump(kDoubleTapMinTime); + await widgetTester.tapAt(const Offset(250, 0)); - final renameButton = find.text('Rename'); + await widgetTester.pumpAndSettle(); - expect(renameButton, findsOneWidget); + final maximizeButton = find.ancestor( + of: find.byType(DecoratedMaximizeButton), + matching: find.byType(InkWell)); - await widgetTester.tap(renameButton); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(maximizeButton); + }); - expect(find.text('Rename Tab'), findsOneWidget); + testWidgets('Closing window (All changes saved)', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - final nameTextField = find.widgetWithText(DialogTextInput, 'Name'); + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), + ), + ); - expect(nameTextField, findsOneWidget); + await widgetTester.pumpAndSettle(); - await widgetTester.enterText(nameTextField, 'New Tab Name!'); - await widgetTester.testTextInput.receiveAction(TextInputAction.done); - await widgetTester.pump(); + final gyroWidget = find.widgetWithText(WidgetContainer, 'Test Gyro'); - final saveButton = find.widgetWithText(TextButton, 'Save'); + expect(gyroWidget, findsOneWidget); - expect(saveButton, findsOneWidget); + // Drag to a location + await widgetTester.drag(gyroWidget, const Offset(256, -128)); + await widgetTester.pumpAndSettle(); - await widgetTester.tap(saveButton); - await widgetTester.pumpAndSettle(); + // Drag back to its original location + await widgetTester.drag(gyroWidget, const Offset(-256, 128)); + await widgetTester.pumpAndSettle(); - expect( - find.widgetWithText(AnimatedContainer, 'Teleoperated'), findsNothing); - expect(find.widgetWithText(AnimatedContainer, 'New Tab Name!'), - findsOneWidget); - }); + final closeButton = find.ancestor( + of: find.byType(DecoratedCloseButton), + matching: find.byType(InkWell)); - testWidgets('Duplicating tab', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + await widgetTester.tap(closeButton); + await widgetTester.pumpAndSettle(); - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - version: '0.0.0.0', - ), - ), - ); + expect(find.widgetWithText(AlertDialog, 'Unsaved Changes'), findsNothing); + }); - await widgetTester.pumpAndSettle(); + testWidgets('Closing window (Unsaved changes)', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - final teleopTab = find.widgetWithText(AnimatedContainer, 'Teleoperated'); + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), + ), + ); - expect(teleopTab, findsOneWidget); + await widgetTester.pumpAndSettle(); - await widgetTester.tap(teleopTab, buttons: kSecondaryButton); - await widgetTester.pumpAndSettle(); + final gyroWidget = find.widgetWithText(WidgetContainer, 'Test Gyro'); - final duplicateButton = find.text('Duplicate'); + expect(gyroWidget, findsOneWidget); - expect(duplicateButton, findsOneWidget); + // Drag to a location + await widgetTester.drag(gyroWidget, const Offset(256, -128)); - await widgetTester.tap(duplicateButton); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(find.text('Teleoperated (Copy)'), findsOneWidget); - }); + final closeButton = find.ancestor( + of: find.byType(DecoratedCloseButton), + matching: find.byType(InkWell)); - testWidgets('Minimizing window', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + expect(closeButton, findsOneWidget); - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - version: '0.0.0.0', - ), - ), - ); + await widgetTester.tap(closeButton); + await widgetTester.pumpAndSettle(); - await widgetTester.pumpAndSettle(); + expect( + find.widgetWithText(AlertDialog, 'Unsaved Changes'), findsOneWidget); - final minimizeButton = find.ancestor( - of: find.byType(DecoratedMinimizeButton), - matching: find.byType(InkWell)); + final discardButton = find.widgetWithText(TextButton, 'Discard'); - expect(minimizeButton, findsOneWidget); + expect(discardButton, findsOneWidget); - await widgetTester.tap(minimizeButton); + await widgetTester.tap(discardButton); + await widgetTester.pumpAndSettle(); + }); }); - testWidgets('Maximizing/unmaximizing window', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + group('[Misc Shortcuts]:', () { + testWidgets('Opening settings', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - version: '0.0.0.0', + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - final appBar = find.byType(CustomAppBar); + final settingsButton = + find.widgetWithIcon(MenuItemButton, Icons.settings); - expect(appBar, findsOneWidget); + expect(settingsButton, findsOneWidget); - await widgetTester.tapAt(const Offset(250, 0)); - await widgetTester.pump(kDoubleTapMinTime); - await widgetTester.tapAt(const Offset(250, 0)); + final settingsButtonWidget = + settingsButton.evaluate().first.widget as MenuItemButton; - await widgetTester.pumpAndSettle(); + settingsButtonWidget.onPressed?.call(); - final maximizeButton = find.ancestor( - of: find.byType(DecoratedMaximizeButton), - matching: find.byType(InkWell)); + await widgetTester.pumpAndSettle(); - await widgetTester.tap(maximizeButton); - }); + expect(find.byType(SettingsDialog), findsOneWidget); + }); - testWidgets('Closing window (All changes saved)', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + testWidgets('Opening settings (shortcut)', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - version: '0.0.0.0', + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - final gyroWidget = find.widgetWithText(WidgetContainer, 'Test Gyro'); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.control); - expect(gyroWidget, findsOneWidget); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.comma); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.comma); - // Drag to a location - await widgetTester.drag(gyroWidget, const Offset(256, -128)); - await widgetTester.pumpAndSettle(); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.control); - // Drag back to its original location - await widgetTester.drag(gyroWidget, const Offset(-256, 128)); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - final closeButton = find.ancestor( - of: find.byType(DecoratedCloseButton), matching: find.byType(InkWell)); + expect(find.byType(SettingsDialog), findsOneWidget); + }); - await widgetTester.tap(closeButton); - await widgetTester.pumpAndSettle(); + testWidgets('IP Address shortcuts', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - expect(find.widgetWithText(AlertDialog, 'Unsaved Changes'), findsNothing); - }); + SharedPreferences.setMockInitialValues({ + PrefKeys.ipAddressMode: IPAddressMode.custom.index, + PrefKeys.ipAddress: '127.0.0.1', + PrefKeys.teamNumber: 353, + }); - testWidgets('Closing window (Unsaved changes)', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + MockNTConnection ntConnection = createMockOfflineNT4(); + MockDSInteropClient dsClient = MockDSInteropClient(); + when(dsClient.lastAnnouncedIP).thenReturn(null); + when(ntConnection.dsClient).thenReturn(dsClient); - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - version: '0.0.0.0', + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: ntConnection, + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - final gyroWidget = find.widgetWithText(WidgetContainer, 'Test Gyro'); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.control); - expect(gyroWidget, findsOneWidget); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.keyK); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.keyK); - // Drag to a location - await widgetTester.drag(gyroWidget, const Offset(256, -128)); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.control); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - final closeButton = find.ancestor( - of: find.byType(DecoratedCloseButton), matching: find.byType(InkWell)); + expect(preferences.getInt(PrefKeys.ipAddressMode), + IPAddressMode.driverStation.index); + expect(preferences.getString(PrefKeys.ipAddress), '10.3.53.2'); - expect(closeButton, findsOneWidget); + await preferences.setString(PrefKeys.ipAddress, '0.0.0.0'); - await widgetTester.tap(closeButton); - await widgetTester.pumpAndSettle(); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.control); - expect(find.widgetWithText(AlertDialog, 'Unsaved Changes'), findsOneWidget); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.keyK); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.keyK); - final discardButton = find.widgetWithText(TextButton, 'Discard'); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.control); - expect(discardButton, findsOneWidget); + await widgetTester.pumpAndSettle(); - await widgetTester.tap(discardButton); - await widgetTester.pumpAndSettle(); - }); + // IP Address shouldn't change since it's already driver station + expect(preferences.getInt(PrefKeys.ipAddressMode), + IPAddressMode.driverStation.index); + expect(preferences.getString(PrefKeys.ipAddress), '0.0.0.0'); - testWidgets('Opening settings', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.control); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.shift); - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - version: '0.0.0.0', - ), - ), - ); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.keyK); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.keyK); - await widgetTester.pumpAndSettle(); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.control); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.shift); + + await widgetTester.pumpAndSettle(); - final settingsButton = find.widgetWithIcon(MenuItemButton, Icons.settings); + expect(preferences.getInt(PrefKeys.ipAddressMode), + IPAddressMode.localhost.index); + expect(preferences.getString(PrefKeys.ipAddress), 'localhost'); - expect(settingsButton, findsOneWidget); + await preferences.setString(PrefKeys.ipAddress, '0.0.0.0'); - final settingsButtonWidget = - settingsButton.evaluate().first.widget as MenuItemButton; + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.control); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.shift); - settingsButtonWidget.onPressed?.call(); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.keyK); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.keyK); - await widgetTester.pumpAndSettle(); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.control); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.shift); + + await widgetTester.pumpAndSettle(); - expect(find.byType(SettingsDialog), findsOneWidget); + // IP address shouldn't change since mode is set to localhost + expect(preferences.getInt(PrefKeys.ipAddressMode), + IPAddressMode.localhost.index); + expect(preferences.getString(PrefKeys.ipAddress), '0.0.0.0'); + }); }); - testWidgets( - 'Robot Notifications', - (widgetTester) async { + group('[Notifications]:', () { + testWidgets('Robot Notifications', (widgetTester) async { FlutterError.onError = ignoreOverflowErrors; final Map data = { 'title': 'Robot Notification Title', 'description': 'Robot Notification Description', - 'level': 'INFO' + 'level': 'INFO', + 'displayTime': 350, + 'width': 300.0, + 'height': 300.0, }; MockNTConnection connection = createMockOnlineNT4( @@ -1227,6 +2164,7 @@ void main() { ntConnection: connection, preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -1248,6 +2186,31 @@ void main() { connection .subscribeAll('/Elastic/RobotNotifications', 0.2) .updateValue(jsonEncode(data), 1); - }, - ); + }); + + testWidgets('Update Notification', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker( + updateAvailable: true, latestVersion: '2025.0.1'), + ), + ), + ); + + await widgetTester.pumpAndSettle(); + + final notificationWidget = find.widgetWithText( + ElegantNotification, 'Version 2025.0.1 Available'); + final notificationIcon = find.byIcon(Icons.update); + + expect(notificationWidget, findsOneWidget); + expect(notificationIcon, findsOneWidget); + }); + }); } diff --git a/test/services/elastic_layout_downloader_test.dart b/test/services/elastic_layout_downloader_test.dart new file mode 100644 index 00000000..0641bcc1 --- /dev/null +++ b/test/services/elastic_layout_downloader_test.dart @@ -0,0 +1,256 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart'; +import 'package:mockito/mockito.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:elastic_dashboard/services/elastic_layout_downloader.dart'; +import 'package:elastic_dashboard/services/nt_connection.dart'; +import 'package:elastic_dashboard/services/settings.dart'; +import '../test_util.dart'; +import '../test_util.mocks.dart'; + +const Map layoutFiles = { + 'dirs': [], + 'files': [ + { + 'name': 'elastic-layout 1.json', + 'size': 1000, + }, + { + 'name': 'elastic-layout 2.json', + 'size': 1000, + }, + { + 'name': 'example.txt', + 'size': 1, + }, + ], +}; + +const Map layoutOne = { + 'version': 1.0, + 'grid_size': 128.0, + 'tabs': [ + { + 'name': 'Test Tab', + 'grid_layout': { + 'layouts': [ + { + 'title': 'Subsystem', + 'x': 384.0, + 'y': 128.0, + 'width': 256.0, + 'height': 384.0, + 'type': 'List Layout', + 'properties': {'label_position': 'TOP'}, + 'children': [ + { + 'title': 'ExampleSubsystem', + 'x': 0.0, + 'y': 0.0, + 'width': 256.0, + 'height': 128.0, + 'type': 'Subsystem', + 'properties': { + 'topic': '/Test Tab/ExampleSubsystem', + 'period': 0.06 + }, + }, + { + 'title': 'Gyro', + 'x': 0.0, + 'y': 0.0, + 'width': 256.0, + 'height': 256.0, + 'type': 'Gyro', + 'properties': { + 'topic': '/Test Tab/Gyro', + 'period': 0.06, + 'counter_clockwise_positive': false + }, + }, + ] + }, + { + 'title': 'Empty Layout', + 'x': 640.0, + 'y': 0.0, + 'width': 256.0, + 'height': 256.0, + 'type': 'List Layout', + 'properties': {'label_position': 'TOP'}, + 'children': [], + }, + ], + 'containers': [ + { + 'title': 'Test Widget', + 'x': 128.0, + 'y': 128.0, + 'width': 256.0, + 'height': 256.0, + 'type': 'Gyro', + 'properties': { + 'topic': '/Test Tab/Gyro', + 'period': 0.06, + 'counter_clockwise_positive': false + }, + }, + ], + }, + }, + ], +}; + +void main() { + late SharedPreferences preferences; + late NTConnection ntConnection; + + setUpAll(() async { + SharedPreferences.setMockInitialValues({ + PrefKeys.ipAddress: '127.0.0.1', + PrefKeys.layoutLocked: false, + }); + + preferences = await SharedPreferences.getInstance(); + + ntConnection = createMockOnlineNT4(); + }); + + test('Get list of available layouts', () async { + Client mockClient = createHttpClient( + mockGetResponses: { + 'http://127.0.0.1:5800/?format=json': + Response(jsonEncode(layoutFiles), 200), + }, + ); + + ElasticLayoutDownloader layoutDownloader = + ElasticLayoutDownloader(mockClient); + + LayoutDownloadResponse downloadResponse = + await layoutDownloader.getAvailableLayouts( + ntConnection: ntConnection, preferences: preferences); + + expect(downloadResponse.successful, isTrue); + expect( + downloadResponse.data, + unorderedEquals([ + 'elastic-layout 1', + 'elastic-layout 2', + ])); + }); + + test('Download layout', () async { + Client mockClient = createHttpClient( + mockGetResponses: { + 'http://127.0.0.1:5800/${Uri.encodeComponent('elastic-layout 1.json')}': + Response(jsonEncode(layoutOne), 200), + }, + ); + + ElasticLayoutDownloader layoutDownloader = + ElasticLayoutDownloader(mockClient); + + LayoutDownloadResponse downloadResponse = + await layoutDownloader.downloadLayout( + ntConnection: ntConnection, + preferences: preferences, + layoutName: 'elastic-layout 1', + ); + + expect(downloadResponse.successful, isTrue); + expect(downloadResponse.data, jsonEncode(layoutOne)); + }); + + group('Unsuccessful if', () { + test('network tables is disconnected', () async { + Client mockClient = createHttpClient( + mockGetResponses: { + 'http://127.0.0.1:5800/${Uri.encodeComponent('elastic-layout 1.json')}': + Response(jsonEncode(layoutOne), 200), + }, + ); + + ElasticLayoutDownloader layoutDownloader = + ElasticLayoutDownloader(mockClient); + + LayoutDownloadResponse downloadResponse = + await layoutDownloader.downloadLayout( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + layoutName: 'elastic-layout 1', + ); + + expect(downloadResponse.successful, false); + expect(downloadResponse.data, + 'Cannot download a remote layout while disconnected from the robot.'); + }); + + test('client response throws an error', () async { + MockClient mockClient = MockClient(); + when(mockClient.get(any)) + .thenAnswer((_) => throw ClientException('Client Exception')); + + ElasticLayoutDownloader layoutDownloader = + ElasticLayoutDownloader(mockClient); + + LayoutDownloadResponse downloadResponse = + await layoutDownloader.downloadLayout( + ntConnection: createMockOnlineNT4(), + preferences: preferences, + layoutName: 'elastic-layout 1', + ); + + expect(downloadResponse.successful, false); + expect(downloadResponse.data, 'Client Exception'); + }); + + test('file is not found', () async { + Client mockClient = createHttpClient( + mockGetResponses: { + 'http://127.0.0.1:5800/${Uri.encodeComponent('elastic-layout 1.json')}': + Response(jsonEncode(layoutOne), 404), + }, + ); + + ElasticLayoutDownloader layoutDownloader = + ElasticLayoutDownloader(mockClient); + + LayoutDownloadResponse downloadResponse = + await layoutDownloader.downloadLayout( + ntConnection: createMockOnlineNT4(), + preferences: preferences, + layoutName: 'elastic-layout 1', + ); + + expect(downloadResponse.successful, false); + expect( + downloadResponse.data, 'File "elastic-layout 1.json" was not found'); + }); + + test('http request gives invalid status code', () async { + Client mockClient = createHttpClient( + mockGetResponses: { + 'http://127.0.0.1:5800/${Uri.encodeComponent('elastic-layout 1.json')}': + Response(jsonEncode(layoutOne), 353), + }, + ); + + ElasticLayoutDownloader layoutDownloader = + ElasticLayoutDownloader(mockClient); + + LayoutDownloadResponse downloadResponse = + await layoutDownloader.downloadLayout( + ntConnection: createMockOnlineNT4(), + preferences: preferences, + layoutName: 'elastic-layout 1', + ); + + expect(downloadResponse.successful, false); + expect(downloadResponse.data, 'Request returned status code 353'); + }); + }); +} diff --git a/test/services/robot_notifications_listener_test.dart b/test/services/robot_notifications_listener_test.dart index d5508539..95fc7329 100644 --- a/test/services/robot_notifications_listener_test.dart +++ b/test/services/robot_notifications_listener_test.dart @@ -10,11 +10,12 @@ import '../test_util.dart'; import '../test_util.mocks.dart'; class MockNotificationCallback extends Mock { - void call(String? title, String? description, Icon? icon); + void call(String? title, String? description, Icon? icon, Duration time, + double width, double? height); } void main() { - test("Robot Notifications (Initial Connection | No Existing Data) ", () { + test('Robot Notifications (Initial Connection | No Existing Data) ', () { MockNTConnection mockConnection = createMockOnlineNT4(); // Create a mock for the onNotification callback @@ -36,17 +37,21 @@ void main() { verifyNoMoreInteractions(mockConnection); // Verify that the onNotification callback was never called - verifyNever(mockOnNotification.call(any, any, any)); + verifyNever(mockOnNotification.call( + any, any, any, const Duration(seconds: 3), 350, 300.0)); }); - test("Robot Notifications (Initial Connection | Old Existing Data) ", () { + test('Robot Notifications (Initial Connection | Old Existing Data) ', () { MockNTConnection mockConnection = createMockOnlineNT4(serverTime: 5000000); MockNT4Subscription mockSub = MockNT4Subscription(); Map data = { 'title': 'Title1', 'description': 'Description1', - 'level': 'Info' + 'level': 'Info', + 'width': 300.0, + 'height': 300.0, + 'displayTime': 3000 }; List listeners = []; @@ -89,7 +94,8 @@ void main() { verify(mockConnection.addDisconnectedListener(any)).called(1); // Verify that the onNotification callback was never called - verifyNever(mockOnNotification(any, any, any)); + verifyNever(mockOnNotification( + any, any, any, const Duration(seconds: 3), 350, any)); // Publish some data and expect an update data['title'] = 'Title2'; @@ -97,7 +103,13 @@ void main() { data['level'] = 'INFO'; mockSub.updateValue(jsonEncode(data), 2); - verify(mockOnNotification(data['title'], data['description'], any)); + verify(mockOnNotification( + data['title'], + data['description'], + any, + Duration(milliseconds: data['displayTime']), + data['width'], + data['height'])); // Try malformed data data['title'] = null; @@ -106,26 +118,32 @@ void main() { mockSub.updateValue(jsonEncode(data), 3); reset(mockOnNotification); - verifyNever(mockOnNotification(any, any, any)); + verifyNever(mockOnNotification( + any, any, any, const Duration(seconds: 3), 350, any)); // Try with missing data data.remove('level'); data['title'] = null; data['description'] = null; + data['height'] = null; mockSub.updateValue(jsonEncode(data), 4); reset(mockOnNotification); - verifyNever(mockOnNotification(any, any, any)); + verifyNever(mockOnNotification( + any, any, any, const Duration(seconds: 3), 350, any)); }); - test("Robot Notifications (Initial Connection | Newer Existing Data) ", () { + test('Robot Notifications (Initial Connection | Newer Existing Data) ', () { MockNTConnection mockConnection = createMockOnlineNT4(serverTime: 5000000); MockNT4Subscription mockSub = MockNT4Subscription(); Map data = { 'title': 'Title1', 'description': 'Description1', - 'level': 'Info' + 'level': 'Info', + 'width': 300.0, + 'height': null, + 'displayTime': 3000 }; List listeners = []; @@ -168,6 +186,7 @@ void main() { verify(mockConnection.addDisconnectedListener(any)).called(1); // Verify that the onNotification callback was called - verify(mockOnNotification(any, any, any)); + verify(mockOnNotification(any, any, any, + Duration(milliseconds: data['displayTime']), data['width'], any)); }); } diff --git a/test/services/shuffleboard_nt_listener_test.dart b/test/services/shuffleboard_nt_listener_test.dart index 9891c490..da34b293 100644 --- a/test/services/shuffleboard_nt_listener_test.dart +++ b/test/services/shuffleboard_nt_listener_test.dart @@ -1,3 +1,5 @@ +import 'package:flutter/foundation.dart'; + import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -5,6 +7,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:elastic_dashboard/services/nt4_client.dart'; import 'package:elastic_dashboard/services/settings.dart'; import 'package:elastic_dashboard/services/shuffleboard_nt_listener.dart'; +import '../test_util.dart'; import '../test_util.mocks.dart'; void main() { @@ -33,6 +36,7 @@ void main() { .thenAnswer((_) => Stream.value(null)); when(mockNT4Connection.isNT4Connected).thenReturn(true); + when(mockNT4Connection.ntConnected).thenReturn(ValueNotifier(true)); when(mockNT4Connection.latencyStream()).thenAnswer((_) => Stream.value(0)); @@ -49,8 +53,6 @@ void main() { when(mockNT4Connection.subscribe(any)).thenReturn(mockSubscription); - // NTConnection.instance = mockNT4Connection; - Map announcedWidgetData = {}; ShuffleboardNTListener ntListener = ShuffleboardNTListener( @@ -101,4 +103,33 @@ void main() { expect(announcedWidgetData['width'], Defaults.gridSize.toDouble() * 2.0); expect(announcedWidgetData['height'], Defaults.gridSize.toDouble() * 2.0); }); + + test('Tab selection change', () { + final ntConnection = createMockOnlineNT4( + virtualTopics: [ + NT4Topic( + name: '/Shuffleboard/.metadata/Selected', + type: NT4TypeStr.kString, + properties: {}, + ), + ], + ); + + String? selectedTab; + + ShuffleboardNTListener( + ntConnection: ntConnection, + preferences: preferences, + onTabChanged: (tab) { + selectedTab = tab; + }, + ) + ..initializeSubscriptions() + ..initializeListeners(); + + ntConnection.updateDataFromTopicName( + '/Shuffleboard/.metadata/Selected', 'Test Tab Selection'); + + expect(selectedTab, 'Test Tab Selection'); + }); } diff --git a/test/services/struct_schemas/pose2d_struct_test.dart b/test/services/struct_schemas/pose2d_struct_test.dart new file mode 100644 index 00000000..5c7e0aba --- /dev/null +++ b/test/services/struct_schemas/pose2d_struct_test.dart @@ -0,0 +1,177 @@ +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:elastic_dashboard/services/struct_schemas/pose2d_struct.dart'; + +void main() { + test('Pose2D struct with valid data', () { + List rawBytes = [ + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x14, + 0x40, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x14, + 0x40, + 0x18, + 0x2d, + 0x44, + 0x54, + 0xfb, + 0x21, + 0x09, + 0x40 + ]; + Uint8List data = Uint8List.fromList(rawBytes); + + Pose2dStruct pose2dStruct = Pose2dStruct.valueFromBytes(data); + + expect(pose2dStruct.x, 5.0); + expect(pose2dStruct.y, 5.0); + expect(pose2dStruct.angle, pi); + }); + + test('Pose2D struct with missing bytes', () { + List rawBytes = [ + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x14, + 0x40, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x14, + 0x40, + 0x18, + 0x2d, + 0x44, + 0x54 + ]; + Uint8List data = Uint8List.fromList(rawBytes); + + Pose2dStruct pose2dStruct = Pose2dStruct.valueFromBytes(data); + + expect(pose2dStruct.x, 5.0); + expect(pose2dStruct.y, 5.0); + expect(pose2dStruct.angle, 0.0); + }); + + test('Pose2D struct with no bytes', () { + List rawBytes = []; + Uint8List data = Uint8List.fromList(rawBytes); + + Pose2dStruct pose2dStruct = Pose2dStruct.valueFromBytes(data); + + expect(pose2dStruct.x, 0.0); + expect(pose2dStruct.y, 0.0); + expect(pose2dStruct.angle, 0.0); + }); + + test('Pose2D array with valid data', () { + List rawBytes = [ + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x14, + 0x40, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x14, + 0x40, + 0x18, + 0x2d, + 0x44, + 0x54, + 0xfb, + 0x21, + 0x09, + 0x40 + ]; + Uint8List data = + Uint8List.fromList([...rawBytes, ...rawBytes, ...rawBytes]); + + List poseList = Pose2dStruct.listFromBytes(data); + expect(poseList.length, 3); + + for (Pose2dStruct pose in poseList) { + expect(pose.x, 5.0); + expect(pose.y, 5.0); + expect(pose.angle, pi); + } + }); + + test('Pose2D array with missing data', () { + List rawBytes = [ + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x14, + 0x40, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x14, + 0x40, + 0x18, + 0x2d, + 0x44, + 0x54, + 0xfb, + 0x21, + 0x09, + 0x40 + ]; + Uint8List data = Uint8List.fromList([ + ...rawBytes, + ...rawBytes, + ...rawBytes, + 0x00, + 0x00, + 0x00, + 0x00, + 0x14, + 0x40, + 0x00 + ]); + + List poseList = Pose2dStruct.listFromBytes(data); + expect(poseList.length, 3); + + for (Pose2dStruct pose in poseList) { + expect(pose.x, 5.0); + expect(pose.y, 5.0); + expect(pose.angle, pi); + } + }); +} diff --git a/test/services/struct_schemas/swerve_module_state_struct_test.dart b/test/services/struct_schemas/swerve_module_state_struct_test.dart new file mode 100644 index 00000000..fba098f3 --- /dev/null +++ b/test/services/struct_schemas/swerve_module_state_struct_test.dart @@ -0,0 +1,138 @@ +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:elastic_dashboard/services/struct_schemas/swerve_module_state_struct.dart'; + +void main() { + test('Module state with valid data', () { + List rawBytes = [ + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x14, + 0x40, + 0x18, + 0x2d, + 0x44, + 0x54, + 0xfb, + 0x21, + 0xf9, + 0x3f + ]; + Uint8List data = Uint8List.fromList(rawBytes); + + SwerveModuleStateStruct moduleStateStruct = + SwerveModuleStateStruct.valueFromBytes(data); + + expect(moduleStateStruct.speed, 5.0); + expect(moduleStateStruct.angle, pi / 2); + }); + + test('Module state with missing bytes', () { + List rawBytes = [ + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x14, + 0x40, + 0x18, + 0x2d, + 0x44, + 0x54, + 0xfb + ]; + Uint8List data = Uint8List.fromList(rawBytes); + + SwerveModuleStateStruct moduleStateStruct = + SwerveModuleStateStruct.valueFromBytes(data); + + expect(moduleStateStruct.speed, 5.0); + expect(moduleStateStruct.angle, 0.0); + }); + + test('Module state with no bytes', () { + List rawBytes = []; + Uint8List data = Uint8List.fromList(rawBytes); + + SwerveModuleStateStruct moduleStateStruct = + SwerveModuleStateStruct.valueFromBytes(data); + + expect(moduleStateStruct.speed, 0.0); + expect(moduleStateStruct.angle, 0.0); + }); + + test('Module state array with valid data', () { + List rawBytes = [ + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x14, + 0x40, + 0x18, + 0x2d, + 0x44, + 0x54, + 0xfb, + 0x21, + 0xf9, + 0x3f + ]; + Uint8List data = + Uint8List.fromList([...rawBytes, ...rawBytes, ...rawBytes]); + + List moduleStateStruct = + SwerveModuleStateStruct.listFromBytes(data); + + expect(moduleStateStruct.length, 3); + + for (SwerveModuleStateStruct moduleStateStruct in moduleStateStruct) { + expect(moduleStateStruct.speed, 5.0); + expect(moduleStateStruct.angle, pi / 2); + } + }); + + test('Module state array with missing data', () { + List rawBytes = [ + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x14, + 0x40, + 0x18, + 0x2d, + 0x44, + 0x54, + 0xfb, + 0x21, + 0xf9, + 0x3f + ]; + Uint8List data = Uint8List.fromList( + [...rawBytes, ...rawBytes, ...rawBytes, 0x00, 0x00, 0x14, 0x40]); + + List moduleStateStruct = + SwerveModuleStateStruct.listFromBytes(data); + + expect(moduleStateStruct.length, 3); + + for (SwerveModuleStateStruct moduleStateStruct in moduleStateStruct) { + expect(moduleStateStruct.speed, 5.0); + expect(moduleStateStruct.angle, pi / 2); + } + }); +} diff --git a/test/test_util.dart b/test/test_util.dart index 226bd5d9..329e6ae6 100644 --- a/test/test_util.dart +++ b/test/test_util.dart @@ -2,17 +2,21 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:http/http.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; +import 'package:elastic_dashboard/services/ds_interop.dart'; import 'package:elastic_dashboard/services/nt4_client.dart'; import 'package:elastic_dashboard/services/nt_connection.dart'; +import 'package:elastic_dashboard/services/update_checker.dart'; import 'test_util.mocks.dart'; @GenerateNiceMocks([ MockSpec(), MockSpec(), - MockSpec() + MockSpec(), + MockSpec(), ]) MockNTConnection createMockOfflineNT4() { HttpOverrides.global = null; @@ -24,8 +28,9 @@ MockNTConnection createMockOfflineNT4() { when(mockSubscription.periodicStream()).thenAnswer((_) => Stream.value(null)); - when(mockSubscription.listen(any)).thenAnswer((realInvocation) {}); + when(mockSubscription.listen(any)).thenAnswer((invocation) {}); + when(mockNT4Connection.ntConnected).thenReturn(ValueNotifier(false)); when(mockNT4Connection.isNT4Connected).thenReturn(false); when(mockNT4Connection.serverTime).thenReturn(0); @@ -33,8 +38,8 @@ MockNTConnection createMockOfflineNT4() { when(mockNT4Connection.connectionStatus()) .thenAnswer((_) => Stream.value(false)); - when(mockNT4Connection.dsConnectionStatus()) - .thenAnswer((_) => Stream.value(false)); + when(mockNT4Connection.dsConnected).thenReturn(ValueNotifier(false)); + when(mockNT4Connection.isDSConnected).thenReturn(false); when(mockNT4Connection.latencyStream()).thenAnswer((_) => Stream.value(0)); @@ -78,18 +83,26 @@ MockNTConnection createMockOnlineNT4({ Map virtualTopicsMap = {}; - List subscriptionListeners = []; - for (int i = 0; i < virtualTopics.length; i++) { virtualTopicsMap.addAll({i + 1: virtualTopics[i]}); } + List publishedTopics = []; + when(mockNT4Connection.announcedTopics()).thenReturn(virtualTopicsMap); + when(mockNT4Connection.addTopicAnnounceListener(any)) + .thenAnswer((invocation) { + for (NT4Topic topic in virtualTopics!) { + invocation.positionalArguments[0].call(topic); + } + }); + when(mockSubscription.periodicStream()).thenAnswer((_) => Stream.value(null)); - when(mockSubscription.listen(any)).thenAnswer((realInvocation) {}); + when(mockSubscription.listen(any)).thenAnswer((_) {}); + when(mockNT4Connection.ntConnected).thenReturn(ValueNotifier(true)); when(mockNT4Connection.isNT4Connected).thenReturn(true); when(mockNT4Connection.serverTime).thenReturn(serverTime); @@ -97,8 +110,8 @@ MockNTConnection createMockOnlineNT4({ when(mockNT4Connection.connectionStatus()) .thenAnswer((_) => Stream.value(true)); - when(mockNT4Connection.dsConnectionStatus()) - .thenAnswer((_) => Stream.value(true)); + when(mockNT4Connection.dsConnected).thenReturn(ValueNotifier(true)); + when(mockNT4Connection.isDSConnected).thenReturn(true); when(mockNT4Connection.latencyStream()).thenAnswer((_) => Stream.value(0)); @@ -119,9 +132,22 @@ MockNTConnection createMockOnlineNT4({ properties: {}); virtualTopicsMap[virtualTopicsMap.length] = newTopic; + publishedTopics.add(newTopic); return newTopic; }); + when(mockNT4Connection.publishTopic(any)).thenAnswer((invocation) { + publishedTopics.add(invocation.positionalArguments[0]); + }); + + when(mockNT4Connection.unpublishTopic(any)).thenAnswer((invocation) { + publishedTopics.remove(invocation.positionalArguments[0]); + }); + + when(mockNT4Connection.isTopicPublished(any)).thenAnswer((invocation) { + return publishedTopics.contains(invocation.positionalArguments[0]); + }); + when(mockNT4Connection.updateDataFromTopic(any, any)) .thenAnswer((invocation) { NT4Topic topic = invocation.positionalArguments[0]; @@ -139,39 +165,115 @@ MockNTConnection createMockOnlineNT4({ }); for (NT4Topic topic in virtualTopics) { + List subscriptionListeners = []; + List subscriptionNotifiers = []; + MockNT4Subscription topicSubscription = MockNT4Subscription(); + when(topicSubscription.value).thenAnswer((_) { + return virtualValues![topic.name]; + }); + + when(topicSubscription.value = any).thenAnswer((invocation) { + virtualValues![topic.name] = invocation.positionalArguments[0]; + for (var notifier in subscriptionNotifiers) { + notifier.call(); + } + }); + + when(topicSubscription.addListener(any)).thenAnswer((invocation) { + subscriptionNotifiers.add(invocation.positionalArguments[0]); + }); + + when(topicSubscription.removeListener(any)).thenAnswer((invocation) { + subscriptionNotifiers.remove(invocation.positionalArguments[0]); + }); + + when(mockNT4Connection.updateDataFromTopic(topic, any)) + .thenAnswer((invocation) { + virtualValues![topic.name] = invocation.positionalArguments[1]; + topicSubscription.value = invocation.positionalArguments[1]; + }); + + when(mockNT4Connection.updateDataFromTopicName(topic.name, any)) + .thenAnswer((invocation) { + virtualValues![topic.name] = invocation.positionalArguments[1]; + topicSubscription.value = invocation.positionalArguments[1]; + }); + + when(mockNT4Connection.updateDataFromSubscription(topicSubscription, any)) + .thenAnswer((invocation) { + virtualValues![topic.name] = invocation.positionalArguments[1]; + topicSubscription.value = invocation.positionalArguments[1]; + }); + when(mockNT4Connection.getTopicFromName(topic.name)).thenReturn(topic); when(topicSubscription.periodicStream(yieldAll: anyNamed('yieldAll'))) - .thenAnswer((_) => Stream.value(virtualValues?[topic.name])); + .thenAnswer((_) => Stream.value(virtualValues![topic.name])); - when(topicSubscription.listen(any)).thenAnswer((realInvocation) { - subscriptionListeners.add(realInvocation.positionalArguments[0]); + when(topicSubscription.listen(any)).thenAnswer((invocation) { + subscriptionListeners.add(invocation.positionalArguments[0]); }); when(topicSubscription.updateValue(any, any)).thenAnswer( - (invoc) { + (invocation) { for (var value in subscriptionListeners) { - value.call( - invoc.positionalArguments[0], invoc.positionalArguments[1]); + value.call(invocation.positionalArguments[0], + invocation.positionalArguments[1]); } + virtualValues![topic.name] = invocation.positionalArguments[1]; + topicSubscription.value = invocation.positionalArguments[1]; }, ); when(mockNT4Connection.getLastAnnouncedValue(topic.name)) - .thenAnswer((_) => virtualValues?[topic.name]); + .thenAnswer((_) => virtualValues![topic.name]); when(mockNT4Connection.subscribe(topic.name, any)) - .thenReturn(topicSubscription); + .thenAnswer((_) => topicSubscription); when(mockNT4Connection.subscribeAll(topic.name, any)) - .thenReturn(topicSubscription); + .thenAnswer((_) => topicSubscription); } return mockNT4Connection; } +@GenerateNiceMocks([ + MockSpec(), +]) +MockUpdateChecker createMockUpdateChecker( + {bool updateAvailable = false, String latestVersion = '0.0.0.0'}) { + MockUpdateChecker updateChecker = MockUpdateChecker(); + + when(updateChecker.isUpdateAvailable()).thenAnswer( + (_) => Future.value( + UpdateCheckerResponse( + updateAvailable: updateAvailable, + error: false, + latestVersion: latestVersion), + ), + ); + + return updateChecker; +} + +@GenerateNiceMocks([ + MockSpec(), +]) +MockClient createHttpClient({Map? mockGetResponses}) { + MockClient mockClient = MockClient(); + + if (mockGetResponses != null) { + for (MapEntry mockRequest in mockGetResponses.entries) { + when(mockClient.get(Uri.parse(mockRequest.key))) + .thenAnswer((_) => Future.value(mockRequest.value)); + } + } + return mockClient; +} + void ignoreOverflowErrors( FlutterErrorDetails details, { bool forceReport = false, diff --git a/test/widgets/dialog_widgets/dialog_color_picker_test.dart b/test/widgets/dialog_widgets/dialog_color_picker_test.dart new file mode 100644 index 00000000..8c1c1a26 --- /dev/null +++ b/test/widgets/dialog_widgets/dialog_color_picker_test.dart @@ -0,0 +1,162 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; + +import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_color_picker.dart'; +import '../../test_util.dart'; + +class MockColorCallback extends Mock { + void onColorChanged(Color? color); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('Color picker select', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + MockColorCallback mockCallback = MockColorCallback(); + + Color? calledBackColor; + + when(mockCallback.onColorChanged(any)).thenAnswer((realInvocation) { + calledBackColor = realInvocation.positionalArguments[0]; + }); + + await widgetTester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DialogColorPicker( + onColorPicked: mockCallback.onColorChanged, + label: 'Color Picker', + initialColor: Colors.green, + ), + ), + ), + ); + + await widgetTester.pumpAndSettle(); + + expect(find.text('Color Picker'), findsOneWidget); + expect(find.byType(ElevatedButton), findsOneWidget); + + await widgetTester.tap(find.byType(ElevatedButton)); + await widgetTester.pumpAndSettle(); + + expect(find.text('Cancel'), findsOneWidget); + expect(find.text('Restore Default'), findsNothing); + expect(find.text('Save'), findsOneWidget); + + final hexInput = find.widgetWithText(TextField, 'Hex Code'); + + expect(hexInput, findsOneWidget); + + await widgetTester.enterText(hexInput, '0000FF'); + await widgetTester.testTextInput.receiveAction(TextInputAction.done); + await widgetTester.pumpAndSettle(); + + expect(calledBackColor, isNull); + + await widgetTester.tap(find.text('Save')); + await widgetTester.pumpAndSettle(); + + expect(calledBackColor, isNotNull); + expect(calledBackColor!.value, 0xFF0000FF); + }); + + testWidgets('Color picker cancel', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + MockColorCallback mockCallback = MockColorCallback(); + + Color? calledBackColor; + + when(mockCallback.onColorChanged(any)).thenAnswer((realInvocation) { + calledBackColor = realInvocation.positionalArguments[0]; + }); + + await widgetTester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DialogColorPicker( + onColorPicked: mockCallback.onColorChanged, + label: 'Color Picker', + initialColor: Colors.green, + ), + ), + ), + ); + + await widgetTester.pumpAndSettle(); + + expect(find.text('Color Picker'), findsOneWidget); + expect(find.byType(ElevatedButton), findsOneWidget); + + await widgetTester.tap(find.byType(ElevatedButton)); + await widgetTester.pumpAndSettle(); + + expect(find.text('Cancel'), findsOneWidget); + expect(find.text('Restore Default'), findsNothing); + expect(find.text('Save'), findsOneWidget); + + final hexInput = find.widgetWithText(TextField, 'Hex Code'); + + expect(hexInput, findsOneWidget); + + await widgetTester.enterText(hexInput, '0000FF'); + await widgetTester.testTextInput.receiveAction(TextInputAction.done); + await widgetTester.pumpAndSettle(); + + expect(calledBackColor, isNull); + + await widgetTester.tap(find.text('Cancel')); + await widgetTester.pumpAndSettle(); + + expect(calledBackColor, isNotNull); + expect(calledBackColor!.value, Colors.green.value); + }); + + testWidgets('Color picker restore default', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + MockColorCallback mockCallback = MockColorCallback(); + + Color? calledBackColor; + + when(mockCallback.onColorChanged(any)).thenAnswer((realInvocation) { + calledBackColor = realInvocation.positionalArguments[0]; + }); + + await widgetTester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DialogColorPicker( + onColorPicked: mockCallback.onColorChanged, + label: 'Color Picker', + initialColor: Colors.green, + defaultColor: Colors.red, + ), + ), + ), + ); + + await widgetTester.pumpAndSettle(); + + expect(find.text('Color Picker'), findsOneWidget); + expect(find.byType(ElevatedButton), findsOneWidget); + + await widgetTester.tap(find.byType(ElevatedButton)); + await widgetTester.pumpAndSettle(); + + expect(find.text('Cancel'), findsOneWidget); + expect(find.text('Restore Default'), findsOneWidget); + expect(find.text('Save'), findsOneWidget); + + await widgetTester.tap(find.text('Restore Default')); + await widgetTester.pumpAndSettle(); + + expect(calledBackColor, isNotNull); + expect(calledBackColor!.value, Colors.red.value); + }); +} diff --git a/test/widgets/editable_tab_bar_test.dart b/test/widgets/editable_tab_bar_test.dart index fe8585fa..b8ef340c 100644 --- a/test/widgets/editable_tab_bar_test.dart +++ b/test/widgets/editable_tab_bar_test.dart @@ -2,7 +2,6 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -12,32 +11,30 @@ import 'package:elastic_dashboard/widgets/editable_tab_bar.dart'; import 'package:elastic_dashboard/widgets/tab_grid.dart'; import '../test_util.dart'; import '../test_util.mocks.dart'; -import 'editable_tab_bar_test.mocks.dart'; -@GenerateNiceMocks([MockSpec()]) -class FakeTabBarFunctions { - void onTabCreate() {} +class FakeTabBarFunctions extends Mock { + void onTabCreate(); - void onTabDestroy() {} + void onTabDestroy(); - void onTabMoveLeft() {} + void onTabMoveLeft(); - void onTabMoveRight() {} + void onTabMoveRight(); - void onTabRename() {} + void onTabRename(); - void onTabChanged() {} + void onTabChanged(); - void onTabDuplicate() {} + void onTabDuplicate(); } void main() { - late MockFakeTabBarFunctions tabBarFunctions; + late FakeTabBarFunctions tabBarFunctions; late MockNTConnection mockNTConnection; late SharedPreferences preferences; setUp(() async { - tabBarFunctions = MockFakeTabBarFunctions(); + tabBarFunctions = FakeTabBarFunctions(); SharedPreferences.setMockInitialValues({}); preferences = await SharedPreferences.getInstance(); mockNTConnection = createMockOfflineNT4(); diff --git a/test/widgets/network_tree/networktables_tree_test.dart b/test/widgets/network_tree/networktables_tree_test.dart new file mode 100644 index 00000000..cc149769 --- /dev/null +++ b/test/widgets/network_tree/networktables_tree_test.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:elastic_dashboard/services/nt4_client.dart'; +import 'package:elastic_dashboard/services/nt_connection.dart'; +import 'package:elastic_dashboard/widgets/network_tree/networktables_tree.dart'; +import '../../test_util.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late SharedPreferences preferences; + setUp(() async { + FlutterError.onError = ignoreOverflowErrors; + SharedPreferences.setMockInitialValues({}); + preferences = await SharedPreferences.getInstance(); + }); + + testWidgets('Network Tables Tree with leading slashes', (widgetTester) async { + NTConnection ntConnection = createMockOnlineNT4( + virtualTopics: [ + NT4Topic( + name: '/Testing/Integer', + type: NT4TypeStr.kInt, + properties: {}, + ), + NT4Topic( + name: '/Testing/Double', + type: NT4TypeStr.kFloat64, + properties: {}, + ), + NT4Topic( + name: '/Testing/SubTable/String', + type: NT4TypeStr.kString, + properties: {}, + ), + ], + ); + + await widgetTester.pumpWidget( + MaterialApp( + home: Scaffold( + body: NetworkTableTree( + ntConnection: ntConnection, + preferences: preferences, + hideMetadata: false), + ), + ), + ); + await widgetTester.pumpAndSettle(); + + expect(find.text('Testing'), findsOneWidget); + await widgetTester.tap(find.text('Testing')); + await widgetTester.pumpAndSettle(); + + expect(find.text('Integer'), findsOneWidget); + expect(find.text('int'), findsOneWidget); + + expect(find.text('Double'), findsOneWidget); + expect(find.text('double'), findsOneWidget); + + expect(find.text('String'), findsNothing); + expect(find.text('string'), findsNothing); + expect(find.text('SubTable'), findsOneWidget); + + await widgetTester.tap(find.text('SubTable')); + await widgetTester.pumpAndSettle(); + + expect(find.text('String'), findsOneWidget); + expect(find.text('string'), findsOneWidget); + }); + + testWidgets('Network Tables Tree without leading slashes', + (widgetTester) async { + NTConnection ntConnection = createMockOnlineNT4( + virtualTopics: [ + NT4Topic( + name: 'Testing/Integer', + type: NT4TypeStr.kInt, + properties: {}, + ), + NT4Topic( + name: 'Testing/Double', + type: NT4TypeStr.kFloat64, + properties: {}, + ), + NT4Topic( + name: 'Testing/SubTable/String', + type: NT4TypeStr.kString, + properties: {}, + ), + ], + ); + + await widgetTester.pumpWidget( + MaterialApp( + home: Scaffold( + body: NetworkTableTree( + ntConnection: ntConnection, + preferences: preferences, + hideMetadata: false), + ), + ), + ); + await widgetTester.pumpAndSettle(); + + expect(find.text('Testing'), findsOneWidget); + await widgetTester.tap(find.text('Testing')); + await widgetTester.pumpAndSettle(); + + expect(find.text('Integer'), findsOneWidget); + expect(find.text('int'), findsOneWidget); + + expect(find.text('Double'), findsOneWidget); + expect(find.text('double'), findsOneWidget); + + expect(find.text('String'), findsNothing); + expect(find.text('string'), findsNothing); + expect(find.text('SubTable'), findsOneWidget); + + await widgetTester.tap(find.text('SubTable')); + await widgetTester.pumpAndSettle(); + + expect(find.text('String'), findsOneWidget); + expect(find.text('string'), findsOneWidget); + }); + + testWidgets('Network Tables Tree searching', (widgetTester) async { + NTConnection ntConnection = createMockOnlineNT4( + virtualTopics: [ + NT4Topic( + name: '/Testing/Integer', + type: NT4TypeStr.kInt, + properties: {}, + ), + NT4Topic( + name: '/Testing/Double', + type: NT4TypeStr.kFloat64, + properties: {}, + ), + NT4Topic( + name: '/Testing/SubTable/String', + type: NT4TypeStr.kString, + properties: {}, + ), + ], + ); + + await widgetTester.pumpWidget( + MaterialApp( + home: Scaffold( + body: NetworkTableTree( + ntConnection: ntConnection, + preferences: preferences, + hideMetadata: false, + searchQuery: 'Double', + ), + ), + ), + ); + await widgetTester.pumpAndSettle(); + + expect(find.text('Testing'), findsOneWidget); + await widgetTester.tap(find.text('Testing')); + await widgetTester.pumpAndSettle(); + + expect(find.text('Integer'), findsNothing); + expect(find.text('int'), findsNothing); + + expect(find.text('Double'), findsOneWidget); + expect(find.text('double'), findsOneWidget); + + expect(find.text('String'), findsNothing); + expect(find.text('string'), findsNothing); + expect(find.text('SubTable'), findsNothing); + }); +} diff --git a/test/widgets/nt_widgets/multi-topic/camera_stream_test.dart b/test/widgets/nt_widgets/multi-topic/camera_stream_test.dart index b5ac84ca..7a84186c 100644 --- a/test/widgets/nt_widgets/multi-topic/camera_stream_test.dart +++ b/test/widgets/nt_widgets/multi-topic/camera_stream_test.dart @@ -69,6 +69,47 @@ void main() { expect(cameraStreamModel.getUrlWithParameters('0.0.0.0'), '0.0.0.0?'); }); + test('Camera stream from json (with invalid resolution)', () { + NTWidgetModel cameraStreamModel = NTWidgetBuilder.buildNTModelFromJson( + ntConnection, + preferences, + 'Camera Stream', + {...cameraStreamJson}..update('resolution', (_) => [101.0, 100.0]), + ); + + expect(cameraStreamModel.type, 'Camera Stream'); + expect(cameraStreamModel.runtimeType, CameraStreamModel); + + if (cameraStreamModel is! CameraStreamModel) { + return; + } + expect(cameraStreamModel.resolution, const Size(102.0, 100.0)); + + expect(cameraStreamModel.getUrlWithParameters('0.0.0.0'), + '0.0.0.0?resolution=102x100&fps=60&compression=50'); + }); + + test('Camera stream from json (with negative resolution)', () { + NTWidgetModel cameraStreamModel = NTWidgetBuilder.buildNTModelFromJson( + ntConnection, + preferences, + 'Camera Stream', + {...cameraStreamJson}..update('resolution', (_) => [-1, 100.0]), + ); + + expect(cameraStreamModel.type, 'Camera Stream'); + expect(cameraStreamModel.runtimeType, CameraStreamModel); + + if (cameraStreamModel is! CameraStreamModel) { + return; + } + + expect(cameraStreamModel.resolution, isNull); + + expect(cameraStreamModel.getUrlWithParameters('0.0.0.0'), + '0.0.0.0?fps=60&compression=50'); + }); + test('Camera stream to json', () { CameraStreamModel cameraStreamModel = CameraStreamModel( ntConnection: ntConnection, diff --git a/test/widgets/nt_widgets/multi-topic/differential_drive_test.dart b/test/widgets/nt_widgets/multi-topic/differential_drive_test.dart index 38d3e524..767a94b9 100644 --- a/test/widgets/nt_widgets/multi-topic/differential_drive_test.dart +++ b/test/widgets/nt_widgets/multi-topic/differential_drive_test.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:geekyants_flutter_gauges/geekyants_flutter_gauges.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:syncfusion_flutter_gauges/gauges.dart'; import 'package:elastic_dashboard/services/nt4_client.dart'; import 'package:elastic_dashboard/services/nt_connection.dart'; @@ -94,11 +94,11 @@ void main() { await widgetTester.pumpAndSettle(); expect(find.byType(CustomPaint), findsWidgets); - expect(find.byType(SfLinearGauge), findsNWidgets(2)); - expect(find.byType(LinearShapePointer), findsNWidgets(2)); + expect(find.byType(LinearGauge), findsNWidgets(2)); + expect(find.byType(Pointer), findsNWidgets(2)); await widgetTester.drag( - find.byType(LinearShapePointer).first, const Offset(0.0, 200.0)); + find.byType(Pointer).first, const Offset(0.0, 200.0)); await widgetTester.pumpAndSettle(); expect( @@ -111,7 +111,7 @@ void main() { 0.50); await widgetTester.drag( - find.byType(LinearShapePointer).last, const Offset(0.0, 300.0)); + find.byType(Pointer).last, const Offset(0.0, 300.0)); await widgetTester.pumpAndSettle(); expect( diff --git a/test/widgets/nt_widgets/multi-topic/field_widget_test.dart b/test/widgets/nt_widgets/multi-topic/field_widget_test.dart new file mode 100644 index 00000000..53e58af5 --- /dev/null +++ b/test/widgets/nt_widgets/multi-topic/field_widget_test.dart @@ -0,0 +1,213 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:elastic_dashboard/services/field_images.dart'; +import 'package:elastic_dashboard/services/nt4_client.dart'; +import 'package:elastic_dashboard/services/nt_connection.dart'; +import 'package:elastic_dashboard/services/nt_widget_builder.dart'; +import 'package:elastic_dashboard/widgets/nt_widgets/multi-topic/field_widget.dart'; +import 'package:elastic_dashboard/widgets/nt_widgets/nt_widget.dart'; +import '../../../test_util.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final Map fieldWidgetJson = { + 'topic': 'Test/Field', + 'period': 0.100, + 'field_game': 'Crescendo', + 'robot_width': 1.0, + 'robot_length': 1.0, + 'show_other_objects': true, + 'show_trajectories': true, + 'robot_color': Colors.red.value, + 'trajectory_color': Colors.white.value, + }; + + late SharedPreferences preferences; + late NTConnection ntConnection; + + setUp(() async { + await FieldImages.loadFields('assets/fields/'); + + SharedPreferences.setMockInitialValues({}); + preferences = await SharedPreferences.getInstance(); + + ntConnection = createMockOnlineNT4( + virtualTopics: [ + NT4Topic( + name: 'Test/Field/Robot', + type: NT4TypeStr.kFloat64Arr, + properties: {}, + ), + NT4Topic( + name: 'Test/Field/OtherObject', + type: NT4TypeStr.kFloat64Arr, + properties: {}, + ), + ], + virtualValues: { + 'Test/Field/Robot': [5.0, 5.0, 270.0], + 'Test/Field/OtherObject': [1.0, 1.0, 0.0], + }, + ); + }); + + test('Field from json', () { + NTWidgetModel fieldWidgetModel = NTWidgetBuilder.buildNTModelFromJson( + ntConnection, + preferences, + 'Field', + fieldWidgetJson, + ); + + expect(fieldWidgetModel.type, 'Field'); + expect(fieldWidgetModel.runtimeType, FieldWidgetModel); + + if (fieldWidgetModel is! FieldWidgetModel) { + return; + } + + expect(fieldWidgetModel.robotWidthMeters, 1.0); + expect(fieldWidgetModel.robotLengthMeters, 1.0); + expect(fieldWidgetModel.showOtherObjects, isTrue); + expect(fieldWidgetModel.showTrajectories, isTrue); + expect(fieldWidgetModel.robotColor.value, Colors.red.value); + expect(fieldWidgetModel.trajectoryColor.value, Colors.white.value); + }); + + test('Field from alias json', () { + NTWidgetModel fieldWidgetModel = NTWidgetBuilder.buildNTModelFromJson( + ntConnection, + preferences, + 'Field2d', + fieldWidgetJson, + ); + + expect(fieldWidgetModel.type, 'Field'); + expect(fieldWidgetModel.runtimeType, FieldWidgetModel); + + if (fieldWidgetModel is! FieldWidgetModel) { + return; + } + + expect(fieldWidgetModel.robotWidthMeters, 1.0); + expect(fieldWidgetModel.robotLengthMeters, 1.0); + expect(fieldWidgetModel.showOtherObjects, isTrue); + expect(fieldWidgetModel.showTrajectories, isTrue); + expect(fieldWidgetModel.robotColor.value, Colors.red.value); + expect(fieldWidgetModel.trajectoryColor.value, Colors.white.value); + }); + + test('Field to json', () { + FieldWidgetModel fieldWidgetModel = FieldWidgetModel( + ntConnection: ntConnection, + preferences: preferences, + period: 0.100, + topic: 'Test/Field', + fieldName: 'Crescendo', + showOtherObjects: true, + showTrajectories: true, + robotWidthMeters: 1.0, + robotLengthMeters: 1.0, + robotColor: Colors.red, + trajectoryColor: Colors.white, + ); + + expect(fieldWidgetModel.toJson(), fieldWidgetJson); + }); + + testWidgets('Field widget test (no trajectory)', (widgetTester) async { + NTWidgetModel fieldWidgetModel = NTWidgetBuilder.buildNTModelFromJson( + ntConnection, + preferences, + 'Field', + fieldWidgetJson, + ); + + await widgetTester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ChangeNotifierProvider.value( + value: fieldWidgetModel, + child: const FieldWidget(), + ), + ), + ), + ); + + await widgetTester.pumpAndSettle(); + + expect(find.byType(CustomPaint), findsNWidgets(3)); + expect( + find.byWidgetPredicate((widget) => + widget is CustomPaint && widget.painter is TrajectoryPainter), + findsNothing); + expect(find.byType(Image), findsOneWidget); + }); + + testWidgets('Field widget test (with trajectory)', (widgetTester) async { + List fakeTrajectory = []; + + for (int i = 0; i < 16; i++) { + fakeTrajectory.add(i * 0.25); + fakeTrajectory.add(i * 0.25); + fakeTrajectory.add(0.0); + } + + NTConnection ntConnection = createMockOnlineNT4( + virtualTopics: [ + NT4Topic( + name: 'Test/Field/Robot', + type: NT4TypeStr.kFloat64Arr, + properties: {}, + ), + NT4Topic( + name: 'Test/Field/OtherObject', + type: NT4TypeStr.kFloat64Arr, + properties: {}, + ), + NT4Topic( + name: 'Test/Field/Trajectory', + type: NT4TypeStr.kFloat64Arr, + properties: {}, + ), + ], + virtualValues: { + 'Test/Field/Robot': [5.0, 5.0, 270.0], + 'Test/Field/OtherObject': [1.0, 1.0, 0.0], + 'Test/Field/Trajectory': fakeTrajectory, + }, + ); + + NTWidgetModel fieldWidgetModel = NTWidgetBuilder.buildNTModelFromJson( + ntConnection, + preferences, + 'Field', + fieldWidgetJson, + ); + + await widgetTester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ChangeNotifierProvider.value( + value: fieldWidgetModel, + child: const FieldWidget(), + ), + ), + ), + ); + + await widgetTester.pumpAndSettle(); + + expect(find.byType(CustomPaint), findsNWidgets(4)); + expect( + find.byWidgetPredicate((widget) => + widget is CustomPaint && widget.painter is TrajectoryPainter), + findsOneWidget); + expect(find.byType(Image), findsOneWidget); + }); +} diff --git a/test/widgets/nt_widgets/multi-topic/gyro_test.dart b/test/widgets/nt_widgets/multi-topic/gyro_test.dart index e110daf5..676dd197 100644 --- a/test/widgets/nt_widgets/multi-topic/gyro_test.dart +++ b/test/widgets/nt_widgets/multi-topic/gyro_test.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:geekyants_flutter_gauges/geekyants_flutter_gauges.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:syncfusion_flutter_gauges/gauges.dart'; import 'package:elastic_dashboard/services/nt4_client.dart'; import 'package:elastic_dashboard/services/nt_connection.dart'; @@ -96,6 +96,6 @@ void main() { await widgetTester.pumpAndSettle(); expect(find.text('176.50'), findsOneWidget); - expect(find.byType(SfRadialGauge), findsOneWidget); + expect(find.byType(RadialGauge), findsOneWidget); }); } diff --git a/test/widgets/nt_widgets/multi-topic/motor_controller_test.dart b/test/widgets/nt_widgets/multi-topic/motor_controller_test.dart index 306d9b10..28200735 100644 --- a/test/widgets/nt_widgets/multi-topic/motor_controller_test.dart +++ b/test/widgets/nt_widgets/multi-topic/motor_controller_test.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:geekyants_flutter_gauges/geekyants_flutter_gauges.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:syncfusion_flutter_gauges/gauges.dart'; import 'package:elastic_dashboard/services/nt4_client.dart'; import 'package:elastic_dashboard/services/nt_connection.dart'; @@ -100,7 +100,7 @@ void main() { await widgetTester.pumpAndSettle(); expect(find.text('-0.50'), findsOneWidget); - expect(find.byType(SfLinearGauge), findsOneWidget); - expect(find.byType(LinearShapePointer), findsOneWidget); + expect(find.byType(LinearGauge), findsOneWidget); + expect(find.byType(Pointer), findsOneWidget); }); } diff --git a/test/widgets/nt_widgets/multi-topic/robot_preferences_test.dart b/test/widgets/nt_widgets/multi-topic/robot_preferences_test.dart index 9df07cc5..f9310ebb 100644 --- a/test/widgets/nt_widgets/multi-topic/robot_preferences_test.dart +++ b/test/widgets/nt_widgets/multi-topic/robot_preferences_test.dart @@ -1,15 +1,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:elastic_dashboard/services/nt4_client.dart'; -import 'package:elastic_dashboard/services/nt_connection.dart'; import 'package:elastic_dashboard/services/nt_widget_builder.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/multi-topic/robot_preferences.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/nt_widget.dart'; import '../../../test_util.dart'; +import '../../../test_util.mocks.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -20,7 +21,7 @@ void main() { }; late SharedPreferences preferences; - late NTConnection ntConnection; + late MockNTConnection ntConnection; setUp(() async { SharedPreferences.setMockInitialValues({}); @@ -109,35 +110,66 @@ void main() { expect(find.widgetWithText(TextField, 'Test Preference'), findsOneWidget); await widgetTester.enterText( find.widgetWithText(TextField, 'Test Preference'), '1'); + // Focusing on the text field should publish the topic + verify(ntConnection.publishTopic(any)).called(1); + await widgetTester.testTextInput.receiveAction(TextInputAction.done); expect( ntConnection.getLastAnnouncedValue('Test/Preferences/Test Preference'), 1); + // After submitting topic should be unpublished + verify(ntConnection.unpublishTopic(any)).called(1); + + clearInteractions(ntConnection); + expect(find.widgetWithText(TextField, 'Preference 1'), findsOneWidget); await widgetTester.enterText( find.widgetWithText(TextField, 'Preference 1'), '0.250'); + // Focusing on the text field should publish the topic + verify(ntConnection.publishTopic(any)).called(1); + await widgetTester.testTextInput.receiveAction(TextInputAction.done); expect(ntConnection.getLastAnnouncedValue('Test/Preferences/Preference 1'), 0.250); + // After submitting topic should be unpublished + verify(ntConnection.unpublishTopic(any)).called(1); + + clearInteractions(ntConnection); + expect(find.widgetWithText(TextField, 'Preference 2'), findsOneWidget); await widgetTester.enterText( find.widgetWithText(TextField, 'Preference 2'), 'true'); + // Focusing on the text field should publish the topic + verify(ntConnection.publishTopic(any)).called(1); + await widgetTester.testTextInput.receiveAction(TextInputAction.done); expect(ntConnection.getLastAnnouncedValue('Test/Preferences/Preference 2'), isTrue); + // After submitting topic should be unpublished + verify(ntConnection.unpublishTopic(any)).called(1); + + clearInteractions(ntConnection); + expect(find.widgetWithText(TextField, 'Preference 3'), findsOneWidget); await widgetTester.enterText( find.widgetWithText(TextField, 'Preference 3'), 'Edited String'); + // Focusing on the text field should publish the topic + verify(ntConnection.publishTopic(any)).called(1); + await widgetTester.testTextInput.receiveAction(TextInputAction.done); expect(ntConnection.getLastAnnouncedValue('Test/Preferences/Preference 3'), 'Edited String'); + // After submitting topic should be unpublished + verify(ntConnection.unpublishTopic(any)).called(1); + + clearInteractions(ntConnection); // Searching final searchField = find.widgetWithText(TextField, 'Search'); diff --git a/test/widgets/nt_widgets/multi-topic/yagsl_swerve_drive_test.dart b/test/widgets/nt_widgets/multi-topic/yagsl_swerve_drive_test.dart index 8d770c1e..cc3abd01 100644 --- a/test/widgets/nt_widgets/multi-topic/yagsl_swerve_drive_test.dart +++ b/test/widgets/nt_widgets/multi-topic/yagsl_swerve_drive_test.dart @@ -18,6 +18,7 @@ void main() { 'period': 0.100, 'show_robot_rotation': true, 'show_desired_states': true, + 'angle_offset': 90.0, }; late SharedPreferences preferences; @@ -47,6 +48,7 @@ void main() { expect(yagslSwerveModel.showRobotRotation, isTrue); expect(yagslSwerveModel.showDesiredStates, isTrue); + expect(yagslSwerveModel.angleOffset, 90.0); }); test('YAGSL swerve drive to json', () { @@ -57,6 +59,7 @@ void main() { period: 0.100, showRobotRotation: true, showDesiredStates: true, + angleOffset: 90.0, ); expect(yagslSwerveModel.toJson(), yagslSwerveJson); diff --git a/test/widgets/nt_widgets/single-topic/multi_color_view_test.dart b/test/widgets/nt_widgets/single-topic/multi_color_view_test.dart index a253570c..323d1926 100644 --- a/test/widgets/nt_widgets/single-topic/multi_color_view_test.dart +++ b/test/widgets/nt_widgets/single-topic/multi_color_view_test.dart @@ -69,7 +69,7 @@ void main() { ); expect(multiColorViewModel.type, 'Multi Color View'); - expect(multiColorViewModel.runtimeType, NTWidgetModel); + expect(multiColorViewModel.runtimeType, SingleTopicNTWidgetModel); expect( multiColorViewModel.getAvailableDisplayTypes(), unorderedEquals([ @@ -79,7 +79,7 @@ void main() { }); test('Multi color view to json', () { - NTWidgetModel multiColorViewModel = NTWidgetModel.createDefault( + NTWidgetModel multiColorViewModel = SingleTopicNTWidgetModel.createDefault( ntConnection: ntConnection, preferences: preferences, type: 'Multi Color View', diff --git a/test/widgets/nt_widgets/single-topic/number_bar_test.dart b/test/widgets/nt_widgets/single-topic/number_bar_test.dart index d45e58f3..2481df80 100644 --- a/test/widgets/nt_widgets/single-topic/number_bar_test.dart +++ b/test/widgets/nt_widgets/single-topic/number_bar_test.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:geekyants_flutter_gauges/geekyants_flutter_gauges.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:syncfusion_flutter_gauges/gauges.dart'; import 'package:elastic_dashboard/services/nt4_client.dart'; import 'package:elastic_dashboard/services/nt_connection.dart'; @@ -126,12 +126,12 @@ void main() { await widgetTester.pumpAndSettle(); expect(find.text('-1.00'), findsOneWidget); - expect(find.byType(SfLinearGauge), findsOneWidget); + expect(find.byType(LinearGauge), findsOneWidget); expect( - (find.byType(SfLinearGauge).evaluate().first.widget as SfLinearGauge) - .orientation, - LinearGaugeOrientation.horizontal); + (find.byType(LinearGauge).evaluate().first.widget as LinearGauge) + .gaugeOrientation, + GaugeOrientation.horizontal); }); testWidgets('Number bar widget test vertical', (widgetTester) async { @@ -164,12 +164,12 @@ void main() { await widgetTester.pumpAndSettle(); expect(find.text('-1.00'), findsOneWidget); - expect(find.byType(SfLinearGauge), findsOneWidget); + expect(find.byType(LinearGauge), findsOneWidget); expect( - (find.byType(SfLinearGauge).evaluate().first.widget as SfLinearGauge) - .orientation, - LinearGaugeOrientation.vertical); + (find.byType(LinearGauge).evaluate().first.widget as LinearGauge) + .gaugeOrientation, + GaugeOrientation.vertical); }); testWidgets('Number bar widget test integer', (widgetTester) async { @@ -216,7 +216,7 @@ void main() { expect(find.text('-1.00'), findsNothing); expect(find.text('-1'), findsOneWidget); - expect(find.byType(SfLinearGauge), findsOneWidget); + expect(find.byType(LinearGauge), findsOneWidget); }); testWidgets('Number bar widget test with divisions', (widgetTester) async { @@ -249,11 +249,10 @@ void main() { await widgetTester.pumpAndSettle(); expect(find.text('-1.00'), findsOneWidget); - expect(find.byType(SfLinearGauge), findsOneWidget); + expect(find.byType(LinearGauge), findsOneWidget); expect( - (find.byType(SfLinearGauge).evaluate().first.widget as SfLinearGauge) - .interval, + (find.byType(LinearGauge).evaluate().first.widget as LinearGauge).steps, 1.0); }); } diff --git a/test/widgets/nt_widgets/single-topic/number_slider_test.dart b/test/widgets/nt_widgets/single-topic/number_slider_test.dart index 66b653cb..c2cea09d 100644 --- a/test/widgets/nt_widgets/single-topic/number_slider_test.dart +++ b/test/widgets/nt_widgets/single-topic/number_slider_test.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:geekyants_flutter_gauges/geekyants_flutter_gauges.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:syncfusion_flutter_gauges/gauges.dart'; import 'package:elastic_dashboard/services/nt4_client.dart'; import 'package:elastic_dashboard/services/nt_connection.dart'; @@ -119,11 +119,11 @@ void main() { await widgetTester.pumpAndSettle(); expect(find.text('-1.00'), findsOneWidget); - expect(find.byType(SfLinearGauge), findsOneWidget); - expect(find.byType(LinearShapePointer), findsOneWidget); + expect(find.byType(LinearGauge), findsOneWidget); + expect(find.byType(Pointer), findsOneWidget); Future pointerDrag = widgetTester.timedDrag( - find.byType(LinearShapePointer), + find.byType(Pointer), const Offset(100.0, 0.0), const Duration(seconds: 1), ); @@ -133,7 +133,8 @@ void main() { Object? valueDuringDrag; Future.delayed(const Duration(milliseconds: 500), () { - draggingDuringDrag = (numberSliderModel as NumberSliderModel).dragging; + draggingDuringDrag = + (numberSliderModel as NumberSliderModel).dragging.value; valueDuringDrag = ntConnection.getLastAnnouncedValue('Test/Double Value'); }); @@ -176,11 +177,11 @@ void main() { await widgetTester.pumpAndSettle(); expect(find.text('-1.00'), findsOneWidget); - expect(find.byType(SfLinearGauge), findsOneWidget); - expect(find.byType(LinearShapePointer), findsOneWidget); + expect(find.byType(LinearGauge), findsOneWidget); + expect(find.byType(Pointer), findsOneWidget); Future pointerDrag = widgetTester.timedDrag( - find.byType(LinearShapePointer), + find.byType(Pointer), const Offset(100.0, 0.0), const Duration(seconds: 1), ); @@ -190,7 +191,7 @@ void main() { Object? valueDuringDrag; Future.delayed(const Duration(milliseconds: 500), () { - draggingDuringDrag = numberSliderModel.dragging; + draggingDuringDrag = numberSliderModel.dragging.value; valueDuringDrag = ntConnection.getLastAnnouncedValue('Test/Double Value'); }); @@ -246,11 +247,11 @@ void main() { expect(find.text('-1.00'), findsNothing); expect(find.text('-1'), findsOneWidget); - expect(find.byType(SfLinearGauge), findsOneWidget); - expect(find.byType(LinearShapePointer), findsOneWidget); + expect(find.byType(LinearGauge), findsOneWidget); + expect(find.byType(Pointer), findsOneWidget); Future pointerDrag = widgetTester.timedDrag( - find.byType(LinearShapePointer), + find.byType(Pointer), const Offset(200.0, 0.0), const Duration(seconds: 1), ); @@ -260,7 +261,7 @@ void main() { Object? valueDuringDrag; Future.delayed(const Duration(milliseconds: 500), () { - draggingDuringDrag = numberSliderModel.dragging; + draggingDuringDrag = numberSliderModel.dragging.value; valueDuringDrag = ntConnection.getLastAnnouncedValue('Test/Int Value'); }); diff --git a/test/widgets/nt_widgets/single-topic/radial_gauge_test.dart b/test/widgets/nt_widgets/single-topic/radial_gauge_test.dart index c30f5e6c..f9d3140d 100644 --- a/test/widgets/nt_widgets/single-topic/radial_gauge_test.dart +++ b/test/widgets/nt_widgets/single-topic/radial_gauge_test.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:geekyants_flutter_gauges/geekyants_flutter_gauges.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:syncfusion_flutter_gauges/gauges.dart'; import 'package:elastic_dashboard/services/nt4_client.dart'; import 'package:elastic_dashboard/services/nt_connection.dart'; @@ -166,7 +166,7 @@ void main() { home: Scaffold( body: ChangeNotifierProvider.value( value: radialGaugeModel, - child: const RadialGauge(), + child: const RadialGaugeWidget(), ), ), ), @@ -174,7 +174,7 @@ void main() { await widgetTester.pumpAndSettle(); - expect(find.byType(SfRadialGauge), findsOneWidget); + expect(find.byType(RadialGauge), findsOneWidget); expect(find.text('-0.50'), findsOneWidget); expect(find.byType(NeedlePointer), findsOneWidget); }); @@ -216,7 +216,7 @@ void main() { home: Scaffold( body: ChangeNotifierProvider.value( value: radialGaugeModel, - child: const RadialGauge(), + child: const RadialGaugeWidget(), ), ), ), @@ -224,7 +224,7 @@ void main() { await widgetTester.pumpAndSettle(); - expect(find.byType(SfRadialGauge), findsOneWidget); + expect(find.byType(RadialGauge), findsOneWidget); expect(find.text('-1.00'), findsNothing); expect(find.text('-1'), findsOneWidget); expect(find.byType(NeedlePointer), findsOneWidget); @@ -254,7 +254,7 @@ void main() { home: Scaffold( body: ChangeNotifierProvider.value( value: radialGaugeModel, - child: const RadialGauge(), + child: const RadialGaugeWidget(), ), ), ), @@ -262,7 +262,7 @@ void main() { await widgetTester.pumpAndSettle(); - expect(find.byType(SfRadialGauge), findsOneWidget); + expect(find.byType(RadialGauge), findsOneWidget); expect(find.text('-0.50'), findsOneWidget); expect(find.byType(NeedlePointer), findsNothing); }); diff --git a/test/widgets/nt_widgets/single-topic/single_color_view_test.dart b/test/widgets/nt_widgets/single-topic/single_color_view_test.dart index e10b3ee8..0c229bc7 100644 --- a/test/widgets/nt_widgets/single-topic/single_color_view_test.dart +++ b/test/widgets/nt_widgets/single-topic/single_color_view_test.dart @@ -62,7 +62,7 @@ void main() { ); expect(singleColorViewModel.type, 'Single Color View'); - expect(singleColorViewModel.runtimeType, NTWidgetModel); + expect(singleColorViewModel.runtimeType, SingleTopicNTWidgetModel); expect( singleColorViewModel.getAvailableDisplayTypes(), unorderedEquals([ @@ -72,7 +72,7 @@ void main() { }); test('Single color view to json', () { - NTWidgetModel singleColorViewModel = NTWidgetModel.createDefault( + NTWidgetModel singleColorViewModel = SingleTopicNTWidgetModel.createDefault( ntConnection: ntConnection, preferences: preferences, type: 'Single Color View', diff --git a/test/widgets/nt_widgets/single-topic/toggle_button_test.dart b/test/widgets/nt_widgets/single-topic/toggle_button_test.dart index 31335ffc..2ba9aff3 100644 --- a/test/widgets/nt_widgets/single-topic/toggle_button_test.dart +++ b/test/widgets/nt_widgets/single-topic/toggle_button_test.dart @@ -50,7 +50,7 @@ void main() { ); expect(toggleButtonModel.type, 'Toggle Button'); - expect(toggleButtonModel.runtimeType, NTWidgetModel); + expect(toggleButtonModel.runtimeType, SingleTopicNTWidgetModel); expect( toggleButtonModel.getAvailableDisplayTypes(), unorderedEquals([ @@ -62,7 +62,7 @@ void main() { }); test('Toggle button to json', () { - NTWidgetModel toggleButtonModel = NTWidgetModel.createDefault( + NTWidgetModel toggleButtonModel = SingleTopicNTWidgetModel.createDefault( ntConnection: ntConnection, preferences: preferences, type: 'Toggle Button', diff --git a/test/widgets/nt_widgets/single-topic/toggle_switch_test.dart b/test/widgets/nt_widgets/single-topic/toggle_switch_test.dart index 1cb143c9..6a59c56b 100644 --- a/test/widgets/nt_widgets/single-topic/toggle_switch_test.dart +++ b/test/widgets/nt_widgets/single-topic/toggle_switch_test.dart @@ -50,7 +50,7 @@ void main() { ); expect(toggleSwitchModel.type, 'Toggle Switch'); - expect(toggleSwitchModel.runtimeType, NTWidgetModel); + expect(toggleSwitchModel.runtimeType, SingleTopicNTWidgetModel); expect( toggleSwitchModel.getAvailableDisplayTypes(), unorderedEquals([ @@ -62,7 +62,7 @@ void main() { }); test('Toggle switch to json', () { - NTWidgetModel toggleSwitchModel = NTWidgetModel.createDefault( + NTWidgetModel toggleSwitchModel = SingleTopicNTWidgetModel.createDefault( ntConnection: ntConnection, preferences: preferences, type: 'Toggle Switch', diff --git a/test/widgets/nt_widgets/single-topic/voltage_view_test.dart b/test/widgets/nt_widgets/single-topic/voltage_view_test.dart index 4415df35..10350b47 100644 --- a/test/widgets/nt_widgets/single-topic/voltage_view_test.dart +++ b/test/widgets/nt_widgets/single-topic/voltage_view_test.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:geekyants_flutter_gauges/geekyants_flutter_gauges.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:syncfusion_flutter_gauges/gauges.dart'; import 'package:elastic_dashboard/services/nt4_client.dart'; import 'package:elastic_dashboard/services/nt_connection.dart'; @@ -126,12 +126,12 @@ void main() { await widgetTester.pumpAndSettle(); expect(find.text('12.00 V'), findsOneWidget); - expect(find.byType(SfLinearGauge), findsOneWidget); + expect(find.byType(LinearGauge), findsOneWidget); expect( - (find.byType(SfLinearGauge).evaluate().first.widget as SfLinearGauge) - .orientation, - LinearGaugeOrientation.horizontal); + (find.byType(LinearGauge).evaluate().first.widget as LinearGauge) + .gaugeOrientation, + GaugeOrientation.horizontal); }); testWidgets('Voltage view widget test vertical', (widgetTester) async { @@ -164,12 +164,12 @@ void main() { await widgetTester.pumpAndSettle(); expect(find.text('12.00 V'), findsOneWidget); - expect(find.byType(SfLinearGauge), findsOneWidget); + expect(find.byType(LinearGauge), findsOneWidget); expect( - (find.byType(SfLinearGauge).evaluate().first.widget as SfLinearGauge) - .orientation, - LinearGaugeOrientation.vertical); + (find.byType(LinearGauge).evaluate().first.widget as LinearGauge) + .gaugeOrientation, + GaugeOrientation.vertical); }); testWidgets('Voltage view widget test with divisions', (widgetTester) async { @@ -202,11 +202,10 @@ void main() { await widgetTester.pumpAndSettle(); expect(find.text('12.00 V'), findsOneWidget); - expect(find.byType(SfLinearGauge), findsOneWidget); + expect(find.byType(LinearGauge), findsOneWidget); expect( - (find.byType(SfLinearGauge).evaluate().first.widget as SfLinearGauge) - .interval, + (find.byType(LinearGauge).evaluate().first.widget as LinearGauge).steps, 0.9); }); } diff --git a/test/widgets/settings_dialog_test.dart b/test/widgets/settings_dialog_test.dart index e21be1cc..25a5e74c 100644 --- a/test/widgets/settings_dialog_test.dart +++ b/test/widgets/settings_dialog_test.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import 'package:flex_seed_scheme/flex_seed_scheme.dart'; import 'package:flutter_colorpicker/flutter_colorpicker.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -15,44 +14,52 @@ import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_text_input.dart' import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_toggle_switch.dart'; import 'package:elastic_dashboard/widgets/settings_dialog.dart'; import '../test_util.dart'; -import 'settings_dialog_test.mocks.dart'; -@GenerateNiceMocks([MockSpec()]) -class FakeSettingsMethods { - void changeColor() {} +class FakeSettingsMethods extends Mock { + void changeColor(); - void changeIPAddress() {} + void changeIPAddress(); - void changeTeamNumber() {} + void changeTeamNumber(); - void changeIPAddressMode() {} + void changeIPAddressMode(); - void changeShowGrid() {} + void changeShowGrid(); - void changeGridSize() {} + void changeGridSize(); - void changeCornerRadius() {} + void changeCornerRadius(); - void changeDSAutoResize() {} + void changeDSAutoResize(); - void changeRememberWindow() {} + void changeRememberWindow(); - void changeLockLayout() {} + void changeLockLayout(); - void changeDefaultPeriod() {} + void changeDefaultPeriod(); - void changeDefaultGraphPeriod() {} + void changeDefaultGraphPeriod(); - void changeThemeVariant() {} + void changeThemeVariant(); + + void openAssetsFolder(); } void main() { TestWidgetsFlutterBinding.ensureInitialized(); late SharedPreferences preferences; - late MockFakeSettingsMethods fakeSettings; + late FakeSettingsMethods fakeSettings; + + final networkSettings = find.widgetWithText(Tab, 'Network'); + final appearanceSettings = find.widgetWithText(Tab, 'Appearance'); + final devSettings = find.widgetWithText(Tab, 'Developer'); - setUpAll(() async { + setUpAll(() { + fakeSettings = FakeSettingsMethods(); + }); + + setUp(() async { SharedPreferences.setMockInitialValues({ PrefKeys.ipAddress: '127.0.0.1', PrefKeys.teamNumber: 353, @@ -71,10 +78,6 @@ void main() { preferences = await SharedPreferences.getInstance(); - fakeSettings = MockFakeSettingsMethods(); - }); - - setUp(() { reset(fakeSettings); }); @@ -93,20 +96,52 @@ void main() { await widgetTester.pumpAndSettle(); expect(find.text('Settings'), findsOneWidget); + + expect(networkSettings, findsOneWidget); + expect(appearanceSettings, findsOneWidget); + expect(devSettings, findsOneWidget); + expect(find.text('Team Number'), findsOneWidget); - expect(find.text('Team Color'), findsOneWidget); expect(find.text('IP Address Mode'), findsOneWidget); expect(find.text('IP Address'), findsOneWidget); + expect(find.text('Default Period'), findsOneWidget); + expect(find.text('Default Graph Period'), findsOneWidget); + + expect(find.text('Team Color'), findsNothing); + expect(find.text('Theme Variant'), findsNothing); + expect(find.text('Show Grid'), findsNothing); + expect(find.text('Grid Size'), findsNothing); + expect(find.text('Corner Radius'), findsNothing); + expect(find.text('Resize to Driver Station Height'), findsNothing); + expect(find.text('Remember Window Position'), findsNothing); + expect(find.text('Lock Layout'), findsNothing); + + expect(appearanceSettings, findsOneWidget); + await widgetTester.tap(appearanceSettings); + await widgetTester.pumpAndSettle(); + + expect(find.text('Team Number'), findsNothing); + expect(find.text('IP Address Mode'), findsNothing); + expect(find.text('IP Address'), findsNothing); + expect(find.text('Default Period'), findsNothing); + expect(find.text('Default Graph Period'), findsNothing); + + expect(find.text('Team Color'), findsOneWidget); expect(find.text('Show Grid'), findsWidgets); expect(find.text('Grid Size'), findsWidgets); expect(find.text('Corner Radius'), findsOneWidget); expect(find.text('Resize to Driver Station Height'), findsOneWidget); expect(find.text('Remember Window Position'), findsOneWidget); expect(find.text('Lock Layout'), findsOneWidget); - expect(find.text('Default Period'), findsOneWidget); - expect(find.text('Default Graph Period'), findsOneWidget); expect(find.text('Theme Variant'), findsOneWidget); + expect(devSettings, findsOneWidget); + + await widgetTester.tap(devSettings); + await widgetTester.pumpAndSettle(); + + expect(find.text('Open Assets Folder'), findsOneWidget); + final closeButton = find.widgetWithText(TextButton, 'Close'); expect(closeButton, findsOneWidget); @@ -149,6 +184,148 @@ void main() { verify(fakeSettings.changeTeamNumber()).called(greaterThanOrEqualTo(1)); }); + testWidgets('Change IP address mode', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + await widgetTester.pumpWidget(MaterialApp( + home: Scaffold( + body: SettingsDialog( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + onIPAddressModeChanged: (mode) { + fakeSettings.changeIPAddressMode(); + }, + ), + ), + )); + + await widgetTester.pumpAndSettle(); + + final ipAddressMode = find.byType(DialogDropdownChooser); + + expect(ipAddressMode, findsOneWidget); + + expect(find.text('Driver Station'), findsOneWidget); + + await widgetTester.tap(ipAddressMode); + await widgetTester.pumpAndSettle(); + + expect(find.text('Team Number (10.TE.AM.2)'), findsOneWidget); + + await widgetTester.tap(find.text('Team Number (10.TE.AM.2)')); + await widgetTester.pumpAndSettle(); + + expect(find.text('Driver Station'), findsNothing); + expect(find.text('Team Number (10.TE.AM.2)'), findsOneWidget); + + await widgetTester.tap(find.text('Team Number (10.TE.AM.2)')); + await widgetTester.pumpAndSettle(); + + expect(find.text('Driver Station'), findsOneWidget); + + await widgetTester.tap(find.text('Driver Station')); + await widgetTester.pumpAndSettle(); + + verify(fakeSettings.changeIPAddressMode()).called(2); + }); + + testWidgets('Change IP address', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + await widgetTester.pumpWidget(MaterialApp( + home: Scaffold( + body: SettingsDialog( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + onIPAddressChanged: (data) async { + fakeSettings.changeIPAddress(); + + await preferences.setString(PrefKeys.ipAddress, data!); + }, + ), + ), + )); + + await widgetTester.pumpAndSettle(); + + final ipAddressField = find.widgetWithText(DialogTextInput, 'IP Address'); + + expect(ipAddressField, findsOneWidget); + + await widgetTester.enterText(ipAddressField, '10.3.53.2'); + await widgetTester.testTextInput.receiveAction(TextInputAction.done); + await widgetTester.pump(); + + expect(preferences.getString(PrefKeys.ipAddress), '10.3.53.2'); + verify(fakeSettings.changeIPAddress()).called(greaterThanOrEqualTo(1)); + }); + + testWidgets('Change default period', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + await widgetTester.pumpWidget(MaterialApp( + home: Scaffold( + body: SettingsDialog( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + onDefaultPeriodChanged: (period) async { + fakeSettings.changeDefaultPeriod(); + + await preferences.setDouble( + PrefKeys.defaultPeriod, double.parse(period!)); + }, + ), + ), + )); + + await widgetTester.pumpAndSettle(); + + final periodField = find.widgetWithText(DialogTextInput, 'Default Period'); + + expect(periodField, findsOneWidget); + + await widgetTester.enterText(periodField, '0.05'); + await widgetTester.testTextInput.receiveAction(TextInputAction.done); + await widgetTester.pumpAndSettle(); + + expect(preferences.getDouble(PrefKeys.defaultPeriod), 0.05); + verify(fakeSettings.changeDefaultPeriod()).called(greaterThanOrEqualTo(1)); + }); + + testWidgets('Change default graph period', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + await widgetTester.pumpWidget(MaterialApp( + home: Scaffold( + body: SettingsDialog( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + onDefaultGraphPeriodChanged: (period) async { + fakeSettings.changeDefaultGraphPeriod(); + + await preferences.setDouble( + PrefKeys.defaultGraphPeriod, double.parse(period!)); + }, + ), + ), + )); + + await widgetTester.pumpAndSettle(); + + final periodField = + find.widgetWithText(DialogTextInput, 'Default Graph Period'); + + expect(periodField, findsOneWidget); + + await widgetTester.enterText(periodField, '0.05'); + await widgetTester.testTextInput.receiveAction(TextInputAction.done); + await widgetTester.pumpAndSettle(); + + expect(preferences.getDouble(PrefKeys.defaultGraphPeriod), 0.05); + verify(fakeSettings.changeDefaultGraphPeriod()) + .called(greaterThanOrEqualTo(1)); + }); + testWidgets('Change team color', (widgetTester) async { FlutterError.onError = ignoreOverflowErrors; @@ -168,6 +345,10 @@ void main() { await widgetTester.pumpAndSettle(); + expect(appearanceSettings, findsOneWidget); + await widgetTester.tap(appearanceSettings); + await widgetTester.pumpAndSettle(); + final teamColorBox = find.byType(DialogColorPicker); expect(teamColorBox, findsOneWidget); @@ -225,6 +406,10 @@ void main() { await widgetTester.pumpAndSettle(); + expect(appearanceSettings, findsOneWidget); + await widgetTester.tap(appearanceSettings); + await widgetTester.pumpAndSettle(); + final themeVariantDropdown = find.widgetWithText(DialogDropdownChooser, 'Chroma'); @@ -277,6 +462,10 @@ void main() { await widgetTester.pumpAndSettle(); + expect(appearanceSettings, findsOneWidget); + await widgetTester.tap(appearanceSettings); + await widgetTester.pumpAndSettle(); + final gridSwitch = find.widgetWithText(DialogToggleSwitch, 'Show Grid'); expect(gridSwitch, findsOneWidget); @@ -319,6 +508,10 @@ void main() { await widgetTester.pumpAndSettle(); + expect(appearanceSettings, findsOneWidget); + await widgetTester.tap(appearanceSettings); + await widgetTester.pumpAndSettle(); + final gridSizeField = find.widgetWithText(DialogTextInput, 'Grid Size'); expect(gridSizeField, findsOneWidget); @@ -352,6 +545,10 @@ void main() { await widgetTester.pumpAndSettle(); + expect(appearanceSettings, findsOneWidget); + await widgetTester.tap(appearanceSettings); + await widgetTester.pumpAndSettle(); + final cornerRadiusField = find.widgetWithText(DialogTextInput, 'Corner Radius'); @@ -384,6 +581,10 @@ void main() { await widgetTester.pumpAndSettle(); + expect(appearanceSettings, findsOneWidget); + await widgetTester.tap(appearanceSettings); + await widgetTester.pumpAndSettle(); + final autoResizeSwitch = find.widgetWithText( DialogToggleSwitch, 'Resize to Driver Station Height'); @@ -427,6 +628,10 @@ void main() { await widgetTester.pumpAndSettle(); + expect(appearanceSettings, findsOneWidget); + await widgetTester.tap(appearanceSettings); + await widgetTester.pumpAndSettle(); + final windowSwitch = find.widgetWithText(DialogToggleSwitch, 'Remember Window Position'); @@ -470,6 +675,10 @@ void main() { await widgetTester.pumpAndSettle(); + expect(appearanceSettings, findsOneWidget); + await widgetTester.tap(appearanceSettings); + await widgetTester.pumpAndSettle(); + final lockLayoutSwitch = find.widgetWithText(DialogToggleSwitch, 'Lock Layout'); @@ -494,63 +703,16 @@ void main() { verify(fakeSettings.changeLockLayout()).called(2); }); - testWidgets('Change IP address mode', (widgetTester) async { + testWidgets('Open assets', (widgetTester) async { FlutterError.onError = ignoreOverflowErrors; await widgetTester.pumpWidget(MaterialApp( home: Scaffold( body: SettingsDialog( - ntConnection: createMockOfflineNT4(), preferences: preferences, - onIPAddressModeChanged: (mode) { - fakeSettings.changeIPAddressMode(); - }, - ), - ), - )); - - await widgetTester.pumpAndSettle(); - - final ipAddressMode = find.byType(DialogDropdownChooser); - - expect(ipAddressMode, findsOneWidget); - - expect(find.text('Driver Station'), findsOneWidget); - - await widgetTester.tap(ipAddressMode); - await widgetTester.pumpAndSettle(); - - expect(find.text('Team Number (10.TE.AM.2)'), findsOneWidget); - - await widgetTester.tap(find.text('Team Number (10.TE.AM.2)')); - await widgetTester.pumpAndSettle(); - - expect(find.text('Driver Station'), findsNothing); - expect(find.text('Team Number (10.TE.AM.2)'), findsOneWidget); - - await widgetTester.tap(find.text('Team Number (10.TE.AM.2)')); - await widgetTester.pumpAndSettle(); - - expect(find.text('Driver Station'), findsOneWidget); - - await widgetTester.tap(find.text('Driver Station')); - await widgetTester.pumpAndSettle(); - - verify(fakeSettings.changeIPAddressMode()).called(2); - }); - - testWidgets('Change IP address', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; - - await widgetTester.pumpWidget(MaterialApp( - home: Scaffold( - body: SettingsDialog( ntConnection: createMockOfflineNT4(), - preferences: preferences, - onIPAddressChanged: (data) async { - fakeSettings.changeIPAddress(); - - await preferences.setString(PrefKeys.ipAddress, data!); + onOpenAssetsFolderPressed: () { + fakeSettings.openAssetsFolder(); }, ), ), @@ -558,81 +720,16 @@ void main() { await widgetTester.pumpAndSettle(); - final ipAddressField = find.widgetWithText(DialogTextInput, 'IP Address'); - - expect(ipAddressField, findsOneWidget); - - await widgetTester.enterText(ipAddressField, '10.3.53.2'); - await widgetTester.testTextInput.receiveAction(TextInputAction.done); - await widgetTester.pump(); - - expect(preferences.getString(PrefKeys.ipAddress), '10.3.53.2'); - verify(fakeSettings.changeIPAddress()).called(greaterThanOrEqualTo(1)); - }); - - testWidgets('Change default period', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; - - await widgetTester.pumpWidget(MaterialApp( - home: Scaffold( - body: SettingsDialog( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - onDefaultPeriodChanged: (period) async { - fakeSettings.changeDefaultPeriod(); - - await preferences.setDouble( - PrefKeys.defaultPeriod, double.parse(period!)); - }, - ), - ), - )); - + expect(devSettings, findsOneWidget); + await widgetTester.tap(devSettings); await widgetTester.pumpAndSettle(); - final periodField = find.widgetWithText(DialogTextInput, 'Default Period'); + final openAssetsButton = find.text('Open Assets Folder'); - expect(periodField, findsOneWidget); + expect(openAssetsButton, findsOneWidget); - await widgetTester.enterText(periodField, '0.05'); - await widgetTester.testTextInput.receiveAction(TextInputAction.done); - await widgetTester.pumpAndSettle(); - - expect(preferences.getDouble(PrefKeys.defaultPeriod), 0.05); - verify(fakeSettings.changeDefaultPeriod()).called(greaterThanOrEqualTo(1)); - }); + await widgetTester.tap(openAssetsButton); - testWidgets('Change default graph period', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; - - await widgetTester.pumpWidget(MaterialApp( - home: Scaffold( - body: SettingsDialog( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - onDefaultGraphPeriodChanged: (period) async { - fakeSettings.changeDefaultGraphPeriod(); - - await preferences.setDouble( - PrefKeys.defaultGraphPeriod, double.parse(period!)); - }, - ), - ), - )); - - await widgetTester.pumpAndSettle(); - - final periodField = - find.widgetWithText(DialogTextInput, 'Default Graph Period'); - - expect(periodField, findsOneWidget); - - await widgetTester.enterText(periodField, '0.05'); - await widgetTester.testTextInput.receiveAction(TextInputAction.done); - await widgetTester.pumpAndSettle(); - - expect(preferences.getDouble(PrefKeys.defaultGraphPeriod), 0.05); - verify(fakeSettings.changeDefaultGraphPeriod()) - .called(greaterThanOrEqualTo(1)); + verify(fakeSettings.openAssetsFolder()).called(1); }); } diff --git a/test/widgets/tab_grid_test.dart b/test/widgets/tab_grid_test.dart index 5d3544df..342d5e73 100644 --- a/test/widgets/tab_grid_test.dart +++ b/test/widgets/tab_grid_test.dart @@ -5,6 +5,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -40,6 +41,7 @@ import 'package:elastic_dashboard/widgets/nt_widgets/single_topic/toggle_switch. import 'package:elastic_dashboard/widgets/nt_widgets/single_topic/voltage_view.dart'; import 'package:elastic_dashboard/widgets/tab_grid.dart'; import '../test_util.dart'; +import '../test_util.mocks.dart'; void main() async { TestWidgetsFlutterBinding.ensureInitialized(); @@ -90,7 +92,7 @@ void main() async { ), ); - await widgetTester.pump(Duration.zero); + await widgetTester.pumpAndSettle(); expect(find.bySubtype(), findsNWidgets(10)); expect(find.bySubtype(), findsNWidgets(11)); @@ -139,7 +141,7 @@ void main() async { ), ); - await widgetTester.pump(Duration.zero); + await widgetTester.pumpAndSettle(); expect(find.bySubtype(), findsNWidgets(14)); expect(find.bySubtype(), findsNWidgets(14)); @@ -181,7 +183,7 @@ void main() async { ), ); - await widgetTester.pump(Duration.zero); + await widgetTester.pumpAndSettle(); await widgetTester.ensureVisible(find.text('Test Number')); @@ -258,7 +260,7 @@ void main() async { ), ); - await widgetTester.pump(Duration.zero); + await widgetTester.pumpAndSettle(); await widgetTester.ensureVisible(find.text('Test Number')); @@ -318,7 +320,7 @@ void main() async { ), ); - await widgetTester.pump(Duration.zero); + await widgetTester.pumpAndSettle(); final gyroWidget = find.widgetWithText(WidgetContainer, 'Test Gyro'); @@ -345,4 +347,50 @@ void main() async { expect(gyroWidget, findsOneWidget); }); + + testWidgets('Disposing properly unsubscribes', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + widgetTester.view.physicalSize = const Size(1920, 1080); + widgetTester.view.devicePixelRatio = 1.0; + + expect(jsonData.containsKey('tabs'), true); + + expect(jsonData['tabs'][0].containsValue('Teleoperated'), true); + expect(jsonData['tabs'][0].containsKey('grid_layout'), true); + + expect(jsonData['tabs'][1].containsValue('Autonomous'), true); + expect(jsonData['tabs'][1].containsKey('grid_layout'), true); + + MockNTConnection ntConnection = createMockOnlineNT4(); + + TabGridModel tabGridModel = TabGridModel.fromJson( + ntConnection: ntConnection, + preferences: preferences, + jsonData: jsonData['tabs'][0]['grid_layout'], + onAddWidgetPressed: () {}, + ); + + await widgetTester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ChangeNotifierProvider.value( + value: tabGridModel, + child: const TabGrid(), + ), + ), + ), + ); + + await widgetTester.pumpAndSettle(); + + int subscribeCallCount = verify(ntConnection.subscribe(any, any)).callCount; + + expect(subscribeCallCount, greaterThan(10)); + + tabGridModel.clearWidgets(); + + await widgetTester.pumpAndSettle(); + + verify(ntConnection.unSubscribe(any)).called(subscribeCallCount); + }); } diff --git a/test_resources/test-layout.json b/test_resources/test-layout.json index 951427b9..c4878b34 100644 --- a/test_resources/test-layout.json +++ b/test_resources/test-layout.json @@ -92,7 +92,9 @@ "robot_width": 0.82, "robot_length": 1.0, "show_other_objects": true, - "show_trajectories": true + "show_trajectories": true, + "robot_color": 4294198070, + "trajectory_color": 4294967295 } }, { diff --git a/wpilib_icon_config.yaml b/wpilib_icon_config.yaml new file mode 100644 index 00000000..6f20ac3d --- /dev/null +++ b/wpilib_icon_config.yaml @@ -0,0 +1,12 @@ +flutter_launcher_icons: + image_path: "assets/logos/wpilib_logo.png" + windows: + generate: true + image_path: "assets/logos/wpilib_logo.png" + icon_size: 256 + macos: + generate: true + image_path: "assets/logos/wpilib_logo.png" + linux: + generate: true + image_path: "assets/logos/wpilib_logo.png"