diff --git a/lib/models/section.dart b/lib/models/section.dart index 2dcec1f..5592203 100644 --- a/lib/models/section.dart +++ b/lib/models/section.dart @@ -92,6 +92,9 @@ class Section { bool getIsSelected() => isSelected; void setIsSelected(bool selected) => isSelected = selected; + /// Toggle selection state + void toggleSelection() => isSelected = !isSelected; + double getLength() => length; double getDepthStart() => depthStart; double getDepthEnd() => depthEnd; diff --git a/lib/models/section_list.dart b/lib/models/section_list.dart index 1414438..288e183 100644 --- a/lib/models/section_list.dart +++ b/lib/models/section_list.dart @@ -43,14 +43,35 @@ class SectionList { /// Get only selected sections List
get selectedSections => sections.where((section) => section.isSelected).toList(); + /// Get selection statistics in a single pass for better performance + SelectionStats get selectionStats { + if (sections.isEmpty) return SelectionStats.empty(); + + int selectedCount = 0; + for (final section in sections) { + if (section.isSelected) selectedCount++; + } + + return SelectionStats( + total: sections.length, + selected: selectedCount, + ); + } + /// Check if all sections are selected - bool get allSelected => sections.isNotEmpty && sections.every((section) => section.isSelected); + bool get allSelected { + final stats = selectionStats; + return stats.total > 0 && stats.selected == stats.total; + } /// Check if no sections are selected - bool get noneSelected => sections.every((section) => !section.isSelected); + bool get noneSelected => selectionStats.selected == 0; /// Check if some sections are selected (for partial selection state) - bool get someSelected => sections.any((section) => section.isSelected) && !allSelected; + bool get someSelected { + final stats = selectionStats; + return stats.selected > 0 && stats.selected < stats.total; + } /// Select all sections void selectAll() { @@ -66,16 +87,47 @@ class SectionList { } } - /// Toggle selection for all sections + /// Toggle selection for all sections (optimized) void toggleSelectAll() { - if (allSelected) { - deselectAll(); - } else { - selectAll(); + final shouldSelectAll = selectionStats.selected < sections.length; + for (final section in sections) { + section.isSelected = shouldSelectAll; + } + } + + /// Set selection state for multiple sections efficiently + void setSelectionForSections(List
sectionsToUpdate, bool selected) { + for (final section in sectionsToUpdate) { + section.isSelected = selected; } } /// Legacy getters/setters for compatibility with existing code List
getSections() => sections; void setSections(List
newSections) => sections = newSections; +} + +/// Efficient statistics for section selection state +class SelectionStats { + final int total; + final int selected; + + const SelectionStats({required this.total, required this.selected}); + + const SelectionStats.empty() : total = 0, selected = 0; + + /// Get number of unselected sections + int get unselected => total - selected; + + /// Check if all are selected + bool get allSelected => total > 0 && selected == total; + + /// Check if none are selected + bool get noneSelected => selected == 0; + + /// Check if some are selected + bool get someSelected => selected > 0 && selected < total; + + @override + String toString() => 'SelectionStats(selected: $selected, total: $total)'; } \ No newline at end of file diff --git a/lib/widgets/data_toolbar.dart b/lib/widgets/data_toolbar.dart index 567aef3..9bd2470 100644 --- a/lib/widgets/data_toolbar.dart +++ b/lib/widgets/data_toolbar.dart @@ -39,89 +39,115 @@ class DataToolbar extends StatelessWidget { @override Widget build(BuildContext context) { return AppBar( - // 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", - 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, - ), - ], + leading: _buildInputActions(), + leadingWidth: hasSections ? 240 : 192, + actions: _buildExportActions(), backgroundColor: Colors.white30, ); } + + Widget _buildInputActions() { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (hasSections) _buildSelectAllButton(), + _buildClearDataButton(), + _buildReadDataButton(), + _buildOpenDMPButton(), + ], + ); + } + + Widget _buildSelectAllButton() { + return IconButton( + onPressed: onToggleSelectAll, + icon: Icon(allSectionsSelected ? Icons.deselect : Icons.select_all), + tooltip: allSectionsSelected ? "Deselect All" : "Select All", + ); + } + + Widget _buildClearDataButton() { + return IconButton( + onPressed: !hasSections ? null : onReset, + icon: const Icon(Icons.backspace_rounded), + tooltip: "Clear local Data", + ); + } + + Widget _buildReadDataButton() { + return IconButton( + onPressed: (serialBusy || !connected) ? null : onReadData, + icon: const Icon(Icons.download_rounded), + tooltip: "Read Data from Device", + ); + } + + Widget _buildOpenDMPButton() { + return FileIcon( + onPressed: serialBusy ? null : onOpenDMP, + icon: Icons.file_open, + extension: 'DMP', + tooltip: "Open DMP file", + size: 24, + color: serialBusy ? Colors.black26 : Colors.black54, + extensionColor: serialBusy ? Colors.black26 : Colors.black87, + ); + } + + List _buildExportActions() { + return [ + _buildSaveDMPButton(), + _buildExportExcelButton(), + _buildExportSurvexButton(), + _buildExportTherionButton(), + ]; + } + + Widget _buildSaveDMPButton() { + return _buildExportFileIcon( + onPressed: onSaveDMP, + extension: 'DMP', + tooltip: "Save selected surveys as DMP", + ); + } + + Widget _buildExportExcelButton() { + return _buildExportFileIcon( + onPressed: onExportXLS, + extension: 'XLS', + tooltip: "Export selected surveys as Excel", + ); + } + + Widget _buildExportSurvexButton() { + return _buildExportFileIcon( + onPressed: onExportSVX, + extension: 'SVX', + tooltip: "Export selected surveys as Survex", + ); + } + + Widget _buildExportTherionButton() { + return _buildExportFileIcon( + onPressed: onExportTH, + extension: 'TH', + tooltip: "Export selected surveys as Therion", + ); + } + + Widget _buildExportFileIcon({ + required VoidCallback? onPressed, + required String extension, + required String tooltip, + }) { + final isEnabled = !serialBusy && hasSelectedSections; + return FileIcon( + onPressed: isEnabled ? onPressed : null, + extension: extension, + tooltip: tooltip, + size: 24, + color: isEnabled ? Colors.black54 : Colors.black26, + extensionColor: isEnabled ? Colors.black87 : Colors.black26, + ); + } } \ No newline at end of file diff --git a/lib/widgets/sectioncard.dart b/lib/widgets/sectioncard.dart index 94c9b75..8662d75 100644 --- a/lib/widgets/sectioncard.dart +++ b/lib/widgets/sectioncard.dart @@ -56,77 +56,108 @@ String generateRandomString(int length) { return Card( elevation: 2, child: ListTile( - title: Text( - "${widget.section.name} - " - "dir: ${(widget.section.direction == SurveyDirection.surveyIn) ? "in" : "out"} - " - "shots: ${widget.section.shots.length - 1}"), - subtitle: Text( - "Length: ${widget.section.getLength().toStringAsFixed(2)}m - " - "Depth (start-(min/max)-end): " - "${widget.section.getDepthStart().toStringAsFixed(2)}-" - "(${widget.section.getDepthMin().toStringAsFixed(2)}/" - "${widget.section.getDepthMax().toStringAsFixed(2)})-" - "${widget.section.getDepthEnd().toStringAsFixed(2)}m"), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (widget.section.getBrokenFlag()) - const Tooltip( - message: 'Recovered section', - child: Icon( - Icons.fmd_bad, - color: Colors.orange, - ), - ), - if (widget.section.hasProblematicShots()) ...[ - const SizedBox(width: 6), - const Tooltip( - message: 'Contains shots with calculated lengths', - child: Icon( - Icons.straighten, - color: Colors.red, - ), - ), - ], - const SizedBox(width: 6), - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text(DateFormat('yyyy-MM-dd HH:mm').format(widget.section.dateSurvey)), - const SizedBox(height: 2), - StarRatingWidget( - quality: qualityService.scoreSurveySection(widget.section), - size: 14.0, - ), - ], - ), - ], - ), - leading: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Checkbox( - value: widget.section.isSelected, - onChanged: (bool? value) { - if (value != null && widget.onSelectionChanged != null) { - widget.onSelectionChanged!(widget.section, value); - } - }, - activeColor: Colors.blue, - ), - WidgetZoom( - heroAnimationTag: generateRandomString(10), - zoomWidget: Container( - child: picture, - ), - ), - ], - ), + title: _buildTitle(), + subtitle: _buildSubtitle(), + trailing: _buildTrailing(), + leading: _buildLeading(), ), ); } + Widget _buildTitle() { + final direction = widget.section.direction == SurveyDirection.surveyIn ? "in" : "out"; + final shotCount = widget.section.shots.length - 1; + return Text("${widget.section.name} - direction: $direction - shots: $shotCount"); + } + + Widget _buildSubtitle() { + final length = widget.section.getLength().toStringAsFixed(2); + final depthStart = widget.section.getDepthStart().toStringAsFixed(2); + final depthMin = widget.section.getDepthMin().toStringAsFixed(2); + final depthMax = widget.section.getDepthMax().toStringAsFixed(2); + final depthEnd = widget.section.getDepthEnd().toStringAsFixed(2); + + return Text( + "Length: ${length}m - Depth (start-(min/max)-end): " + "$depthStart-($depthMin/$depthMax)-${depthEnd}m" + ); + } + + Widget _buildTrailing() { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.section.getBrokenFlag()) _buildBrokenIcon(), + if (widget.section.hasProblematicShots()) ...[ + const SizedBox(width: 6), + _buildProblematicShotsIcon(), + ], + const SizedBox(width: 6), + _buildSectionInfo(), + ], + ); + } + + Widget _buildBrokenIcon() { + return const Tooltip( + message: 'Recovered section', + child: Icon(Icons.fmd_bad, color: Colors.orange), + ); + } + + Widget _buildProblematicShotsIcon() { + return const Tooltip( + message: 'Contains shots with calculated lengths', + child: Icon(Icons.straighten, color: Colors.red), + ); + } + + Widget _buildSectionInfo() { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text(DateFormat('yyyy-MM-dd HH:mm').format(widget.section.dateSurvey)), + const SizedBox(height: 2), + StarRatingWidget( + quality: qualityService.scoreSurveySection(widget.section), + size: 14.0, + ), + ], + ); + } + + Widget _buildLeading() { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildSelectionCheckbox(), + _buildPreviewImage(), + ], + ); + } + + Widget _buildSelectionCheckbox() { + return Checkbox( + value: widget.section.isSelected, + onChanged: _handleSelectionChange, + activeColor: Colors.blue, + ); + } + + Widget _buildPreviewImage() { + return WidgetZoom( + heroAnimationTag: generateRandomString(10), + zoomWidget: Container(child: picture), + ); + } + + void _handleSelectionChange(bool? value) { + if (value != null && widget.onSelectionChanged != null) { + widget.onSelectionChanged!(widget.section, value); + } + } + double _calculateOptimalGridSpacing(double maxExtent) { // Target: no more than 10 grid divisions in the largest direction const maxGridDivisions = 10; diff --git a/test/selective_export_test.dart b/test/selective_export_test.dart index d0ca718..b494e1f 100644 --- a/test/selective_export_test.dart +++ b/test/selective_export_test.dart @@ -15,6 +15,12 @@ void main() { // Test setting selection section2.setIsSelected(true); expect(section2.isSelected, true); + + // Test toggle selection + section2.toggleSelection(); + expect(section2.isSelected, false); + section2.toggleSelection(); + expect(section2.isSelected, true); }); test('SectionList selection methods should work correctly', () { @@ -52,14 +58,30 @@ void main() { expect(sectionList.someSelected, false); expect(sectionList.selectedSections.length, 3); - // Test toggle - sectionList.toggleSelectAll(); // Should deselect all + // Test optimized toggle + sectionList.toggleSelectAll(); // Should deselect all (since all are currently selected) expect(sectionList.noneSelected, true); - sectionList.toggleSelectAll(); // Should select all + sectionList.toggleSelectAll(); // Should select all (since none are currently selected) expect(sectionList.allSelected, true); }); + test('SelectionStats should provide efficient selection information', () { + final section1 = Section(name: "Section 1", isSelected: true); + final section2 = Section(name: "Section 2", isSelected: false); + final section3 = Section(name: "Section 3", isSelected: true); + + final sectionList = SectionList(sections: [section1, section2, section3]); + final stats = sectionList.selectionStats; + + expect(stats.total, 3); + expect(stats.selected, 2); + expect(stats.unselected, 1); + expect(stats.someSelected, true); + expect(stats.allSelected, false); + expect(stats.noneSelected, false); + }); + test('selectedSections should return only selected sections', () { final section1 = Section(name: "Section 1", isSelected: true); final section2 = Section(name: "Section 2", isSelected: false); @@ -72,5 +94,21 @@ void main() { expect(selectedSections[0].name, "Section 1"); expect(selectedSections[1].name, "Section 3"); }); + + test('setSelectionForSections should efficiently update multiple sections', () { + final section1 = Section(name: "Section 1", isSelected: true); + final section2 = Section(name: "Section 2", isSelected: true); + final section3 = Section(name: "Section 3", isSelected: true); + + final sectionList = SectionList(sections: [section1, section2, section3]); + + // Deselect specific sections + sectionList.setSelectionForSections([section1, section3], false); + + expect(section1.isSelected, false); + expect(section2.isSelected, true); + expect(section3.isSelected, false); + expect(sectionList.selectedSections.length, 1); + }); }); } \ No newline at end of file