diff --git a/lib/main.dart b/lib/main.dart index a5bc577..671907f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; import 'package:dart_ping_ios/dart_ping_ios.dart'; +import 'package:desktop_drop/desktop_drop.dart'; import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; import 'package:network_info_plus/network_info_plus.dart'; @@ -129,6 +130,9 @@ class _MyHomePageState extends State { bool scanningNetwork = false; String networkScanProgress = ""; + // Drag and drop + bool _isDragging = false; + // CLI List cliHistory = [""]; List cliCommandHistory = [""]; @@ -283,13 +287,90 @@ class _MyHomePageState extends State { } Future _onOpenDMP() async { - final result = await _fileService.openDMPFile(); - if (result.success && result.hasData) { - transferBuffer = result.data!; - setState(() { - dmpLoaded = transferBuffer.isNotEmpty; - }); - await _analyzeTransferBuffer(); + final result = await _fileService.openDMPFiles(); + if (result.success && result.hasResults) { + await _processMultipleFiles(result); + } + } + + Future _processMultipleFiles(MultiFileResult result) async { + int processedCount = 0; + final successfulFiles = result.successfulFiles; + final failedFiles = result.failedFiles; + + for (final fileResult in successfulFiles) { + if (fileResult.hasData) { + transferBuffer = fileResult.data!; + final dataResult = await _dataService.processTransferBuffer(transferBuffer, unitType); + + if (dataResult.success) { + setState(() { + sections.mergeSections(dataResult.sections); + dmpLoaded = true; + }); + processedCount++; + + if (dataResult.brokenSegmentDetected) { + await _showBrokenSegmentWarning(); + } + } + } + } + + // Show summary message + String message = "Processed $processedCount files successfully"; + if (failedFiles.isNotEmpty) { + message += "\n${failedFiles.length} files failed to load:"; + for (final failed in failedFiles) { + message += "\n• ${failed.fileName}: ${failed.error}"; + } + } + + if (mounted) { + if (failedFiles.isNotEmpty) { + _showErrorMessage(message); + } else { + _showMessage(message); + } + } + } + + // Handle dropped files + Future _onFilesDropped(List filePaths) async { + final dmpFiles = filePaths.where((path) => path.toLowerCase().endsWith('.dmp')).toList(); + + if (dmpFiles.isEmpty) { + _showErrorMessage("No DMP files found in dropped files"); + return; + } + + try { + final fileResults = []; + + for (final filePath in dmpFiles) { + try { + final file = File(filePath); + final fileName = file.path.split(Platform.pathSeparator).last; + final transferBuffer = await _fileService.parseDMPFile(file); + + fileResults.add(FileProcessingResult.success( + fileName: fileName, + data: transferBuffer, + )); + } catch (e) { + final fileName = filePath.split(Platform.pathSeparator).last; + fileResults.add(FileProcessingResult.error( + fileName: fileName, + error: "Failed to parse: $e", + )); + } + } + + final result = MultiFileResult.success(fileResults); + await _processMultipleFiles(result); + + } catch (e) { + _showErrorMessage("Error processing dropped files: $e"); } } @@ -298,7 +379,7 @@ class _MyHomePageState extends State { if (result.success) { setState(() { - sections.sections = result.sections; + sections.mergeSections(result.sections); }); if (result.brokenSegmentDetected) { @@ -822,21 +903,88 @@ class _MyHomePageState extends State { ), ], ), - body: (!connected && !dmpLoaded) - ? WelcomeScreen( - scanningNetwork: scanningNetwork, - networkDeviceFound: networkDeviceFound, - networkScanProgress: networkScanProgress, - ipController: ipController, - ipMNemo: ipMNemo, - onRefreshMnemo: _onRefreshMnemo, - onOpenDMP: _onOpenDMP, - onNetworkScan: _onNetworkScan, - onNetworkScanStop: _onNetworkScanStop, - onNetworkDMP: _onNetworkDMP, - onIPChanged: _onIPChanged, - ) - : _buildMainInterface(), + body: _buildDragTarget( + (!connected && !dmpLoaded) + ? WelcomeScreen( + scanningNetwork: scanningNetwork, + networkDeviceFound: networkDeviceFound, + networkScanProgress: networkScanProgress, + ipController: ipController, + ipMNemo: ipMNemo, + onRefreshMnemo: _onRefreshMnemo, + onOpenDMP: _onOpenDMP, + onNetworkScan: _onNetworkScan, + onNetworkScanStop: _onNetworkScanStop, + onNetworkDMP: _onNetworkDMP, + onIPChanged: _onIPChanged, + ) + : _buildMainInterface(), + ), + ); + } + + Widget _buildDragTarget(Widget child) { + // For desktop platforms, wrap with drag target + if (Platform.isAndroid || Platform.isIOS) { + return child; + } + + return DropTarget( + onDragDone: (details) async { + setState(() { + _isDragging = false; + }); + + final files = details.files; + final dmpFiles = files + .where((file) => file.path.toLowerCase().endsWith('.dmp')) + .map((file) => file.path) + .toList(); + + if (dmpFiles.isNotEmpty) { + await _onFilesDropped(dmpFiles); + } + }, + onDragEntered: (details) { + setState(() { + _isDragging = true; + }); + }, + onDragExited: (details) { + setState(() { + _isDragging = false; + }); + }, + child: Stack( + children: [ + child, + if (_isDragging) + Container( + color: Colors.blue.withValues(alpha: 0.3), + child: const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.file_upload, + size: 64, + color: Colors.white, + ), + SizedBox(height: 16), + Text( + 'Drop DMP files here to load them', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ], + ), + ), + ), + ], + ), ); } diff --git a/lib/models/section_list.dart b/lib/models/section_list.dart index 288e183..e98d923 100644 --- a/lib/models/section_list.dart +++ b/lib/models/section_list.dart @@ -102,6 +102,122 @@ class SectionList { } } + /// Merge new sections with existing sections, resolving conflicts + /// + /// Merge behavior: + /// - Case 1: New section doesn't exist → Add it to existing sections + /// - Case 2: Section exists with same properties (shot count, start/end depths) → Skip (no duplicate) + /// - Case 3: Section exists with same name but different properties → Add with incremented name + void mergeSections(List
newSections) { + for (final newSection in newSections) { + _mergeSection(newSection); + } + } + + /// Merge a single section, handling conflicts + void _mergeSection(Section newSection) { + final existingSection = _findConflictingSection(newSection); + + if (existingSection == null) { + // Case 1: Section does not exist - add it + sections.add(newSection); + } else { + if (_areSectionsIdentical(existingSection, newSection)) { + // Case 2: Section exists with same properties (shot count, depth range) - skip it + return; + } else { + // Case 3: Section exists with same name but different properties - add with new name + final uniqueName = _generateUniqueName(newSection.name); + newSection.name = uniqueName; + sections.add(newSection); + } + } + } + + /// Find existing section that conflicts with the new section + Section? _findConflictingSection(Section newSection) { + for (final section in sections) { + if (section.name == newSection.name) { + return section; + } + } + return null; + } + + /// Check if two sections are identical based on shot count and depth range + /// Sections are considered identical if they have: + /// - Same number of shots + /// - Same starting depth + /// - Same ending depth + bool _areSectionsIdentical(Section section1, Section section2) { + if (section1.shots.length != section2.shots.length) { + return false; + } + + if (section1.depthStart != section2.depthStart) { + return false; + } + + if (section1.depthEnd != section2.depthEnd) { + return false; + } + + return true; + } + + /// Generate a unique section name by incrementing suffix + String _generateUniqueName(String baseName) { + if (baseName.length < 3) { + baseName = baseName.padRight(3, '0'); + } + + String newName = _incrementName(baseName); + + while (_nameExists(newName)) { + newName = _incrementName(newName); + } + + return newName; + } + + /// Check if a name already exists + bool _nameExists(String name) { + for (final section in sections) { + if (section.name == name) { + return true; + } + } + return false; + } + + /// Increment section name following format (AA1->AA2, AA9->AB0, AZ9->BA0) + String _incrementName(String sectionName) { + if (sectionName.length != 3) { + return sectionName; + } + + final chars = sectionName.split(''); + final letter1 = chars[0]; + final letter2 = chars[1]; + final number = int.tryParse(chars[2]) ?? 0; + + if (number < 9) { + return '$letter1$letter2${number + 1}'; + } else { + if (letter2 != 'Z') { + final nextLetter2 = String.fromCharCode(letter2.codeUnitAt(0) + 1); + return '$letter1${nextLetter2}0'; + } else { + if (letter1 != 'Z') { + final nextLetter1 = String.fromCharCode(letter1.codeUnitAt(0) + 1); + return '${nextLetter1}A0'; + } else { + return 'AA0'; + } + } + } + } + /// Legacy getters/setters for compatibility with existing code List
getSections() => sections; void setSections(List
newSections) => sections = newSections; diff --git a/lib/services/data_processing_service.dart b/lib/services/data_processing_service.dart index c55871e..1fda33a 100644 --- a/lib/services/data_processing_service.dart +++ b/lib/services/data_processing_service.dart @@ -23,6 +23,10 @@ class DataProcessingService { UnitType unitType ) async { try { + if (transferBuffer.isEmpty) { + return DataProcessingResult.error("Transfer buffer is empty"); + } + final sections =
[]; int cursor = 0; bool brokenSegmentDetected = false; @@ -49,6 +53,11 @@ class DataProcessingService { if (sectionResult.shouldStop) { break; } + + // Safety check to prevent infinite loops + if (cursor <= 0 || cursor >= transferBuffer.length) { + break; + } } return DataProcessingResult.success( @@ -346,16 +355,25 @@ class DataProcessingService { /// Read a single byte from the buffer int _readByteFromBuffer(List buffer, int address) { - return address < buffer.length ? buffer[address] : 0; + if (address < 0 || address >= buffer.length) { + return 0; + } + return buffer[address]; } /// Read a 16-bit integer from the buffer (little endian) int _readIntFromBuffer(List buffer, int address) { - if (address + 1 >= buffer.length) return 0; + if (address < 0 || address + 1 >= buffer.length) { + return 0; + } - final bytes = Uint8List.fromList([buffer[address], buffer[address + 1]]); - final byteData = ByteData.sublistView(bytes); - return byteData.getInt16(0); + try { + final bytes = Uint8List.fromList([buffer[address], buffer[address + 1]]); + final byteData = ByteData.sublistView(bytes); + return byteData.getInt16(0); + } catch (e) { + return 0; + } } } diff --git a/lib/services/file_service.dart b/lib/services/file_service.dart index 6b82569..3a62e73 100644 --- a/lib/services/file_service.dart +++ b/lib/services/file_service.dart @@ -1,59 +1,237 @@ import 'dart:io'; import 'dart:convert'; -import 'package:csv/csv.dart'; +import 'dart:collection'; +import 'dart:math' as math; import 'package:file_picker/file_picker.dart'; import '../models/models.dart'; import '../excelexport.dart'; import '../survexporter.dart'; import '../thexporter.dart'; +/// Memory pool for efficient buffer reuse +class _BufferPool { + final Queue> _pool = Queue(); + static const int _maxPoolSize = 5; + static const int _maxBufferSize = 50000; // Don't pool huge buffers + + List acquire() { + if (_pool.isNotEmpty) { + final buffer = _pool.removeFirst(); + buffer.clear(); + return buffer; + } + return []; + } + + void release(List buffer) { + if (_pool.length < _maxPoolSize && buffer.length < _maxBufferSize) { + _pool.add(buffer); + } + } +} + /// Service for handling file operations (import/export) class FileService { + static final _BufferPool _bufferPool = _BufferPool(); - /// Open and parse a DMP file - Future openDMPFile() async { + /// Open and parse DMP file(s) - supports both single and multiple selection + Future openDMPFiles() async { try { FilePickerResult? result; if (Platform.isAndroid || Platform.isIOS) { result = await FilePicker.platform.pickFiles( - dialogTitle: "Open DMP", + dialogTitle: "Open DMP File(s)", type: FileType.any, - allowMultiple: false, + allowMultiple: true, ); } else { result = await FilePicker.platform.pickFiles( - dialogTitle: "Open DMP", + dialogTitle: "Open DMP File(s) - Hold Ctrl/Cmd or Shift for multiple", type: FileType.custom, allowedExtensions: ["dmp"], - allowMultiple: false, + allowMultiple: true, ); } if (result == null) { - return FileResult.cancelled(); + return MultiFileResult.cancelled(); } - final file = File(result.files.first.path!); - final input = file.openRead(); + final fileResults = []; - final fields = await input - .transform(utf8.decoder) - .transform(const CsvToListConverter(fieldDelimiter: ';')) - .toList(); - - final transferBuffer = []; - for (var element in fields[0]) { - if (element != "") transferBuffer.add(element); + for (int i = 0; i < result.files.length; i++) { + final platformFile = result.files[i]; + + if (platformFile.path == null) { + continue; + } + + try { + final file = File(platformFile.path!); + + // Early validation pipeline + final validationResult = await _validateDMPFile(file); + if (!validationResult.isValid) { + fileResults.add(FileProcessingResult.error( + fileName: platformFile.name, + error: validationResult.error!, + )); + continue; + } + + final transferBuffer = await parseDMPFileOptimized( + file, + onProgress: (progress) { + // Progress reporting could be exposed here if needed + }, + ); + + fileResults.add(FileProcessingResult.success( + fileName: platformFile.name, + data: transferBuffer, + )); + } catch (e) { + fileResults.add(FileProcessingResult.error( + fileName: platformFile.name, + error: "Failed to parse: $e", + )); + } } - return FileResult.success(transferBuffer); + return MultiFileResult.success(fileResults); } catch (e) { - return FileResult.error("Failed to open DMP file: $e"); + return MultiFileResult.error("Failed to open DMP files: $e"); } } + /// Early validation pipeline for DMP files + Future<_ValidationResult> _validateDMPFile(File file) async { + try { + if (!await file.exists()) { + return _ValidationResult.invalid("File does not exist"); + } + + final fileSize = await file.length(); + if (fileSize == 0) { + return _ValidationResult.invalid("File is empty (0 bytes)"); + } + + if (fileSize < 48) { + return _ValidationResult.invalid( + "File too small ($fileSize bytes) - minimum 48 bytes required for valid DMP format" + ); + } + + // Quick format validation - read first line to check file version + final firstBytes = await file.openRead(0, math.min(200, fileSize)).toList(); + final firstChunk = utf8.decode(firstBytes.expand((x) => x).toList()); + final firstLineEnd = firstChunk.indexOf('\n'); + final firstLine = firstLineEnd > 0 ? firstChunk.substring(0, firstLineEnd) : firstChunk; + final fields = firstLine.split(';'); + + if (fields.isEmpty) { + return _ValidationResult.invalid("Invalid CSV format - no fields found"); + } + + final version = _parseElementOptimized(fields.first); + if (version == null || version < 2 || version > 5) { + return _ValidationResult.invalid( + "Invalid DMP file version: ${fields.first}. Supported versions: 2-5" + ); + } + + return _ValidationResult.valid(); + } catch (e) { + return _ValidationResult.invalid("Validation failed: $e"); + } + } + + /// Optimized integer parsing with early type checking + int? _parseElementOptimized(dynamic element) { + if (element == null || element == "") return null; + + // Fast path for integers + if (element is int) return element; + + // Optimized string parsing + if (element is String) { + if (element.isEmpty) return null; + return int.tryParse(element); + } + + // Fallback for other types + return int.tryParse(element.toString()); + } + + /// Stream-based DMP file parsing with batching and progress reporting + Future> parseDMPFileOptimized( + File file, { + void Function(double progress)? onProgress, + }) async { + const batchSize = 1000; + final fileSize = await file.length(); + int processedBytes = 0; + + final transferBuffer = _bufferPool.acquire(); + + try { + final stream = file.openRead() + .transform(utf8.decoder) + .transform(LineSplitter()); + + await for (final line in stream) { + processedBytes += line.length + 1; // +1 for newline + + // Parse CSV line + final fields = line.split(';'); + if (fields.isEmpty) continue; + + // Process in batches to avoid blocking UI + for (int start = 0; start < fields.length; start += batchSize) { + final end = math.min(start + batchSize, fields.length); + + for (int i = start; i < end; i++) { + final value = _parseElementOptimized(fields[i]); + if (value != null) { + transferBuffer.add(value); + } + } + + // Yield control back to UI thread after each batch + if (end - start >= batchSize) { + await Future.delayed(Duration.zero); + } + } + + // Report progress + onProgress?.call(processedBytes / fileSize); + + // Only process first line for DMP files (they're single-line CSV) + break; + } + + if (transferBuffer.isEmpty) { + throw Exception("No valid numeric data found in DMP file"); + } + + // Create a copy since we're returning from the pool + final result = List.from(transferBuffer); + return result; + + } catch (e) { + rethrow; + } finally { + _bufferPool.release(transferBuffer); + } + } + + /// Legacy method for backward compatibility + Future> parseDMPFile(File file) async { + return parseDMPFileOptimized(file); + } + /// Save data as DMP file Future saveDMPFile(List transferBuffer) async { try { @@ -241,4 +419,64 @@ class FileResult { bool get hasData => data != null && data!.isNotEmpty; bool get isCancelled => !success && error == null; +} + +/// Result of processing multiple files +class MultiFileResult { + final bool success; + final List? results; + final String? error; + + const MultiFileResult._(this.success, this.results, this.error); + + factory MultiFileResult.success(List results) => + MultiFileResult._(true, results, null); + + factory MultiFileResult.error(String error) => + MultiFileResult._(false, null, error); + + factory MultiFileResult.cancelled() => + MultiFileResult._(false, null, "Operation cancelled"); + + bool get hasResults => results != null && results!.isNotEmpty; + bool get isCancelled => !success && error == "Operation cancelled"; + + List get successfulFiles => + results?.where((r) => r.success).toList() ?? []; + + List get failedFiles => + results?.where((r) => !r.success).toList() ?? []; +} + +/// Result of processing a single file within a multi-file operation +class FileProcessingResult { + final bool success; + final String fileName; + final List? data; + final String? error; + + const FileProcessingResult._(this.success, this.fileName, this.data, this.error); + + factory FileProcessingResult.success({ + required String fileName, + required List data, + }) => FileProcessingResult._(true, fileName, data, null); + + factory FileProcessingResult.error({ + required String fileName, + required String error, + }) => FileProcessingResult._(false, fileName, null, error); + + bool get hasData => data != null && data!.isNotEmpty; +} + +/// Result of early file validation +class _ValidationResult { + final bool isValid; + final String? error; + + const _ValidationResult._(this.isValid, this.error); + + factory _ValidationResult.valid() => const _ValidationResult._(true, null); + factory _ValidationResult.invalid(String error) => _ValidationResult._(false, error); } \ No newline at end of file diff --git a/lib/widgets/data_toolbar.dart b/lib/widgets/data_toolbar.dart index 9bd2470..09660ba 100644 --- a/lib/widgets/data_toolbar.dart +++ b/lib/widgets/data_toolbar.dart @@ -39,9 +39,88 @@ class DataToolbar extends StatelessWidget { @override Widget build(BuildContext context) { return AppBar( - leading: _buildInputActions(), - leadingWidth: hasSections ? 240 : 192, - actions: _buildExportActions(), + // Left side - Input functions + leading: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Select all/deselect all button + if (hasSections) + IconButton( + onPressed: onToggleSelectAll, + icon: Icon(allSectionsSelected ? Icons.deselect : Icons.select_all), + tooltip: allSectionsSelected ? "Deselect All" : "Select All", + ), + + // Clear data button + IconButton( + onPressed: !hasSections ? null : onReset, + icon: const Icon(Icons.backspace_rounded), + tooltip: "Clear local Data", + ), + + // Read data from device button + IconButton( + onPressed: (serialBusy || !connected) ? null : onReadData, + icon: const Icon(Icons.download_rounded), + tooltip: "Read Data from Device", + ), + + // Open DMP file button + FileIcon( + onPressed: serialBusy ? null : onOpenDMP, + icon: Icons.file_open, + extension: 'DMP', + tooltip: "Open DMP file(s)\nHold Ctrl/Cmd or Shift to select multiple", + size: 24, + color: serialBusy ? Colors.black26 : Colors.black54, + extensionColor: serialBusy ? Colors.black26 : Colors.black87, + ), + ], + ), + leadingWidth: hasSections ? 240 : 192, // Adjust width based on whether select all button is shown + + // Right side - Export functions + actions: [ + // Save DMP file button + FileIcon( + onPressed: (serialBusy || !hasSelectedSections) ? null : onSaveDMP, + extension: 'DMP', + tooltip: "Save selected surveys as DMP", + size: 24, + color: (serialBusy || !hasSelectedSections) ? Colors.black26 : Colors.black54, + extensionColor: (serialBusy || !hasSelectedSections) ? Colors.black26 : Colors.black87, + ), + + // Export to Excel button + FileIcon( + onPressed: (serialBusy || !hasSelectedSections) ? null : onExportXLS, + extension: 'XLS', + tooltip: "Export selected surveys as Excel", + size: 24, + color: (serialBusy || !hasSelectedSections) ? Colors.black26 : Colors.black54, + extensionColor: (serialBusy || !hasSelectedSections) ? Colors.black26 : Colors.black87, + ), + + // Export to Survex button + FileIcon( + onPressed: (serialBusy || !hasSelectedSections) ? null : onExportSVX, + extension: 'SVX', + tooltip: "Export selected surveys as Survex", + size: 24, + color: (serialBusy || !hasSelectedSections) ? Colors.black26 : Colors.black54, + extensionColor: (serialBusy || !hasSelectedSections) ? Colors.black26 : Colors.black87, + ), + + // Export to Therion button + FileIcon( + onPressed: (serialBusy || !hasSelectedSections) ? null : onExportTH, + extension: 'TH', + tooltip: "Export selected surveys as Therion", + size: 24, + color: (serialBusy || !hasSelectedSections) ? Colors.black26 : Colors.black54, + extensionColor: (serialBusy || !hasSelectedSections) ? Colors.black26 : Colors.black87, + ), + ], backgroundColor: Colors.white30, ); } diff --git a/lib/widgets/welcome_screen.dart b/lib/widgets/welcome_screen.dart index 85bb02c..cbef521 100644 --- a/lib/widgets/welcome_screen.dart +++ b/lib/widgets/welcome_screen.dart @@ -50,16 +50,28 @@ class WelcomeScreen extends StatelessWidget { ], // File operations section - const Text("Open a DMP file"), + const Text("Open DMP file(s)"), FileIcon( icon: Icons.file_open, onPressed: onOpenDMP, extension: 'DMP', - tooltip: "Open a DMP", + tooltip: "Open DMP file(s)\nHold Ctrl/Cmd or Shift to select multiple", size: 24, color: Colors.black54, extensionColor: Colors.black87, ), + if (!Platform.isAndroid && !Platform.isIOS) + const Padding( + padding: EdgeInsets.only(top: 8.0), + child: Text( + "Or drag and drop DMP files here", + style: TextStyle( + fontSize: 12, + color: Colors.grey, + fontStyle: FontStyle.italic, + ), + ), + ), const SizedBox(width: 10, height: 60), // Network connection section diff --git a/pubspec.yaml b/pubspec.yaml index a953ed4..a990c75 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -33,6 +33,7 @@ dependencies: dart_numerics: ^0.0.6 dart_ping: ^9.0.1 dart_ping_ios: ^4.0.2 + desktop_drop: ^0.4.4 dio: null disks_desktop: ^1.0.1 excel: ^4.0.0 diff --git a/test/widget_test.dart b/test/widget_test.dart deleted file mode 100644 index 0750fbb..0000000 --- a/test/widget_test.dart +++ /dev/null @@ -1,30 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:mnemolink/main.dart'; - -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); -}