diff --git a/lib/features/buddies/presentation/widgets/buddy_list_content.dart b/lib/features/buddies/presentation/widgets/buddy_list_content.dart index 3dabcb1e0..9899bc89a 100644 --- a/lib/features/buddies/presentation/widgets/buddy_list_content.dart +++ b/lib/features/buddies/presentation/widgets/buddy_list_content.dart @@ -18,6 +18,7 @@ import 'package:submersion/features/buddies/domain/entities/buddy.dart'; import 'package:submersion/features/buddies/presentation/providers/buddy_providers.dart'; import 'package:submersion/features/buddies/presentation/widgets/dense_buddy_list_tile.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/shared/widgets/debounced_search_results.dart'; /// Content widget for the buddy list, used in master-detail layout. /// @@ -605,34 +606,10 @@ class BuddySearchDelegate extends SearchDelegate { } Widget _buildSearchResults(BuildContext context) { - final searchAsync = ref.watch(buddySearchProvider(query)); - - return searchAsync.when( - data: (buddies) { - if (buddies.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.search_off, - size: 64, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant.withValues(alpha: 0.5), - ), - const SizedBox(height: 16), - Text( - context.l10n.buddies_search_noResults(query), - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ); - } - + return DebouncedSearchResults( + query: query, + watchProvider: (ref, q) => ref.watch(buddySearchProvider(q)), + dataBuilder: (context, buddies) { return ListView.builder( itemCount: buddies.length, itemBuilder: (context, index) { @@ -647,8 +624,32 @@ class BuddySearchDelegate extends SearchDelegate { }, ); }, - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, _) => Center(child: Text('Error: $error')), + emptyBuilder: (context, query) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.search_off, + size: 64, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + ), + const SizedBox(height: 16), + Text( + context.l10n.buddies_search_noResults(query), + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + }, + errorBuilder: (context, error) { + return Center(child: Text('Error: $error')); + }, ); } } diff --git a/lib/features/buddies/presentation/widgets/buddy_picker.dart b/lib/features/buddies/presentation/widgets/buddy_picker.dart index 4eafdff7f..d592e0b6d 100644 --- a/lib/features/buddies/presentation/widgets/buddy_picker.dart +++ b/lib/features/buddies/presentation/widgets/buddy_picker.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:submersion/core/providers/provider.dart'; import 'package:go_router/go_router.dart'; @@ -225,6 +227,9 @@ class _BuddySelectionSheet extends ConsumerStatefulWidget { class _BuddySelectionSheetState extends ConsumerState<_BuddySelectionSheet> { final _searchController = TextEditingController(); String _searchQuery = ''; + String _debouncedQuery = ''; + Timer? _debounceTimer; + List? _lastSearchResults; late List _localSelectedBuddies; @override @@ -235,15 +240,16 @@ class _BuddySelectionSheetState extends ConsumerState<_BuddySelectionSheet> { @override void dispose() { + _debounceTimer?.cancel(); _searchController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - final buddiesAsync = _searchQuery.isEmpty + final buddiesAsync = _debouncedQuery.isEmpty ? ref.watch(allBuddiesProvider) - : ref.watch(buddySearchProvider(_searchQuery)); + : ref.watch(buddySearchProvider(_debouncedQuery)); return DraggableScrollableSheet( initialChildSize: 0.7, @@ -286,7 +292,12 @@ class _BuddySelectionSheetState extends ConsumerState<_BuddySelectionSheet> { tooltip: context.l10n.buddies_action_clearSearch, onPressed: () { _searchController.clear(); - setState(() => _searchQuery = ''); + _debounceTimer?.cancel(); + setState(() { + _searchQuery = ''; + _debouncedQuery = ''; + _lastSearchResults = null; + }); }, ) : null, @@ -296,6 +307,22 @@ class _BuddySelectionSheetState extends ConsumerState<_BuddySelectionSheet> { ), onChanged: (value) { setState(() => _searchQuery = value); + _debounceTimer?.cancel(); + if (value.isEmpty) { + setState(() { + _debouncedQuery = ''; + _lastSearchResults = null; + }); + } else { + _debounceTimer = Timer( + const Duration(milliseconds: 300), + () { + if (mounted) { + setState(() => _debouncedQuery = value); + } + }, + ); + } }, ), ), @@ -327,6 +354,9 @@ class _BuddySelectionSheetState extends ConsumerState<_BuddySelectionSheet> { Expanded( child: buddiesAsync.when( data: (buddies) { + if (_debouncedQuery.isNotEmpty) { + _lastSearchResults = buddies; + } if (buddies.isEmpty) { return Center( child: Column( @@ -353,67 +383,25 @@ class _BuddySelectionSheetState extends ConsumerState<_BuddySelectionSheet> { ); } - return ListView.builder( - controller: scrollController, - itemCount: buddies.length, - itemBuilder: (context, index) { - final buddy = buddies[index]; - final isSelected = _localSelectedBuddies.any( - (b) => b.buddy.id == buddy.id, - ); - final selectedRole = _localSelectedBuddies - .where((b) => b.buddy.id == buddy.id) - .map((b) => b.role) - .firstOrNull; - - return ListTile( - leading: CircleAvatar( - backgroundColor: isSelected - ? Theme.of(context).colorScheme.primaryContainer - : Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - child: isSelected - ? Icon( - Icons.check, - color: Theme.of( - context, - ).colorScheme.onPrimaryContainer, - ) - : Text( - buddy.initials, - style: TextStyle( - fontWeight: FontWeight.bold, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - ), - ), + return _buildBuddyListView(scrollController, buddies); + }, + loading: () { + if (_lastSearchResults != null && + _lastSearchResults!.isNotEmpty) { + return Column( + children: [ + const LinearProgressIndicator(), + Expanded( + child: _buildBuddyListView( + scrollController, + _lastSearchResults!, + ), ), - title: Text(buddy.name), - subtitle: buddy.certificationLevel != null - ? Text(buddy.certificationLevel!.displayName) - : null, - trailing: isSelected - ? Chip( - label: Text( - selectedRole?.displayName ?? 'Buddy', - ), - visualDensity: VisualDensity.compact, - ) - : null, - onTap: () { - if (isSelected) { - _removeBuddy(buddy.id); - } else { - _showRoleSelectorForBuddy(context, buddy); - } - }, - ); - }, - ); + ], + ); + } + return const Center(child: CircularProgressIndicator()); }, - loading: () => const Center(child: CircularProgressIndicator()), error: (error, _) => Center(child: Text('Error: $error')), ), ), @@ -423,6 +411,63 @@ class _BuddySelectionSheetState extends ConsumerState<_BuddySelectionSheet> { ); } + Widget _buildBuddyListView( + ScrollController scrollController, + List buddies, + ) { + return ListView.builder( + controller: scrollController, + itemCount: buddies.length, + itemBuilder: (context, index) { + final buddy = buddies[index]; + final isSelected = _localSelectedBuddies.any( + (b) => b.buddy.id == buddy.id, + ); + final selectedRole = _localSelectedBuddies + .where((b) => b.buddy.id == buddy.id) + .map((b) => b.role) + .firstOrNull; + + return ListTile( + leading: CircleAvatar( + backgroundColor: isSelected + ? Theme.of(context).colorScheme.primaryContainer + : Theme.of(context).colorScheme.surfaceContainerHighest, + child: isSelected + ? Icon( + Icons.check, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ) + : Text( + buddy.initials, + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + title: Text(buddy.name), + subtitle: buddy.certificationLevel != null + ? Text(buddy.certificationLevel!.displayName) + : null, + trailing: isSelected + ? Chip( + label: Text(selectedRole?.displayName ?? 'Buddy'), + visualDensity: VisualDensity.compact, + ) + : null, + onTap: () { + if (isSelected) { + _removeBuddy(buddy.id); + } else { + _showRoleSelectorForBuddy(context, buddy); + } + }, + ); + }, + ); + } + void _removeBuddy(String buddyId) { setState(() { _localSelectedBuddies = _localSelectedBuddies diff --git a/lib/features/dive_centers/presentation/widgets/dive_center_list_content.dart b/lib/features/dive_centers/presentation/widgets/dive_center_list_content.dart index 19d1a8c74..3ecb52a31 100644 --- a/lib/features/dive_centers/presentation/widgets/dive_center_list_content.dart +++ b/lib/features/dive_centers/presentation/widgets/dive_center_list_content.dart @@ -16,6 +16,7 @@ import 'package:submersion/features/dive_centers/domain/entities/dive_center.dar import 'package:submersion/features/dive_centers/presentation/providers/dive_center_providers.dart'; import 'package:submersion/features/dive_centers/presentation/widgets/compact_dive_center_list_tile.dart'; import 'package:submersion/features/dive_centers/presentation/widgets/dense_dive_center_list_tile.dart'; +import 'package:submersion/shared/widgets/debounced_search_results.dart'; /// Content widget for the dive center list, used in master-detail layout. class DiveCenterListContent extends ConsumerStatefulWidget { @@ -604,38 +605,10 @@ class DiveCenterSearchDelegate extends SearchDelegate { Widget buildSuggestions(BuildContext context) => _buildSearchResults(context); Widget _buildSearchResults(BuildContext context) { - final resultsAsync = ref.watch(diveCenterSearchProvider(query)); - - return resultsAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, _) => Center( - child: Text(context.l10n.diveCenters_error_generic(error.toString())), - ), - data: (centers) { - if (centers.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.search_off, - size: 48, - color: Theme.of(context).colorScheme.outline, - ), - const SizedBox(height: 16), - Text( - query.isEmpty - ? context.l10n.diveCenters_search_prompt - : context.l10n.diveCenters_search_noResults(query), - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Theme.of(context).colorScheme.outline, - ), - ), - ], - ), - ); - } - + return DebouncedSearchResults( + query: query, + watchProvider: (ref, q) => ref.watch(diveCenterSearchProvider(q)), + dataBuilder: (context, centers) { return ListView.builder( itemCount: centers.length, itemBuilder: (context, index) { @@ -679,6 +652,49 @@ class DiveCenterSearchDelegate extends SearchDelegate { }, ); }, + emptyQueryBuilder: (context) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.search, + size: 48, + color: Theme.of(context).colorScheme.outline, + ), + const SizedBox(height: 16), + Text( + context.l10n.diveCenters_search_prompt, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.outline, + ), + ), + ], + ), + ), + emptyBuilder: (context, query) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.search_off, + size: 48, + color: Theme.of(context).colorScheme.outline, + ), + const SizedBox(height: 16), + Text( + context.l10n.diveCenters_search_noResults(query), + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.outline, + ), + ), + ], + ), + ), + errorBuilder: (context, error) { + return Center( + child: Text(context.l10n.diveCenters_error_generic(error.toString())), + ); + }, ); } } diff --git a/lib/features/dive_log/data/repositories/dive_repository_impl.dart b/lib/features/dive_log/data/repositories/dive_repository_impl.dart index 09a013bdf..a8e146fb6 100644 --- a/lib/features/dive_log/data/repositories/dive_repository_impl.dart +++ b/lib/features/dive_log/data/repositories/dive_repository_impl.dart @@ -1485,61 +1485,75 @@ class DiveRepository { /// Search dives by notes or buddy name Future> searchDives(String query, {String? diverId}) async { try { - final searchQuery = _db.select(_db.dives) - ..where( - (t) => - t.notes.contains(query) | - t.buddy.contains(query) | - t.diveMaster.contains(query), - ) - ..orderBy([ - (t) => OrderingTerm.desc(coalesce([t.entryTime, t.diveDateTime])), - (t) => OrderingTerm.desc(t.diveNumber), - ]); - - if (diverId != null) { - searchQuery.where((t) => t.diverId.equals(diverId)); - } - - final rows = await searchQuery.get(); + // Search across dive fields and all related tables in a single query. + // Matches: notes, buddy (legacy field), dive master, site name/country/ + // region, dive center name, linked buddy names, tag names, custom fields. + final likeTerm = '%$query%'; + final diverClause = diverId != null ? 'AND d.diver_id = ?' : ''; + final diverArgs = diverId != null + ? [Variable(diverId)] + : >[]; - // Also search custom field keys and values - final customFieldMatches = await _db + final matchingIds = await _db .customSelect( - 'SELECT DISTINCT cf.dive_id FROM dive_custom_fields cf ' - 'INNER JOIN dives d ON cf.dive_id = d.id ' - 'WHERE (cf.field_key LIKE ? OR cf.field_value LIKE ?) ' - '${diverId != null ? 'AND d.diver_id = ?' : ''}', + ''' + SELECT DISTINCT d.id + FROM dives d + LEFT JOIN dive_sites ds ON d.site_id = ds.id + LEFT JOIN dive_centers dc ON d.dive_center_id = dc.id + LEFT JOIN dive_buddies db ON db.dive_id = d.id + LEFT JOIN buddies b ON db.buddy_id = b.id + LEFT JOIN dive_tags dt ON dt.dive_id = d.id + LEFT JOIN tags t ON dt.tag_id = t.id + LEFT JOIN dive_custom_fields cf ON cf.dive_id = d.id + WHERE ( + d.notes LIKE ? + OR d.buddy LIKE ? + OR d.dive_master LIKE ? + OR ds.name LIKE ? + OR ds.country LIKE ? + OR ds.region LIKE ? + OR dc.name LIKE ? + OR b.name LIKE ? + OR t.name LIKE ? + OR cf.field_key LIKE ? + OR cf.field_value LIKE ? + ) + $diverClause + ''', variables: [ - Variable('%$query%'), - Variable('%$query%'), - if (diverId != null) Variable(diverId), + Variable(likeTerm), + Variable(likeTerm), + Variable(likeTerm), + Variable(likeTerm), + Variable(likeTerm), + Variable(likeTerm), + Variable(likeTerm), + Variable(likeTerm), + Variable(likeTerm), + Variable(likeTerm), + Variable(likeTerm), + ...diverArgs, ], ) .get(); - final customFieldDiveIds = customFieldMatches - .map((r) => r.data['dive_id'] as String) - .toSet(); + if (matchingIds.isEmpty) return []; - // Remove IDs already found by the main query - final existingIds = rows.map((r) => r.id).toSet(); - final additionalIds = customFieldDiveIds.difference(existingIds); + final ids = matchingIds.map((r) => r.data['id'] as String).toList(); - // Load additional dives from custom field matches - final List allRows = rows.toList(); - if (additionalIds.isNotEmpty) { - final additionalQuery = _db.select(_db.dives) - ..where((t) => t.id.isIn(additionalIds.toList())) - ..orderBy([ - (t) => OrderingTerm.desc(coalesce([t.entryTime, t.diveDateTime])), - (t) => OrderingTerm.desc(t.diveNumber), - ]); - final additionalRows = await additionalQuery.get(); - allRows.addAll(additionalRows); - } + final rows = + await (_db.select(_db.dives) + ..where((t) => t.id.isIn(ids)) + ..orderBy([ + (t) => OrderingTerm.desc( + coalesce([t.entryTime, t.diveDateTime]), + ), + (t) => OrderingTerm.desc(t.diveNumber), + ])) + .get(); - return Future.wait(allRows.map(_mapRowToDive)); + return Future.wait(rows.map(_mapRowToDive)); } catch (e, stackTrace) { _log.error('Failed to search dives: $query', e, stackTrace); rethrow; diff --git a/lib/features/dive_log/presentation/pages/dive_list_page.dart b/lib/features/dive_log/presentation/pages/dive_list_page.dart index 9b06fdec8..fb96cbdba 100644 --- a/lib/features/dive_log/presentation/pages/dive_list_page.dart +++ b/lib/features/dive_log/presentation/pages/dive_list_page.dart @@ -18,6 +18,7 @@ import 'package:submersion/features/tags/presentation/widgets/tag_input_widget.d import 'package:submersion/features/dive_log/domain/entities/dive.dart'; import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; import 'package:submersion/features/dive_log/presentation/widgets/add_dive_bottom_sheet.dart'; +import 'package:submersion/shared/widgets/debounced_search_results.dart'; import 'package:submersion/features/dive_log/presentation/widgets/dive_list_content.dart'; import 'package:submersion/features/dive_log/presentation/widgets/dive_map_content.dart'; import 'package:submersion/features/dive_log/presentation/widgets/dive_profile_chart.dart'; @@ -266,37 +267,33 @@ class DiveSearchDelegate extends SearchDelegate { } Widget _buildSearchResults(BuildContext context) { - final searchAsync = ref.watch(diveSearchProvider(query)); - - return searchAsync.when( - data: (dives) { - if (dives.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ExcludeSemantics( - child: Icon( - Icons.search_off, - size: 64, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant.withValues(alpha: 0.5), - ), - ), - const SizedBox(height: 16), - Text( - context.l10n.diveLog_listPage_searchNoResults(query), - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], + return DebouncedSearchResults( + query: query, + watchProvider: (ref, q) => ref.watch(diveSearchProvider(q)), + emptyBuilder: (context, q) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ExcludeSemantics( + child: Icon( + Icons.search_off, + size: 64, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + ), ), - ); - } - - // Calculate value range for card coloring based on active attribute + const SizedBox(height: 16), + Text( + context.l10n.diveLog_listPage_searchNoResults(q), + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + dataBuilder: (context, dives) { final colorAttribute = ref.read(settingsProvider).cardColorAttribute; final colorValues = dives .map((d) => getCardColorValueFromDive(d, colorAttribute)) @@ -337,8 +334,7 @@ class DiveSearchDelegate extends SearchDelegate { }, ); }, - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, _) => Center( + errorBuilder: (context, error) => Center( child: Text( context.l10n.diveLog_listPage_errorLoading(error.toString()), ), diff --git a/lib/features/dive_sites/presentation/widgets/site_list_content.dart b/lib/features/dive_sites/presentation/widgets/site_list_content.dart index 8941375c9..e4d3aa0d4 100644 --- a/lib/features/dive_sites/presentation/widgets/site_list_content.dart +++ b/lib/features/dive_sites/presentation/widgets/site_list_content.dart @@ -20,6 +20,7 @@ import 'package:submersion/features/dive_sites/presentation/providers/site_provi import 'package:submersion/features/dive_sites/presentation/widgets/compact_site_list_tile.dart'; import 'package:submersion/features/dive_sites/presentation/widgets/dense_site_list_tile.dart'; import 'package:submersion/features/dive_sites/presentation/widgets/site_filter_sheet.dart'; +import 'package:submersion/shared/widgets/debounced_search_results.dart'; /// Content widget for the site list, used in master-detail layout. class SiteListContent extends ConsumerStatefulWidget { @@ -988,34 +989,10 @@ class SiteSearchDelegate extends SearchDelegate { } Widget _buildSearchResults(BuildContext context) { - final searchAsync = ref.watch(siteSearchProvider(query)); - - return searchAsync.when( - data: (sites) { - if (sites.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.search_off, - size: 64, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant.withValues(alpha: 0.5), - ), - const SizedBox(height: 16), - Text( - context.l10n.diveSites_list_search_noResults(query), - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ); - } - + return DebouncedSearchResults( + query: query, + watchProvider: (ref, q) => ref.watch(siteSearchProvider(q)), + dataBuilder: (context, sites) { return ListView.builder( itemCount: sites.length, itemBuilder: (context, index) { @@ -1039,10 +1016,36 @@ class SiteSearchDelegate extends SearchDelegate { }, ); }, - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, _) => Center( - child: Text(context.l10n.diveSites_list_search_error(error.toString())), - ), + emptyBuilder: (context, query) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.search_off, + size: 64, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + ), + const SizedBox(height: 16), + Text( + context.l10n.diveSites_list_search_noResults(query), + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + }, + errorBuilder: (context, error) { + return Center( + child: Text( + context.l10n.diveSites_list_search_error(error.toString()), + ), + ); + }, ); } } diff --git a/lib/features/equipment/presentation/widgets/equipment_list_content.dart b/lib/features/equipment/presentation/widgets/equipment_list_content.dart index 553c66363..b2ffe355d 100644 --- a/lib/features/equipment/presentation/widgets/equipment_list_content.dart +++ b/lib/features/equipment/presentation/widgets/equipment_list_content.dart @@ -13,6 +13,7 @@ import 'package:submersion/features/equipment/presentation/widgets/dense_equipme import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; import 'package:submersion/shared/widgets/list_view_mode_toggle.dart'; import 'package:submersion/shared/widgets/master_detail/responsive_breakpoints.dart'; +import 'package:submersion/shared/widgets/debounced_search_results.dart'; import 'package:submersion/shared/widgets/sort_bottom_sheet.dart'; /// Special filter value for computed "service due" items @@ -628,54 +629,50 @@ class EquipmentSearchDelegate extends SearchDelegate { } Widget _buildSearchResults(BuildContext context) { - return Consumer( - builder: (context, ref, child) { - final searchAsync = ref.watch(equipmentSearchProvider(query)); - - return searchAsync.when( - data: (equipment) { - if (equipment.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.search_off, - size: 64, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant.withValues(alpha: 0.5), - ), - const SizedBox(height: 16), - Text( - 'No equipment found for "$query"', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ); - } - - return ListView.builder( - itemCount: equipment.length, - itemBuilder: (context, index) { - final item = equipment[index]; - return EquipmentListTile( - item: item, - onTap: () { - close(context, item); - context.push('/equipment/${item.id}'); - }, - ); + return DebouncedSearchResults( + query: query, + watchProvider: (ref, q) => ref.watch(equipmentSearchProvider(q)), + dataBuilder: (context, equipment) { + return ListView.builder( + itemCount: equipment.length, + itemBuilder: (context, index) { + final item = equipment[index]; + return EquipmentListTile( + item: item, + onTap: () { + close(context, item); + context.push('/equipment/${item.id}'); }, ); }, - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, _) => Center(child: Text('Error: $error')), ); }, + emptyBuilder: (context, query) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.search_off, + size: 64, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + ), + const SizedBox(height: 16), + Text( + 'No equipment found for "$query"', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + }, + errorBuilder: (context, error) { + return Center(child: Text('Error: $error')); + }, ); } } diff --git a/lib/features/marine_life/presentation/widgets/species_picker_dialog.dart b/lib/features/marine_life/presentation/widgets/species_picker_dialog.dart index d6b0edb2f..492e8ad17 100644 --- a/lib/features/marine_life/presentation/widgets/species_picker_dialog.dart +++ b/lib/features/marine_life/presentation/widgets/species_picker_dialog.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:submersion/core/constants/enums.dart'; @@ -21,6 +23,9 @@ class _SpeciesPickerDialogState extends ConsumerState { late Set _selectedIds; final _searchController = TextEditingController(); String _searchQuery = ''; + String _debouncedQuery = ''; + Timer? _debounceTimer; + List? _lastSearchResults; SpeciesCategory? _selectedCategory; @override @@ -31,6 +36,7 @@ class _SpeciesPickerDialogState extends ConsumerState { @override void dispose() { + _debounceTimer?.cancel(); _searchController.dispose(); super.dispose(); } @@ -38,9 +44,9 @@ class _SpeciesPickerDialogState extends ConsumerState { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final speciesAsync = _searchQuery.isEmpty + final speciesAsync = _debouncedQuery.isEmpty ? ref.watch(allSpeciesProvider) - : ref.watch(speciesSearchProvider(_searchQuery)); + : ref.watch(speciesSearchProvider(_debouncedQuery)); return Dialog( child: ConstrainedBox( @@ -93,7 +99,12 @@ class _SpeciesPickerDialogState extends ConsumerState { .marineLife_speciesPicker_clearSearchTooltip, onPressed: () { _searchController.clear(); - setState(() => _searchQuery = ''); + _debounceTimer?.cancel(); + setState(() { + _searchQuery = ''; + _debouncedQuery = ''; + _lastSearchResults = null; + }); }, ) : null, @@ -109,6 +120,22 @@ class _SpeciesPickerDialogState extends ConsumerState { ), onChanged: (value) { setState(() => _searchQuery = value); + _debounceTimer?.cancel(); + if (value.isEmpty) { + setState(() { + _debouncedQuery = ''; + _lastSearchResults = null; + }); + } else { + _debounceTimer = Timer( + const Duration(milliseconds: 300), + () { + if (mounted) { + setState(() => _debouncedQuery = value); + } + }, + ); + } }, ), const SizedBox(height: 12), @@ -156,57 +183,28 @@ class _SpeciesPickerDialogState extends ConsumerState { Expanded( child: speciesAsync.when( data: (allSpecies) { - final filtered = _selectedCategory == null - ? allSpecies - : allSpecies - .where((s) => s.category == _selectedCategory) - .toList(); - - if (filtered.isEmpty) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.search_off, - size: 48, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(height: 16), - Text( - context - .l10n - .marineLife_speciesPicker_noSpeciesFound, - style: Theme.of(context).textTheme.bodyLarge - ?.copyWith(color: colorScheme.onSurfaceVariant), + if (_debouncedQuery.isNotEmpty) { + _lastSearchResults = allSpecies; + } + return _buildSpeciesList(context, allSpecies); + }, + loading: () { + if (_lastSearchResults != null && + _lastSearchResults!.isNotEmpty) { + return Column( + children: [ + const LinearProgressIndicator(), + Expanded( + child: _buildSpeciesList( + context, + _lastSearchResults!, ), - ], - ), + ), + ], ); } - - // Group by category - final grouped = >{}; - for (final species in filtered) { - grouped - .putIfAbsent(species.category, () => []) - .add(species); - } - - return ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: grouped.length, - itemBuilder: (context, index) { - final entry = grouped.entries.elementAt(index); - return _buildCategorySection( - context, - entry.key, - entry.value, - ); - }, - ); + return const Center(child: CircularProgressIndicator()); }, - loading: () => const Center(child: CircularProgressIndicator()), error: (error, _) => Center( child: Text( context.l10n.marineLife_speciesPicker_error( @@ -260,6 +258,50 @@ class _SpeciesPickerDialogState extends ConsumerState { ); } + Widget _buildSpeciesList(BuildContext context, List allSpecies) { + final colorScheme = Theme.of(context).colorScheme; + final filtered = _selectedCategory == null + ? allSpecies + : allSpecies.where((s) => s.category == _selectedCategory).toList(); + + if (filtered.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.search_off, + size: 48, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + context.l10n.marineLife_speciesPicker_noSpeciesFound, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } + + // Group by category + final grouped = >{}; + for (final species in filtered) { + grouped.putIfAbsent(species.category, () => []).add(species); + } + + return ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: grouped.length, + itemBuilder: (context, index) { + final entry = grouped.entries.elementAt(index); + return _buildCategorySection(context, entry.key, entry.value); + }, + ); + } + Widget _buildCategorySection( BuildContext context, SpeciesCategory category, diff --git a/lib/shared/widgets/debounced_search_results.dart b/lib/shared/widgets/debounced_search_results.dart new file mode 100644 index 000000000..7d2b81976 --- /dev/null +++ b/lib/shared/widgets/debounced_search_results.dart @@ -0,0 +1,130 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// A widget that debounces search queries and caches the last successful +/// results to avoid spinner churn while typing. +/// +/// Wraps a [FutureProvider.family]-backed search with two mitigations: +/// 1. The raw [query] is debounced before the provider is watched, reducing +/// the number of async operations triggered during fast typing. +/// 2. While a new query is loading, the last successful results remain +/// visible with a subtle linear progress indicator, instead of replacing +/// the entire results list with a spinner. +class DebouncedSearchResults extends ConsumerStatefulWidget { + /// The raw, un-debounced search query (e.g. from a [SearchDelegate] or + /// [TextField.onChanged]). + final String query; + + /// How long to wait after the last keystroke before triggering a search. + final Duration debounceDuration; + + /// Watches the appropriate search provider for [query] and returns the + /// current [AsyncValue]. Called with the **debounced** query. + final AsyncValue> Function(WidgetRef ref, String query) watchProvider; + + /// Builds the result list from successfully loaded items. + final Widget Function(BuildContext context, List items) dataBuilder; + + /// Builds the empty-results view (e.g. "No results for X"). + final Widget Function(BuildContext context, String query) emptyBuilder; + + /// Builds the error view. + final Widget Function(BuildContext context, Object error) errorBuilder; + + /// Optional widget shown when the query is empty. If null, a [SizedBox.shrink] + /// is returned for empty queries. + final Widget Function(BuildContext context)? emptyQueryBuilder; + + const DebouncedSearchResults({ + super.key, + required this.query, + this.debounceDuration = const Duration(milliseconds: 300), + required this.watchProvider, + required this.dataBuilder, + required this.emptyBuilder, + required this.errorBuilder, + this.emptyQueryBuilder, + }); + + @override + ConsumerState> createState() => + _DebouncedSearchResultsState(); +} + +class _DebouncedSearchResultsState + extends ConsumerState> { + Timer? _debounceTimer; + String _debouncedQuery = ''; + List? _lastResults; + + @override + void initState() { + super.initState(); + _debouncedQuery = widget.query; + } + + @override + void didUpdateWidget(DebouncedSearchResults oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.query != oldWidget.query) { + _debounceTimer?.cancel(); + if (widget.query.isEmpty) { + // Clear immediately — no debounce needed for empty queries + setState(() { + _debouncedQuery = ''; + _lastResults = null; + }); + } else { + _debounceTimer = Timer(widget.debounceDuration, () { + if (mounted) { + setState(() => _debouncedQuery = widget.query); + } + }); + } + } + } + + @override + void dispose() { + _debounceTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_debouncedQuery.isEmpty) { + _lastResults = null; + if (widget.emptyQueryBuilder != null) { + return widget.emptyQueryBuilder!(context); + } + return const SizedBox.shrink(); + } + + final searchAsync = widget.watchProvider(ref, _debouncedQuery); + + return searchAsync.when( + data: (items) { + _lastResults = items; + if (items.isEmpty) { + return widget.emptyBuilder(context, _debouncedQuery); + } + return widget.dataBuilder(context, items); + }, + loading: () { + // Show cached results with a progress indicator instead of a spinner + if (_lastResults != null && _lastResults!.isNotEmpty) { + return Column( + children: [ + const LinearProgressIndicator(), + Expanded(child: widget.dataBuilder(context, _lastResults!)), + ], + ); + } + return const Center(child: CircularProgressIndicator()); + }, + error: (error, _) => widget.errorBuilder(context, error), + ); + } +} diff --git a/test/features/buddies/presentation/widgets/buddy_picker_test.dart b/test/features/buddies/presentation/widgets/buddy_picker_test.dart new file mode 100644 index 000000000..408acf4fd --- /dev/null +++ b/test/features/buddies/presentation/widgets/buddy_picker_test.dart @@ -0,0 +1,459 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/features/buddies/domain/entities/buddy.dart'; +import 'package:submersion/features/buddies/presentation/providers/buddy_providers.dart'; +import 'package:submersion/features/buddies/presentation/widgets/buddy_picker.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +final _now = DateTime(2024, 1, 1); + +final _testBuddies = [ + Buddy(id: '1', name: 'Alice Smith', createdAt: _now, updatedAt: _now), + Buddy( + id: '2', + name: 'Bob Jones', + certificationLevel: CertificationLevel.advancedOpenWater, + createdAt: _now, + updatedAt: _now, + ), + Buddy(id: '3', name: 'Charlie Brown', createdAt: _now, updatedAt: _now), +]; + +Widget _buildPicker({ + List selectedBuddies = const [], + ValueChanged>? onChanged, + List? overrides, +}) { + return ProviderScope( + overrides: overrides?.cast() ?? [], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: BuddyPicker( + selectedBuddies: selectedBuddies, + onChanged: onChanged ?? (_) {}, + ), + ), + ), + ); +} + +/// Opens the buddy selection bottom sheet via the add button. +Future _openSheet(WidgetTester tester) async { + await tester.tap(find.byIcon(Icons.add)); + await tester.pumpAndSettle(); +} + +/// Sets a tall screen so that bottom sheets and role selectors fit without +/// overflow. +void _useTallScreen(WidgetTester tester) { + tester.view.physicalSize = const Size(640, 1400); + tester.view.devicePixelRatio = 1.0; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); +} + +void main() { + group('BuddyPicker - BuddySelectionSheet', () { + testWidgets('opens sheet and shows all buddies', (tester) async { + await tester.pumpWidget( + _buildPicker( + overrides: [ + allBuddiesProvider.overrideWith((ref) async => _testBuddies), + buddySearchProvider.overrideWith((ref, q) async => []), + ], + ), + ); + await tester.pumpAndSettle(); + await _openSheet(tester); + + expect(find.text('Alice Smith'), findsOneWidget); + expect(find.text('Bob Jones'), findsOneWidget); + expect(find.text('Charlie Brown'), findsOneWidget); + }); + + testWidgets('shows certification level as subtitle', (tester) async { + await tester.pumpWidget( + _buildPicker( + overrides: [ + allBuddiesProvider.overrideWith((ref) async => _testBuddies), + ], + ), + ); + await tester.pumpAndSettle(); + await _openSheet(tester); + + // Bob has Advanced Open Water certification + expect(find.text('Advanced Open Water'), findsOneWidget); + }); + + testWidgets('debounces search input by 300ms', (tester) async { + await tester.pumpWidget( + _buildPicker( + overrides: [ + allBuddiesProvider.overrideWith((ref) async => _testBuddies), + buddySearchProvider.overrideWith((ref, query) async { + return _testBuddies + .where( + (b) => b.name.toLowerCase().contains(query.toLowerCase()), + ) + .toList(); + }), + ], + ), + ); + await tester.pumpAndSettle(); + await _openSheet(tester); + + // Type in search field + await tester.enterText(find.byType(TextField), 'Alice'); + await tester.pump(); + + // Before debounce fires -- still showing all buddies + expect(find.text('Bob Jones'), findsOneWidget); + expect(find.text('Charlie Brown'), findsOneWidget); + + // Advance past the 300ms debounce + await tester.pump(const Duration(milliseconds: 300)); + await tester.pumpAndSettle(); + + // Now filtered to Alice only + expect(find.text('Alice Smith'), findsOneWidget); + expect(find.text('Bob Jones'), findsNothing); + expect(find.text('Charlie Brown'), findsNothing); + }); + + testWidgets('clear button resets search immediately', (tester) async { + await tester.pumpWidget( + _buildPicker( + overrides: [ + allBuddiesProvider.overrideWith((ref) async => _testBuddies), + buddySearchProvider.overrideWith((ref, query) async { + return _testBuddies + .where( + (b) => b.name.toLowerCase().contains(query.toLowerCase()), + ) + .toList(); + }), + ], + ), + ); + await tester.pumpAndSettle(); + await _openSheet(tester); + + // Search and wait for debounce + await tester.enterText(find.byType(TextField), 'Alice'); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pumpAndSettle(); + expect(find.text('Bob Jones'), findsNothing); + + // Tap clear button -- should reset instantly (no debounce) + await tester.tap(find.byIcon(Icons.clear)); + await tester.pumpAndSettle(); + + // All buddies visible again + expect(find.text('Alice Smith'), findsOneWidget); + expect(find.text('Bob Jones'), findsOneWidget); + expect(find.text('Charlie Brown'), findsOneWidget); + }); + + testWidgets('empty query immediately clears debounced state', ( + tester, + ) async { + await tester.pumpWidget( + _buildPicker( + overrides: [ + allBuddiesProvider.overrideWith((ref) async => _testBuddies), + buddySearchProvider.overrideWith((ref, query) async { + return _testBuddies + .where( + (b) => b.name.toLowerCase().contains(query.toLowerCase()), + ) + .toList(); + }), + ], + ), + ); + await tester.pumpAndSettle(); + await _openSheet(tester); + + // Type something, wait for debounce, then clear via text + await tester.enterText(find.byType(TextField), 'Al'); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pumpAndSettle(); + + // Now enter empty text (simulates user deleting all text) + await tester.enterText(find.byType(TextField), ''); + await tester.pump(); // Single frame -- no debounce for empty + + // Should switch back to all buddies immediately + await tester.pumpAndSettle(); + expect(find.text('Alice Smith'), findsOneWidget); + expect(find.text('Bob Jones'), findsOneWidget); + }); + + testWidgets('shows selected state for pre-selected buddies', ( + tester, + ) async { + final selectedBuddy = BuddyWithRole( + buddy: _testBuddies[0], + role: BuddyRole.buddy, + ); + + await tester.pumpWidget( + _buildPicker( + selectedBuddies: [selectedBuddy], + overrides: [ + allBuddiesProvider.overrideWith((ref) async => _testBuddies), + ], + ), + ); + await tester.pumpAndSettle(); + await _openSheet(tester); + + // Alice should be selected (check icon + role chip) + expect(find.byIcon(Icons.check), findsOneWidget); + }); + + testWidgets('tapping unselected buddy opens role selector', (tester) async { + _useTallScreen(tester); + await tester.pumpWidget( + _buildPicker( + overrides: [ + allBuddiesProvider.overrideWith((ref) async => _testBuddies), + ], + ), + ); + await tester.pumpAndSettle(); + await _openSheet(tester); + + // Tap Alice to open role selector + await tester.tap(find.text('Alice Smith')); + await tester.pumpAndSettle(); + + // Role selector should show all roles + expect(find.text('Dive Guide'), findsOneWidget); + expect(find.text('Instructor'), findsOneWidget); + expect(find.text('Divemaster'), findsOneWidget); + }); + + testWidgets('selecting a role adds buddy to selection', (tester) async { + _useTallScreen(tester); + await tester.pumpWidget( + _buildPicker( + overrides: [ + allBuddiesProvider.overrideWith((ref) async => _testBuddies), + ], + ), + ); + await tester.pumpAndSettle(); + await _openSheet(tester); + + // Tap Alice -> role selector + await tester.tap(find.text('Alice Smith')); + await tester.pumpAndSettle(); + + // Select "Instructor" role + await tester.tap(find.text('Instructor')); + await tester.pumpAndSettle(); + + // Alice should now be selected (check icon visible) + expect(find.byIcon(Icons.check), findsOneWidget); + }); + + testWidgets('tapping selected buddy deselects them', (tester) async { + _useTallScreen(tester); + final selectedBuddy = BuddyWithRole( + buddy: _testBuddies[0], + role: BuddyRole.buddy, + ); + + await tester.pumpWidget( + _buildPicker( + selectedBuddies: [selectedBuddy], + overrides: [ + allBuddiesProvider.overrideWith((ref) async => _testBuddies), + ], + ), + ); + await tester.pumpAndSettle(); + await _openSheet(tester); + + // Alice is selected + expect(find.byIcon(Icons.check), findsOneWidget); + + // Tap Alice in the sheet list (not the chip) to deselect + final aliceInSheet = find.descendant( + of: find.byType(ListTile), + matching: find.text('Alice Smith'), + ); + await tester.tap(aliceInSheet.first); + await tester.pumpAndSettle(); + + // Check icon should be gone + expect(find.byIcon(Icons.check), findsNothing); + }); + + testWidgets('shows empty state when no buddies exist', (tester) async { + await tester.pumpWidget( + _buildPicker( + overrides: [ + allBuddiesProvider.overrideWith((ref) async => []), + ], + ), + ); + await tester.pumpAndSettle(); + await _openSheet(tester); + + // Should show empty state with people icon + expect(find.byIcon(Icons.people_outline), findsWidgets); + }); + + testWidgets('shows search empty state when no results', (tester) async { + await tester.pumpWidget( + _buildPicker( + overrides: [ + allBuddiesProvider.overrideWith((ref) async => _testBuddies), + buddySearchProvider.overrideWith((ref, query) async => []), + ], + ), + ); + await tester.pumpAndSettle(); + await _openSheet(tester); + + // Search for something with no results + await tester.enterText(find.byType(TextField), 'zzz'); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pumpAndSettle(); + + // Should show search_off icon (empty search state) + expect(find.byIcon(Icons.search_off), findsOneWidget); + }); + + testWidgets('shows loading spinner when provider is loading', ( + tester, + ) async { + final completer = Completer>(); + addTearDown(() { + if (!completer.isCompleted) completer.complete([]); + }); + + await tester.pumpWidget( + _buildPicker( + overrides: [ + allBuddiesProvider.overrideWith((ref) => completer.future), + ], + ), + ); + await tester.pump(); + await tester.pump(); + + // Open sheet -- use pump() not pumpAndSettle() since provider never + // completes + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('caches search results and shows LinearProgressIndicator ' + 'during subsequent loading', (tester) async { + var callCount = 0; + final secondSearchCompleter = Completer>(); + addTearDown(() { + if (!secondSearchCompleter.isCompleted) { + secondSearchCompleter.complete([]); + } + }); + + await tester.pumpWidget( + _buildPicker( + overrides: [ + allBuddiesProvider.overrideWith((ref) async => _testBuddies), + buddySearchProvider.overrideWith((ref, query) { + callCount++; + if (callCount <= 1) { + // First search completes immediately + return Future.value( + _testBuddies + .where( + (b) => + b.name.toLowerCase().contains(query.toLowerCase()), + ) + .toList(), + ); + } + // Second search hangs in loading + return secondSearchCompleter.future; + }), + ], + ), + ); + await tester.pumpAndSettle(); + await _openSheet(tester); + + // First search -- completes immediately, caches results + await tester.enterText(find.byType(TextField), 'Al'); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pumpAndSettle(); + expect(find.text('Alice Smith'), findsOneWidget); + + // Second search -- provider returns loading + await tester.enterText(find.byType(TextField), 'Alice'); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(); // Rebuild with loading state + + // Cached results should still be visible + expect(find.text('Alice Smith'), findsOneWidget); + // LinearProgressIndicator should appear + expect(find.byType(LinearProgressIndicator), findsOneWidget); + }); + + testWidgets('done button returns selected buddies via onChanged', ( + tester, + ) async { + _useTallScreen(tester); + List? result; + + await tester.pumpWidget( + _buildPicker( + onChanged: (buddies) => result = buddies, + overrides: [ + allBuddiesProvider.overrideWith((ref) async => _testBuddies), + ], + ), + ); + await tester.pumpAndSettle(); + await _openSheet(tester); + + // Select Alice with "Instructor" role + await tester.tap(find.text('Alice Smith')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Instructor')); + await tester.pumpAndSettle(); + + // Tap "Done" -- it's a TextButton in the sheet header + // Find all TextButtons and tap the one inside the bottom sheet + // The "Done" button is rendered by the _BuddySelectionSheet header + final doneButton = find.descendant( + of: find.byType(DraggableScrollableSheet), + matching: find.byType(TextButton), + ); + await tester.tap(doneButton); + await tester.pumpAndSettle(); + + expect(result, isNotNull); + expect(result!.length, equals(1)); + expect(result![0].buddy.name, equals('Alice Smith')); + expect(result![0].role, equals(BuddyRole.instructor)); + }); + }); +} diff --git a/test/features/dive_log/data/repositories/dive_repository_test.dart b/test/features/dive_log/data/repositories/dive_repository_test.dart index 78c7ef0a3..f85c2d4ed 100644 --- a/test/features/dive_log/data/repositories/dive_repository_test.dart +++ b/test/features/dive_log/data/repositories/dive_repository_test.dart @@ -1,9 +1,14 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; import 'package:submersion/core/performance/perf_timer.dart'; +import 'package:submersion/features/buddies/data/repositories/buddy_repository.dart'; +import 'package:submersion/features/buddies/domain/entities/buddy.dart'; import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; import 'package:submersion/features/dive_log/domain/entities/dive.dart'; import 'package:submersion/features/dive_sites/data/repositories/site_repository_impl.dart'; import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; +import 'package:submersion/features/tags/data/repositories/tag_repository.dart'; +import 'package:submersion/features/tags/domain/entities/tag.dart'; import '../../../../helpers/performance_data_generator.dart'; import '../../../../helpers/test_database.dart'; @@ -418,6 +423,84 @@ void main() { expect(results, isEmpty); }); + + test('should find dives by linked site name', () async { + final site = await siteRepository.createSite( + const DiveSite(id: '', name: 'Blue Hole Belize'), + ); + await repository.createDive(createTestDive(diveNumber: 10, site: site)); + + final results = await repository.searchDives('Blue Hole'); + + expect(results.length, equals(1)); + expect(results[0].diveNumber, equals(10)); + }); + + test('should find dives by site country', () async { + final site = await siteRepository.createSite( + const DiveSite(id: '', name: 'Some Reef', country: 'Thailand'), + ); + await repository.createDive(createTestDive(diveNumber: 11, site: site)); + + final results = await repository.searchDives('Thailand'); + + expect(results.length, equals(1)); + expect(results[0].diveNumber, equals(11)); + }); + + test('should find dives by site region', () async { + final site = await siteRepository.createSite( + const DiveSite(id: '', name: 'Wall Dive', region: 'Cozumel'), + ); + await repository.createDive(createTestDive(diveNumber: 12, site: site)); + + final results = await repository.searchDives('Cozumel'); + + expect(results.length, equals(1)); + expect(results[0].diveNumber, equals(12)); + }); + + test('should find dives by linked buddy name', () async { + final buddyRepo = BuddyRepository(); + final buddy = await buddyRepo.createBuddy( + Buddy( + id: '', + name: 'Jacques Cousteau', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ), + ); + final dive = await repository.createDive( + createTestDive(diveNumber: 13), + ); + await buddyRepo.addBuddyToDive(dive.id, buddy.id, BuddyRole.buddy); + + final results = await repository.searchDives('Cousteau'); + + expect(results.length, equals(1)); + expect(results[0].diveNumber, equals(13)); + }); + + test('should find dives by tag name', () async { + final tagRepo = TagRepository(); + final tag = await tagRepo.createTag( + Tag( + id: '', + name: 'wreck-exploration', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ), + ); + final dive = await repository.createDive( + createTestDive(diveNumber: 14), + ); + await tagRepo.addTagToDive(dive.id, tag.id); + + final results = await repository.searchDives('wreck-exploration'); + + expect(results.length, equals(1)); + expect(results[0].diveNumber, equals(14)); + }); }); group('getStatistics', () { diff --git a/test/features/marine_life/presentation/widgets/species_picker_dialog_test.dart b/test/features/marine_life/presentation/widgets/species_picker_dialog_test.dart new file mode 100644 index 000000000..5474dc561 --- /dev/null +++ b/test/features/marine_life/presentation/widgets/species_picker_dialog_test.dart @@ -0,0 +1,470 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/features/marine_life/domain/entities/species.dart'; +import 'package:submersion/features/marine_life/presentation/providers/species_providers.dart'; +import 'package:submersion/features/marine_life/presentation/widgets/species_picker_dialog.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +final _testSpecies = [ + const Species( + id: '1', + commonName: 'Clownfish', + category: SpeciesCategory.fish, + scientificName: 'Amphiprioninae', + ), + const Species( + id: '2', + commonName: 'Manta Ray', + category: SpeciesCategory.ray, + scientificName: 'Mobula birostris', + ), + const Species( + id: '3', + commonName: 'Green Turtle', + category: SpeciesCategory.turtle, + scientificName: 'Chelonia mydas', + ), + const Species( + id: '4', + commonName: 'Blue Shark', + category: SpeciesCategory.shark, + scientificName: 'Prionace glauca', + ), +]; + +/// Sets a tall, wide screen so the dialog and filter chips fit. +void _useLargeScreen(WidgetTester tester) { + tester.view.physicalSize = const Size(1200, 1400); + tester.view.devicePixelRatio = 1.0; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); +} + +Widget _buildDialog({ + Set initialSelection = const {}, + List? overrides, +}) { + return ProviderScope( + overrides: overrides?.cast() ?? [], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Center( + child: SpeciesPickerDialog(initialSelection: initialSelection), + ), + ), + ), + ); +} + +void main() { + group('SpeciesPickerDialog', () { + testWidgets('shows species list grouped by category', (tester) async { + _useLargeScreen(tester); + await tester.pumpWidget( + _buildDialog( + overrides: [ + allSpeciesProvider.overrideWith((ref) async => _testSpecies), + speciesSearchProvider.overrideWith((ref, q) async => []), + ], + ), + ); + await tester.pumpAndSettle(); + + // Species names visible + expect(find.text('Clownfish'), findsOneWidget); + expect(find.text('Manta Ray'), findsOneWidget); + expect(find.text('Green Turtle'), findsOneWidget); + expect(find.text('Blue Shark'), findsOneWidget); + + // Category headers visible + expect(find.text('Fish'), findsWidgets); // header + filter chip + expect(find.text('Ray'), findsWidgets); + expect(find.text('Turtle'), findsWidgets); + expect(find.text('Shark'), findsWidgets); + }); + + testWidgets('shows scientific names as subtitles', (tester) async { + _useLargeScreen(tester); + await tester.pumpWidget( + _buildDialog( + overrides: [ + allSpeciesProvider.overrideWith((ref) async => _testSpecies), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Amphiprioninae'), findsOneWidget); + expect(find.text('Mobula birostris'), findsOneWidget); + expect(find.text('Chelonia mydas'), findsOneWidget); + expect(find.text('Prionace glauca'), findsOneWidget); + }); + + testWidgets('debounces search input by 300ms', (tester) async { + await tester.pumpWidget( + _buildDialog( + overrides: [ + allSpeciesProvider.overrideWith((ref) async => _testSpecies), + speciesSearchProvider.overrideWith((ref, query) async { + return _testSpecies + .where( + (s) => s.commonName.toLowerCase().contains( + query.toLowerCase(), + ), + ) + .toList(); + }), + ], + ), + ); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'Clown'); + await tester.pump(); + + // Before debounce -- all species still visible + expect(find.text('Manta Ray'), findsOneWidget); + expect(find.text('Green Turtle'), findsOneWidget); + + // Advance past 300ms debounce + await tester.pump(const Duration(milliseconds: 300)); + await tester.pumpAndSettle(); + + // Now filtered + expect(find.text('Clownfish'), findsOneWidget); + expect(find.text('Manta Ray'), findsNothing); + expect(find.text('Green Turtle'), findsNothing); + }); + + testWidgets('clear button resets search immediately', (tester) async { + await tester.pumpWidget( + _buildDialog( + overrides: [ + allSpeciesProvider.overrideWith((ref) async => _testSpecies), + speciesSearchProvider.overrideWith((ref, query) async { + return _testSpecies + .where( + (s) => s.commonName.toLowerCase().contains( + query.toLowerCase(), + ), + ) + .toList(); + }), + ], + ), + ); + await tester.pumpAndSettle(); + + // Search and wait for debounce + await tester.enterText(find.byType(TextField), 'Clown'); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pumpAndSettle(); + expect(find.text('Manta Ray'), findsNothing); + + // Tap clear + await tester.tap(find.byIcon(Icons.clear)); + await tester.pumpAndSettle(); + + // All species visible again + expect(find.text('Clownfish'), findsOneWidget); + expect(find.text('Manta Ray'), findsOneWidget); + expect(find.text('Green Turtle'), findsOneWidget); + }); + + testWidgets('empty text input clears debounced state immediately', ( + tester, + ) async { + _useLargeScreen(tester); + await tester.pumpWidget( + _buildDialog( + overrides: [ + allSpeciesProvider.overrideWith((ref) async => _testSpecies), + speciesSearchProvider.overrideWith((ref, query) async { + return _testSpecies + .where( + (s) => s.commonName.toLowerCase().contains( + query.toLowerCase(), + ), + ) + .toList(); + }), + ], + ), + ); + await tester.pumpAndSettle(); + + // Search + await tester.enterText(find.byType(TextField), 'Manta'); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pumpAndSettle(); + + // Clear by entering empty text + await tester.enterText(find.byType(TextField), ''); + await tester.pump(); // Single frame -- no debounce for empty + await tester.pumpAndSettle(); + + // All species visible + expect(find.text('Clownfish'), findsOneWidget); + expect(find.text('Blue Shark'), findsOneWidget); + }); + + testWidgets('category filter chips filter the list', (tester) async { + _useLargeScreen(tester); + await tester.pumpWidget( + _buildDialog( + overrides: [ + allSpeciesProvider.overrideWith((ref) async => _testSpecies), + ], + ), + ); + await tester.pumpAndSettle(); + + // All species visible initially + expect(find.text('Clownfish'), findsOneWidget); + expect(find.text('Manta Ray'), findsOneWidget); + expect(find.text('Green Turtle'), findsOneWidget); + expect(find.text('Blue Shark'), findsOneWidget); + + // Tap the "Fish" filter chip (last instance since it also appears as + // a category header in the list) + final fishChips = find.ancestor( + of: find.text('Fish'), + matching: find.byType(FilterChip), + ); + await tester.tap(fishChips.first); + await tester.pumpAndSettle(); + + // Only fish species visible + expect(find.text('Clownfish'), findsOneWidget); + expect(find.text('Manta Ray'), findsNothing); + expect(find.text('Green Turtle'), findsNothing); + expect(find.text('Blue Shark'), findsNothing); + }); + + testWidgets('selecting "All" filter shows all species', (tester) async { + _useLargeScreen(tester); + await tester.pumpWidget( + _buildDialog( + overrides: [ + allSpeciesProvider.overrideWith((ref) async => _testSpecies), + ], + ), + ); + await tester.pumpAndSettle(); + + // Filter to fish first + final fishChip = find.ancestor( + of: find.text('Fish'), + matching: find.byType(FilterChip), + ); + await tester.tap(fishChip.first); + await tester.pumpAndSettle(); + expect(find.text('Manta Ray'), findsNothing); + + // Tap "All" chip to reset + final allChip = find.ancestor( + of: find.text('All'), + matching: find.byType(FilterChip), + ); + await tester.tap(allChip.first); + await tester.pumpAndSettle(); + + // All species visible again + expect(find.text('Clownfish'), findsOneWidget); + expect(find.text('Manta Ray'), findsOneWidget); + }); + + testWidgets('empty category filter shows empty state', (tester) async { + _useLargeScreen(tester); + await tester.pumpWidget( + _buildDialog( + overrides: [ + allSpeciesProvider.overrideWith((ref) async => _testSpecies), + ], + ), + ); + await tester.pumpAndSettle(); + + // Tap "Mammal" filter -- no test species in this category + final mammalChip = find.ancestor( + of: find.text('Mammal'), + matching: find.byType(FilterChip), + ); + await tester.tap(mammalChip.first); + await tester.pumpAndSettle(); + + // Should show search_off icon (empty state) + expect(find.byIcon(Icons.search_off), findsOneWidget); + }); + + testWidgets('multi-select checkboxes toggle selection', (tester) async { + await tester.pumpWidget( + _buildDialog( + overrides: [ + allSpeciesProvider.overrideWith((ref) async => _testSpecies), + ], + ), + ); + await tester.pumpAndSettle(); + + // Initially nothing selected -- find checkboxes + var checkboxTiles = tester.widgetList( + find.byType(CheckboxListTile), + ); + expect(checkboxTiles.every((cb) => cb.value == false), isTrue); + + // Tap Clownfish to select + await tester.tap(find.text('Clownfish')); + await tester.pumpAndSettle(); + + // Clownfish should now be checked + checkboxTiles = tester.widgetList( + find.byType(CheckboxListTile), + ); + final clownfishTile = checkboxTiles.firstWhere( + (cb) => (cb.title as Text?)?.data == 'Clownfish', + ); + expect(clownfishTile.value, isTrue); + + // Tap again to deselect + await tester.tap(find.text('Clownfish')); + await tester.pumpAndSettle(); + + checkboxTiles = tester.widgetList( + find.byType(CheckboxListTile), + ); + final deselectedTile = checkboxTiles.firstWhere( + (cb) => (cb.title as Text?)?.data == 'Clownfish', + ); + expect(deselectedTile.value, isFalse); + }); + + testWidgets('initial selection is reflected in checkboxes', (tester) async { + _useLargeScreen(tester); + await tester.pumpWidget( + _buildDialog( + initialSelection: {'1', '3'}, + overrides: [ + allSpeciesProvider.overrideWith((ref) async => _testSpecies), + ], + ), + ); + await tester.pumpAndSettle(); + + final checkboxTiles = tester + .widgetList(find.byType(CheckboxListTile)) + .toList(); + final clownfish = checkboxTiles.firstWhere( + (cb) => (cb.title as Text?)?.data == 'Clownfish', + ); + final turtle = checkboxTiles.firstWhere( + (cb) => (cb.title as Text?)?.data == 'Green Turtle', + ); + final ray = checkboxTiles.firstWhere( + (cb) => (cb.title as Text?)?.data == 'Manta Ray', + ); + + expect(clownfish.value, isTrue); + expect(turtle.value, isTrue); + expect(ray.value, isFalse); + }); + + testWidgets('shows loading spinner when provider is loading', ( + tester, + ) async { + final completer = Completer>(); + addTearDown(() { + if (!completer.isCompleted) completer.complete([]); + }); + + await tester.pumpWidget( + _buildDialog( + overrides: [ + allSpeciesProvider.overrideWith((ref) => completer.future), + ], + ), + ); + await tester.pump(); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('caches search results and shows LinearProgressIndicator ' + 'during subsequent loading', (tester) async { + var callCount = 0; + final secondSearchCompleter = Completer>(); + addTearDown(() { + if (!secondSearchCompleter.isCompleted) { + secondSearchCompleter.complete([]); + } + }); + + await tester.pumpWidget( + _buildDialog( + overrides: [ + allSpeciesProvider.overrideWith((ref) async => _testSpecies), + speciesSearchProvider.overrideWith((ref, query) { + callCount++; + if (callCount <= 1) { + return Future.value( + _testSpecies + .where( + (s) => s.commonName.toLowerCase().contains( + query.toLowerCase(), + ), + ) + .toList(), + ); + } + return secondSearchCompleter.future; + }), + ], + ), + ); + await tester.pumpAndSettle(); + + // First search -- completes immediately, caches results + await tester.enterText(find.byType(TextField), 'Cl'); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pumpAndSettle(); + expect(find.text('Clownfish'), findsOneWidget); + + // Second search -- provider hangs in loading + await tester.enterText(find.byType(TextField), 'Clown'); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(); + + // Cached results still visible + expect(find.text('Clownfish'), findsOneWidget); + // Linear progress indicator on top + expect(find.byType(LinearProgressIndicator), findsOneWidget); + }); + + testWidgets('close button dismisses dialog', (tester) async { + await tester.pumpWidget( + _buildDialog( + overrides: [ + allSpeciesProvider.overrideWith((ref) async => _testSpecies), + ], + ), + ); + await tester.pumpAndSettle(); + + // Tap close button + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + + // Dialog should be dismissed -- species list gone + expect(find.text('Clownfish'), findsNothing); + }); + }); +} diff --git a/test/shared/widgets/debounced_search_results_test.dart b/test/shared/widgets/debounced_search_results_test.dart new file mode 100644 index 000000000..229775bef --- /dev/null +++ b/test/shared/widgets/debounced_search_results_test.dart @@ -0,0 +1,221 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/shared/widgets/debounced_search_results.dart'; + +void main() { + group('DebouncedSearchResults', () { + /// Builds a test harness that renders [DebouncedSearchResults] inside a + /// [ProviderScope]. The [queryNotifier] lets us swap the query from + /// outside, and [asyncNotifier] lets us control what the provider returns. + Widget buildSubject({ + required ValueNotifier queryNotifier, + required ValueNotifier>> asyncNotifier, + Widget Function(BuildContext)? emptyQueryBuilder, + }) { + return ProviderScope( + child: MaterialApp( + home: Scaffold( + body: ValueListenableBuilder( + valueListenable: queryNotifier, + builder: (context, query, _) { + return SizedBox( + height: 400, + child: DebouncedSearchResults( + query: query, + watchProvider: (ref, q) => asyncNotifier.value, + dataBuilder: (context, items) => ListView( + children: items + .map( + (item) => ListTile( + key: ValueKey(item), + title: Text(item), + ), + ) + .toList(), + ), + emptyBuilder: (context, q) => Text('No results for "$q"'), + errorBuilder: (context, error) => Text('Error: $error'), + emptyQueryBuilder: emptyQueryBuilder, + ), + ); + }, + ), + ), + ), + ); + } + + testWidgets('shows results after debounce period', ( + WidgetTester tester, + ) async { + final queryNotifier = ValueNotifier(''); + final asyncNotifier = ValueNotifier>>( + const AsyncValue.data(['Apple', 'Apricot']), + ); + + await tester.pumpWidget( + buildSubject( + queryNotifier: queryNotifier, + asyncNotifier: asyncNotifier, + ), + ); + + // Start with empty query -- nothing visible + expect(find.text('Apple'), findsNothing); + + // Set a query; results should NOT appear immediately (debounce) + queryNotifier.value = 'ap'; + await tester.pump(); // rebuild with new query, timer starts + + expect(find.text('Apple'), findsNothing); + + // Advance past the 300ms debounce + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.text('Apple'), findsOneWidget); + expect(find.text('Apricot'), findsOneWidget); + }); + + testWidgets( + 'shows cached results with LinearProgressIndicator during loading', + (WidgetTester tester) async { + final queryNotifier = ValueNotifier('fruit'); + final asyncNotifier = ValueNotifier>>( + const AsyncValue.data(['Banana', 'Blueberry']), + ); + + await tester.pumpWidget( + buildSubject( + queryNotifier: queryNotifier, + asyncNotifier: asyncNotifier, + ), + ); + + // The initial query is set at construction, so debounced query starts + // equal to it. Results appear immediately. + await tester.pump(); + expect(find.text('Banana'), findsOneWidget); + + // Now simulate a new search that is still loading + asyncNotifier.value = const AsyncValue.loading(); + queryNotifier.value = 'berry'; + await tester.pump(); // trigger rebuild, starts debounce timer + await tester.pump(const Duration(milliseconds: 300)); // debounce fires + + // Cached results should still be visible + expect(find.text('Banana'), findsOneWidget); + expect(find.text('Blueberry'), findsOneWidget); + // And a LinearProgressIndicator should be showing + expect(find.byType(LinearProgressIndicator), findsOneWidget); + }, + ); + + testWidgets('immediately clears when query becomes empty', ( + WidgetTester tester, + ) async { + final queryNotifier = ValueNotifier('fruit'); + final asyncNotifier = ValueNotifier>>( + const AsyncValue.data(['Cherry']), + ); + + await tester.pumpWidget( + buildSubject( + queryNotifier: queryNotifier, + asyncNotifier: asyncNotifier, + ), + ); + await tester.pump(); + expect(find.text('Cherry'), findsOneWidget); + + // Clear the query -- should disappear immediately, no 300ms wait + queryNotifier.value = ''; + await tester.pump(); // single frame, no timer advance + + expect(find.text('Cherry'), findsNothing); + }); + + testWidgets( + 'shows emptyQueryBuilder widget when provided and query is empty', + (WidgetTester tester) async { + final queryNotifier = ValueNotifier(''); + final asyncNotifier = ValueNotifier>>( + const AsyncValue.data([]), + ); + + await tester.pumpWidget( + buildSubject( + queryNotifier: queryNotifier, + asyncNotifier: asyncNotifier, + emptyQueryBuilder: (context) => const Text('Type to search'), + ), + ); + await tester.pump(); + + expect(find.text('Type to search'), findsOneWidget); + }, + ); + + testWidgets( + 'shows SizedBox.shrink when no emptyQueryBuilder and query is empty', + (WidgetTester tester) async { + final queryNotifier = ValueNotifier(''); + final asyncNotifier = ValueNotifier>>( + const AsyncValue.data([]), + ); + + await tester.pumpWidget( + buildSubject( + queryNotifier: queryNotifier, + asyncNotifier: asyncNotifier, + ), + ); + await tester.pump(); + + // The harness wraps content in a SizedBox(height: 400), so filter to + // only the shrink variant (width == 0 && height == 0). + final shrinkFinder = find.byWidgetPredicate( + (widget) => + widget is SizedBox && widget.width == 0.0 && widget.height == 0.0, + ); + expect(shrinkFinder, findsOneWidget); + }, + ); + + testWidgets('shows emptyBuilder when search returns empty list', ( + WidgetTester tester, + ) async { + final queryNotifier = ValueNotifier('xyz'); + final asyncNotifier = ValueNotifier>>( + const AsyncValue.data([]), + ); + + await tester.pumpWidget( + buildSubject( + queryNotifier: queryNotifier, + asyncNotifier: asyncNotifier, + ), + ); + await tester.pump(); + + expect(find.text('No results for "xyz"'), findsOneWidget); + }); + + testWidgets('shows errorBuilder on error', (WidgetTester tester) async { + final queryNotifier = ValueNotifier('fail'); + final asyncNotifier = ValueNotifier>>( + AsyncValue.error('Network timeout', StackTrace.current), + ); + + await tester.pumpWidget( + buildSubject( + queryNotifier: queryNotifier, + asyncNotifier: asyncNotifier, + ), + ); + await tester.pump(); + + expect(find.text('Error: Network timeout'), findsOneWidget); + }); + }); +}