diff --git a/lib/features/buddies/presentation/buddy_dive_share.dart b/lib/features/buddies/presentation/buddy_dive_share.dart new file mode 100644 index 0000000000..70a7ad4046 --- /dev/null +++ b/lib/features/buddies/presentation/buddy_dive_share.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; + +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/services/export/uddf/uddf_dives_extras.dart'; +import 'package:submersion/core/services/export/uddf/uddf_source_fetch.dart'; +import 'package:submersion/features/buddies/presentation/providers/buddy_providers.dart'; +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/settings/presentation/providers/export_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; +import 'package:submersion/shared/widgets/export_destination_sheet.dart'; + +/// Exports every dive shared with [buddyId] as UDDF, via the share sheet or +/// a save panel depending on the user's choice. +/// +/// Split out of [BuddyDetailPage] (pure export logic, no UI beyond the +/// destination sheet and a couple of snackbars) to keep that file under the +/// project's 800-line guideline. +Future shareDivesWithBuddy( + BuildContext context, + WidgetRef ref, + String buddyId, +) async { + // Capture the scaffold messenger and l10n before any async gaps + final scaffoldMessenger = ScaffoldMessenger.of(context); + final l10n = context.l10n; + + final choice = await showExportDestinationSheetWithOptions( + context, + title: l10n.buddies_action_shareDives, + showRawDataToggle: true, + showDiveContentToggles: true, + ); + if (choice == null) return; + final destination = choice.destination; + final options = choice.options; + + // Show preparing message + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text(l10n.buddies_message_preparingExport), + duration: const Duration(seconds: 1), + ), + ); + + // Get all dive IDs for this buddy + final diveIds = await ref.read(diveIdsForBuddyProvider(buddyId).future); + + if (diveIds.isEmpty) { + scaffoldMessenger.hideCurrentSnackBar(); + scaffoldMessenger.showSnackBar( + SnackBar(content: Text(l10n.buddies_message_noDivesToShare)), + ); + return; + } + + try { + // Fetch all dives + final diveRepository = ref.read(diveRepositoryProvider); + final dives = []; + for (final diveId in diveIds) { + final dive = await diveRepository.getDiveById(diveId); + if (dive != null) { + dives.add(dive); + } + } + + if (dives.isEmpty) { + scaffoldMessenger.hideCurrentSnackBar(); + scaffoldMessenger.showSnackBar( + SnackBar(content: Text(l10n.buddies_message_noDivesFound)), + ); + return; + } + + // Get unique sites from dives + final sites = dives + .where((d) => d.site != null) + .map((d) => d.site!) + .toSet() + .toList(); + + scaffoldMessenger.hideCurrentSnackBar(); + + // Hand the UDDF to the share sheet, or to a save panel on the user's + // request. Either way no success snackbar follows: the share sheet and + // the save panel each provide their own feedback. + final exportService = ref.read(exportServiceProvider); + final dataSources = await ref.read(uddfSourceFetchProvider)( + dives.map((d) => d.id).toList(growable: false), + options, + ); + final extras = await ref.read(uddfDivesExtrasFetchProvider)( + dives.map((d) => d.id).toList(growable: false), + options, + ); + switch (destination) { + case ExportDestination.share: + await exportService.exportDivesToUddf( + dives, + sites: sites, + dataSources: dataSources, + extras: extras, + options: options, + ); + case ExportDestination.saveToFile: + await exportService.saveDivesToUddfFile( + dives, + sites: sites, + dataSources: dataSources, + extras: extras, + options: options, + ); + } + } catch (e) { + scaffoldMessenger.hideCurrentSnackBar(); + scaffoldMessenger.showSnackBar( + SnackBar(content: Text(l10n.buddies_message_exportFailed(e.toString()))), + ); + } +} diff --git a/lib/features/buddies/presentation/pages/buddy_detail_page.dart b/lib/features/buddies/presentation/pages/buddy_detail_page.dart index 844b7d47b5..893d9f03f2 100644 --- a/lib/features/buddies/presentation/pages/buddy_detail_page.dart +++ b/lib/features/buddies/presentation/pages/buddy_detail_page.dart @@ -1,26 +1,23 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:submersion/core/services/export/uddf/uddf_dives_extras.dart'; -import 'package:submersion/core/services/export/uddf/uddf_source_fetch.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:submersion/shared/widgets/profile_photo/profile_avatar.dart'; import 'package:submersion/l10n/l10n_extension.dart'; import 'package:submersion/features/buddies/presentation/buddy_certification_l10n.dart'; +import 'package:submersion/features/buddies/presentation/buddy_dive_share.dart'; import 'package:submersion/core/constants/list_view_mode.dart'; import 'package:submersion/core/utils/unit_formatter.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; import 'package:submersion/shared/widgets/master_detail/detail_scroll_retainer.dart'; -import 'package:submersion/shared/widgets/export_destination_sheet.dart'; import 'package:submersion/shared/widgets/master_detail/responsive_breakpoints.dart'; import 'package:submersion/features/buddies/data/repositories/buddy_repository.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_favorite_button.dart'; +import 'package:submersion/features/buddies/presentation/widgets/buddy_shared_dives_section.dart'; import 'package:submersion/features/certifications/presentation/providers/certification_providers.dart'; -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/settings/presentation/providers/export_providers.dart'; import 'package:submersion/features/certifications/presentation/certification_title_l10n.dart'; class BuddyDetailPage extends ConsumerStatefulWidget { @@ -157,7 +154,7 @@ class _BuddyDetailContent extends ConsumerWidget { ], // Shared dives - _buildSharedDivesSection(context, ref), + BuddySharedDivesSection(buddyId: buddy.id), ], ), ); @@ -175,6 +172,7 @@ class _BuddyDetailContent extends ConsumerWidget { appBar: AppBar( title: Text(buddy.name), actions: [ + BuddyFavoriteButton(buddyId: buddy.id, isFavorite: buddy.isFavorite), IconButton( icon: const Icon(Icons.edit), tooltip: context.l10n.buddies_action_edit, @@ -267,6 +265,11 @@ class _BuddyDetailContent extends ConsumerWidget { ], ), ), + BuddyFavoriteButton( + buddyId: buddy.id, + isFavorite: buddy.isFavorite, + iconSize: 20, + ), IconButton( icon: const Icon(Icons.edit, size: 20), tooltip: context.l10n.common_action_edit, @@ -332,107 +335,8 @@ class _BuddyDetailContent extends ConsumerWidget { } } - Future _shareDivesWithBuddy(BuildContext context, WidgetRef ref) async { - // Capture the scaffold messenger and l10n before any async gaps - final scaffoldMessenger = ScaffoldMessenger.of(context); - final l10n = context.l10n; - - final choice = await showExportDestinationSheetWithOptions( - context, - title: l10n.buddies_action_shareDives, - showRawDataToggle: true, - showDiveContentToggles: true, - ); - if (choice == null) return; - final destination = choice.destination; - final options = choice.options; - - // Show preparing message - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text(l10n.buddies_message_preparingExport), - duration: const Duration(seconds: 1), - ), - ); - - // Get all dive IDs for this buddy - final diveIds = await ref.read(diveIdsForBuddyProvider(buddy.id).future); - - if (diveIds.isEmpty) { - scaffoldMessenger.hideCurrentSnackBar(); - scaffoldMessenger.showSnackBar( - SnackBar(content: Text(l10n.buddies_message_noDivesToShare)), - ); - return; - } - - try { - // Fetch all dives - final diveRepository = ref.read(diveRepositoryProvider); - final dives = []; - for (final diveId in diveIds) { - final dive = await diveRepository.getDiveById(diveId); - if (dive != null) { - dives.add(dive); - } - } - - if (dives.isEmpty) { - scaffoldMessenger.hideCurrentSnackBar(); - scaffoldMessenger.showSnackBar( - SnackBar(content: Text(l10n.buddies_message_noDivesFound)), - ); - return; - } - - // Get unique sites from dives - final sites = dives - .where((d) => d.site != null) - .map((d) => d.site!) - .toSet() - .toList(); - - scaffoldMessenger.hideCurrentSnackBar(); - - // Hand the UDDF to the share sheet, or to a save panel on the user's - // request. Either way no success snackbar follows: the share sheet and - // the save panel each provide their own feedback. - final exportService = ref.read(exportServiceProvider); - final dataSources = await ref.read(uddfSourceFetchProvider)( - dives.map((d) => d.id).toList(growable: false), - options, - ); - final extras = await ref.read(uddfDivesExtrasFetchProvider)( - dives.map((d) => d.id).toList(growable: false), - options, - ); - switch (destination) { - case ExportDestination.share: - await exportService.exportDivesToUddf( - dives, - sites: sites, - dataSources: dataSources, - extras: extras, - options: options, - ); - case ExportDestination.saveToFile: - await exportService.saveDivesToUddfFile( - dives, - sites: sites, - dataSources: dataSources, - extras: extras, - options: options, - ); - } - } catch (e) { - scaffoldMessenger.hideCurrentSnackBar(); - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text(l10n.buddies_message_exportFailed(e.toString())), - ), - ); - } - } + Future _shareDivesWithBuddy(BuildContext context, WidgetRef ref) => + shareDivesWithBuddy(context, ref, buddy.id); Widget _buildProfileHeader(BuildContext context) { return Center( @@ -622,182 +526,6 @@ class _BuddyDetailContent extends ConsumerWidget { ); } - Widget _buildSharedDivesSection(BuildContext context, WidgetRef ref) { - final diveIdsAsync = ref.watch(diveIdsForBuddyProvider(buddy.id)); - final divesAsync = ref.watch(divesForBuddyProvider(buddy.id)); - final theme = Theme.of(context); - // Includes the year: shared dives routinely span several years, so a bare - // "Mar 28" is ambiguous (#982). Matches the stats card above. - final units = UnitFormatter(ref.watch(settingsProvider)); - - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - context.l10n.buddies_section_sharedDives, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - diveIdsAsync.when( - data: (ids) => TextButton( - onPressed: ids.isEmpty - ? null - : () { - // Set filter to show only shared dives with this buddy - ref - .read(diveFilterProvider.notifier) - .state = DiveFilterState( - diveIds: ids, - buddyId: buddy.id, - ); - // Navigate to dive list - context.go('/dives'); - }, - child: Text( - context.l10n.buddies_action_viewAll(ids.length), - ), - ), - loading: () => const SizedBox.shrink(), - error: (e, st) => const SizedBox.shrink(), - ), - ], - ), - const SizedBox(height: 8), - divesAsync.when( - data: (dives) { - if (dives.isEmpty) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 16), - child: Center( - child: Text(context.l10n.buddies_detail_noDivesTogether), - ), - ); - } - // Show first 5 dives with same format as trip detail page - final displayDives = dives.take(5).toList(); - return Column( - children: displayDives.map((dive) { - return Semantics( - button: true, - label: - 'View dive ${dive.diveNumber ?? ''} at ${dive.site?.name ?? 'Unknown Site'}', - child: InkWell( - onTap: () => context.push('/dives/${dive.id}'), - borderRadius: BorderRadius.circular(8), - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 8, - horizontal: 4, - ), - child: Row( - children: [ - // Dive number badge - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: theme.colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(8), - ), - alignment: Alignment.center, - child: Text( - '#${dive.diveNumber ?? '-'}', - style: theme.textTheme.labelMedium?.copyWith( - color: theme.colorScheme.onPrimaryContainer, - fontWeight: FontWeight.bold, - ), - ), - ), - const SizedBox(width: 12), - // Dive details - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - dive.site?.name ?? 'Unknown Site', - style: theme.textTheme.bodyMedium - ?.copyWith( - fontWeight: FontWeight.w500, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Text( - units.formatDate(dive.dateTime), - style: theme.textTheme.bodySmall - ?.copyWith( - color: theme - .colorScheme - .onSurfaceVariant, - ), - ), - ], - ), - ), - // Stats - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - if (dive.maxDepth != null) - Text( - '${dive.maxDepth!.toStringAsFixed(1)}m', - style: theme.textTheme.bodyMedium - ?.copyWith( - fontWeight: FontWeight.w500, - ), - ), - if (dive.bottomTime != null) - Text( - '${dive.bottomTime!.inMinutes}min', - style: theme.textTheme.bodySmall - ?.copyWith( - color: theme - .colorScheme - .onSurfaceVariant, - ), - ), - ], - ), - const SizedBox(width: 4), - ExcludeSemantics( - child: Icon( - Icons.chevron_right, - color: theme.colorScheme.onSurfaceVariant, - size: 20, - ), - ), - ], - ), - ), - ), - ); - }).toList(), - ); - }, - loading: () => const Center( - child: Padding( - padding: EdgeInsets.all(16), - child: CircularProgressIndicator.adaptive(), - ), - ), - error: (e, st) => - Text(context.l10n.buddies_error_unableToLoadDives), - ), - ], - ), - ), - ); - } - Future _showDeleteConfirmation(BuildContext context) async { return await showDialog( context: context, diff --git a/lib/features/buddies/presentation/providers/buddy_providers.dart b/lib/features/buddies/presentation/providers/buddy_providers.dart index 6517ac73e3..3ca2b8034c 100644 --- a/lib/features/buddies/presentation/providers/buddy_providers.dart +++ b/lib/features/buddies/presentation/providers/buddy_providers.dart @@ -142,6 +142,27 @@ List applyBuddyWithDiveCountSorting( return sorted; } +/// Partitions buddies into favorites and others, each sorted independently +/// by [sort] (issue #1336). Favorites are rendered first by callers, +/// pinning them to the top regardless of the chosen sort field -- the same +/// rule the "Add buddy" picker sheet already applies. +({List favorites, List others}) +pinFavoriteBuddiesToTop( + List buddies, + SortState sort, +) { + return ( + favorites: applyBuddyWithDiveCountSorting( + buddies.where((b) => b.buddy.isFavorite).toList(), + sort, + ), + others: applyBuddyWithDiveCountSorting( + buddies.where((b) => !b.buddy.isFavorite).toList(), + sort, + ), + ); +} + /// Apply sorting to a list of buddies (for backward compatibility) List applyBuddySorting( List buddies, diff --git a/lib/features/buddies/presentation/widgets/buddy_favorite_button.dart b/lib/features/buddies/presentation/widgets/buddy_favorite_button.dart new file mode 100644 index 0000000000..40f0b571d7 --- /dev/null +++ b/lib/features/buddies/presentation/widgets/buddy_favorite_button.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; + +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/buddies/presentation/providers/buddy_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Favorite-star toggle shared by every buddy surface that offers it: the +/// list tiles, the detail page, and the "Add buddy" picker sheet (issue +/// #1336, extending the picker's own star from issue #638). +/// +/// [unselectedColor] lets a list row match its own secondary-text tint; +/// left null, the button inherits the surrounding IconTheme (the app bar's +/// icon color, for instance). The favorite color is always the theme's +/// primary color, unlike the unselected one. +/// +/// The tap target has an explicit 32x32 floor regardless of [iconSize] -- +/// without it, a small [iconSize] shrinks the button's own hit area to the +/// icon's size, and a near-miss tap falls through to whatever sits behind it +/// (the row's own navigation, in the dense and compact tiles). On touch +/// platforms the theme's padded tap-target size then lifts it to the 48x48 +/// Material minimum. The button keeps the standard visual density on +/// purpose: compact density subtracts 8 from both, which left 40x40 on touch +/// and 32x24 on desktop. +class BuddyFavoriteButton extends ConsumerWidget { + final String buddyId; + final bool isFavorite; + final double iconSize; + final Color? unselectedColor; + + const BuddyFavoriteButton({ + super.key, + required this.buddyId, + required this.isFavorite, + this.iconSize = 24, + this.unselectedColor, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return IconButton( + icon: Icon( + isFavorite ? Icons.star : Icons.star_border, + size: iconSize, + color: isFavorite + ? Theme.of(context).colorScheme.primary + : unselectedColor, + ), + tooltip: isFavorite + ? context.l10n.diveLog_detail_tooltip_removeFromFavorites + : context.l10n.diveLog_detail_tooltip_addToFavorites, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + onPressed: () => + ref.read(buddyListNotifierProvider.notifier).toggleFavorite(buddyId), + ); + } +} diff --git a/lib/features/buddies/presentation/widgets/buddy_list_content.dart b/lib/features/buddies/presentation/widgets/buddy_list_content.dart index 9217d9f53f..139c8e5ad1 100644 --- a/lib/features/buddies/presentation/widgets/buddy_list_content.dart +++ b/lib/features/buddies/presentation/widgets/buddy_list_content.dart @@ -453,7 +453,10 @@ class _BuddyListContentState extends ConsumerState { Widget buildContent() { return buddiesAsync.when( data: (buddies) { - final sorted = applyBuddyWithDiveCountSorting(buddies, sort); + // Favorites are pinned to the top regardless of the chosen sort + // field (issue #1336), matching the "Add buddy" picker sheet. + final (:favorites, :others) = pinFavoriteBuddiesToTop(buddies, sort); + final sorted = [...favorites, ...others]; return sorted.isEmpty ? _buildEmptyState(context) : _buildBuddyList(context, ref, sorted); diff --git a/lib/features/buddies/presentation/widgets/buddy_list_tile.dart b/lib/features/buddies/presentation/widgets/buddy_list_tile.dart index 577d047bbd..6f474de002 100644 --- a/lib/features/buddies/presentation/widgets/buddy_list_tile.dart +++ b/lib/features/buddies/presentation/widgets/buddy_list_tile.dart @@ -7,6 +7,7 @@ import 'package:submersion/features/buddies/domain/constants/buddy_field.dart'; import 'package:submersion/features/buddies/domain/entities/buddy.dart'; import 'package:submersion/features/buddies/domain/entities/buddy_with_dive_count.dart'; import 'package:submersion/features/buddies/presentation/providers/buddy_providers.dart'; +import 'package:submersion/features/buddies/presentation/widgets/buddy_favorite_button.dart'; import 'package:submersion/features/dive_roles/domain/entities/dive_role.dart'; import 'package:submersion/features/dive_roles/presentation/dive_role_display.dart'; import 'package:submersion/features/dive_roles/presentation/providers/dive_role_providers.dart'; @@ -205,6 +206,15 @@ class BuddyListTile extends ConsumerWidget { ], ), ), + // Favoriting (issue #1336) is independent of bulk + // selection, unlike the chevron below, so it stays + // visible and tappable in every mode. + BuddyFavoriteButton( + buddyId: buddy.id, + isFavorite: buddy.isFavorite, + iconSize: 20, + unselectedColor: secondaryTextColor, + ), if (!isSelectionMode) ExcludeSemantics( child: Icon( diff --git a/lib/features/buddies/presentation/widgets/buddy_picker.dart b/lib/features/buddies/presentation/widgets/buddy_picker.dart index 63dd2ffc6a..4700722ffb 100644 --- a/lib/features/buddies/presentation/widgets/buddy_picker.dart +++ b/lib/features/buddies/presentation/widgets/buddy_picker.dart @@ -18,6 +18,7 @@ import 'package:submersion/features/buddies/data/repositories/buddy_repository.d show BuddyWithDiveCount; 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_favorite_button.dart'; import 'package:submersion/features/certifications/domain/entities/certification.dart'; import 'package:submersion/features/certifications/presentation/providers/certification_providers.dart'; import 'package:submersion/features/dive_roles/presentation/widgets/dive_role_selector_sheet.dart'; @@ -575,14 +576,7 @@ class _BuddySelectionSheetState extends ConsumerState<_BuddySelectionSheet> { // Favorites are pinned to the top regardless of the chosen sort field // (issue #638); each partition is sorted independently so the toggle // still reorders within both groups. - final favorites = applyBuddyWithDiveCountSorting( - buddies.where((b) => b.buddy.isFavorite).toList(), - sort, - ); - final others = applyBuddyWithDiveCountSorting( - buddies.where((b) => !b.buddy.isFavorite).toList(), - sort, - ); + final (:favorites, :others) = pinFavoriteBuddiesToTop(buddies, sort); final rows = <_PickerRow>[ if (favorites.isNotEmpty) @@ -661,21 +655,11 @@ class _BuddySelectionSheetState extends ConsumerState<_BuddySelectionSheet> { ), ), ), - IconButton( - icon: Icon( - buddy.isFavorite ? Icons.star : Icons.star_border, - size: 20, - color: buddy.isFavorite - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.onSurfaceVariant, - ), - tooltip: buddy.isFavorite - ? context.l10n.diveLog_detail_tooltip_removeFromFavorites - : context.l10n.diveLog_detail_tooltip_addToFavorites, - visualDensity: VisualDensity.compact, - onPressed: () => ref - .read(buddyListNotifierProvider.notifier) - .toggleFavorite(buddy.id), + BuddyFavoriteButton( + buddyId: buddy.id, + isFavorite: buddy.isFavorite, + iconSize: 20, + unselectedColor: Theme.of(context).colorScheme.onSurfaceVariant, ), if (isSelected) Chip( diff --git a/lib/features/buddies/presentation/widgets/buddy_shared_dives_section.dart b/lib/features/buddies/presentation/widgets/buddy_shared_dives_section.dart new file mode 100644 index 0000000000..60d1c57fc2 --- /dev/null +++ b/lib/features/buddies/presentation/widgets/buddy_shared_dives_section.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/utils/unit_formatter.dart'; +import 'package:submersion/features/buddies/presentation/providers/buddy_providers.dart'; +import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Card listing the newest dives shared with a buddy, capped at five, with a +/// "view all" link that hands the buddy's full dive-id set to the dive list +/// filter (#982: the year is shown alongside the date since the list +/// routinely spans several years, unlike a bare "Mar 28"). +/// +/// Split out of [BuddyDetailPage] to keep that file under the project's +/// 800-line guideline. +class BuddySharedDivesSection extends ConsumerWidget { + final String buddyId; + + const BuddySharedDivesSection({super.key, required this.buddyId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final diveIdsAsync = ref.watch(diveIdsForBuddyProvider(buddyId)); + final divesAsync = ref.watch(divesForBuddyProvider(buddyId)); + final theme = Theme.of(context); + final units = UnitFormatter(ref.watch(settingsProvider)); + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + context.l10n.buddies_section_sharedDives, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + diveIdsAsync.when( + data: (ids) => TextButton( + onPressed: ids.isEmpty + ? null + : () { + // Set filter to show only shared dives with this buddy + ref.read(diveFilterProvider.notifier).state = + DiveFilterState(diveIds: ids, buddyId: buddyId); + // Navigate to dive list + context.go('/dives'); + }, + child: Text( + context.l10n.buddies_action_viewAll(ids.length), + ), + ), + loading: () => const SizedBox.shrink(), + error: (e, st) => const SizedBox.shrink(), + ), + ], + ), + const SizedBox(height: 8), + divesAsync.when( + data: (dives) { + if (dives.isEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Center( + child: Text(context.l10n.buddies_detail_noDivesTogether), + ), + ); + } + // Show first 5 dives with same format as trip detail page + final displayDives = dives.take(5).toList(); + return Column( + children: displayDives.map((dive) { + return Semantics( + button: true, + label: + 'View dive ${dive.diveNumber ?? ''} at ${dive.site?.name ?? 'Unknown Site'}', + child: InkWell( + onTap: () => context.push('/dives/${dive.id}'), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 4, + ), + child: Row( + children: [ + // Dive number badge + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.center, + child: Text( + '#${dive.diveNumber ?? '-'}', + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.onPrimaryContainer, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 12), + // Dive details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + dive.site?.name ?? 'Unknown Site', + style: theme.textTheme.bodyMedium + ?.copyWith( + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + units.formatDate(dive.dateTime), + style: theme.textTheme.bodySmall + ?.copyWith( + color: theme + .colorScheme + .onSurfaceVariant, + ), + ), + ], + ), + ), + // Stats + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (dive.maxDepth != null) + Text( + units.formatDepth(dive.maxDepth), + style: theme.textTheme.bodyMedium + ?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + if (dive.bottomTime != null) + Text( + '${dive.bottomTime!.inMinutes}min', + style: theme.textTheme.bodySmall + ?.copyWith( + color: theme + .colorScheme + .onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(width: 4), + ExcludeSemantics( + child: Icon( + Icons.chevron_right, + color: theme.colorScheme.onSurfaceVariant, + size: 20, + ), + ), + ], + ), + ), + ), + ); + }).toList(), + ); + }, + loading: () => const Center( + child: Padding( + padding: EdgeInsets.all(16), + child: CircularProgressIndicator.adaptive(), + ), + ), + error: (e, st) => + Text(context.l10n.buddies_error_unableToLoadDives), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/buddies/presentation/widgets/compact_buddy_list_tile.dart b/lib/features/buddies/presentation/widgets/compact_buddy_list_tile.dart index e9d40756af..450d2e3a4e 100644 --- a/lib/features/buddies/presentation/widgets/compact_buddy_list_tile.dart +++ b/lib/features/buddies/presentation/widgets/compact_buddy_list_tile.dart @@ -6,6 +6,7 @@ import 'package:submersion/features/buddies/domain/constants/buddy_field.dart'; import 'package:submersion/features/buddies/domain/entities/buddy.dart'; import 'package:submersion/features/buddies/domain/entities/buddy_with_dive_count.dart'; import 'package:submersion/features/buddies/presentation/providers/buddy_providers.dart'; +import 'package:submersion/features/buddies/presentation/widgets/buddy_favorite_button.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; import 'package:submersion/l10n/l10n_extension.dart'; import 'package:submersion/shared/selection/selection_checkbox_slot.dart'; @@ -124,6 +125,14 @@ class CompactBuddyListTile extends ConsumerWidget { BuddyField.diveCount, ), ), + // Favoriting (issue #1336), independent of bulk + // selection. + BuddyFavoriteButton( + buddyId: buddy.id, + isFavorite: buddy.isFavorite, + iconSize: 18, + unselectedColor: secondaryTextColor, + ), ExcludeSemantics( child: Icon( Icons.chevron_right, diff --git a/lib/features/buddies/presentation/widgets/dense_buddy_list_tile.dart b/lib/features/buddies/presentation/widgets/dense_buddy_list_tile.dart index b6e0578da4..07840041f8 100644 --- a/lib/features/buddies/presentation/widgets/dense_buddy_list_tile.dart +++ b/lib/features/buddies/presentation/widgets/dense_buddy_list_tile.dart @@ -1,13 +1,14 @@ import 'package:flutter/material.dart'; import 'package:submersion/features/buddies/domain/entities/buddy.dart'; +import 'package:submersion/features/buddies/presentation/widgets/buddy_favorite_button.dart'; import 'package:submersion/shared/selection/selection_checkbox_slot.dart'; import 'package:submersion/l10n/l10n_extension.dart'; import 'package:submersion/features/buddies/presentation/buddy_certification_l10n.dart'; /// Single-row flat tile for the buddy list (maximum density). /// -/// Row: Buddy name (expanded) | Cert level (~100px) | Dive count (~40px) | Chevron +/// Row: Buddy name (expanded) | Cert level (~100px) | Dive count (~40px) | Favorite star | Chevron /// No avatar, no agency. Uses a bottom border divider instead of a card wrapper. class DenseBuddyListTile extends StatelessWidget { final Buddy buddy; @@ -106,6 +107,13 @@ class DenseBuddyListTile extends StatelessWidget { ) : null, ), + // Favoriting (issue #1336), independent of bulk selection. + BuddyFavoriteButton( + buddyId: buddy.id, + isFavorite: buddy.isFavorite, + iconSize: 16, + unselectedColor: secondaryTextColor, + ), ExcludeSemantics( child: Icon( Icons.chevron_right, diff --git a/test/features/buddies/helpers/fake_buddy_list_notifier.dart b/test/features/buddies/helpers/fake_buddy_list_notifier.dart new file mode 100644 index 0000000000..d47fd45cf1 --- /dev/null +++ b/test/features/buddies/helpers/fake_buddy_list_notifier.dart @@ -0,0 +1,22 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/legacy.dart'; +import 'package:submersion/features/buddies/domain/entities/buddy.dart'; +import 'package:submersion/features/buddies/presentation/providers/buddy_providers.dart'; + +/// Records [toggleFavorite] calls instead of reaching the real repository, +/// which widget tests have no database for. Every other call is a no-op, +/// mirroring the `noSuchMethod`-based mocks elsewhere in this feature. +class FakeBuddyListNotifier extends StateNotifier>> + implements BuddyListNotifier { + FakeBuddyListNotifier() : super(const AsyncValue.data([])); + + final List toggledFavoriteIds = []; + + @override + Future toggleFavorite(String buddyId) async { + toggledFavoriteIds.add(buddyId); + } + + @override + dynamic noSuchMethod(Invocation invocation) => null; +} diff --git a/test/features/buddies/presentation/pages/buddy_detail_page_test.dart b/test/features/buddies/presentation/pages/buddy_detail_page_test.dart index 6c37c09ff1..09ec3dc1f9 100644 --- a/test/features/buddies/presentation/pages/buddy_detail_page_test.dart +++ b/test/features/buddies/presentation/pages/buddy_detail_page_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:submersion/core/constants/list_view_mode.dart'; +import 'package:submersion/core/constants/units.dart'; import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/features/buddies/data/repositories/buddy_repository.dart'; import 'package:submersion/features/buddies/domain/entities/buddy.dart'; @@ -12,6 +13,7 @@ import 'package:submersion/features/settings/presentation/providers/settings_pro import 'package:submersion/l10n/arb/app_localizations.dart'; import '../../../../helpers/mock_providers.dart'; +import '../../helpers/fake_buddy_list_notifier.dart'; /// Silences the RenderFlex overflow this page produces at phone widths while /// still surfacing every other framework error. @@ -277,4 +279,185 @@ void main() { ); }); }); + + // The shared-dives card appended a literal "m" to the stored meter value, + // so an imperial diver saw a metric number labelled as meters. + group('BuddyDetailPage shared dive depth units', () { + testWidgets('shows max depth in the active diver\'s depth unit', ( + tester, + ) async { + final buddy = Buddy( + id: 'buddy-1', + name: 'Jane Doe', + notes: '', + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ); + + final dives = [ + createTestDiveWithBottomTime( + id: 'buddy-dive-1', + diveNumber: 1, + maxDepth: 25.0, + ), + ]; + + final overrides = await getBaseOverrides( + settingsNotifier: MockSettingsNotifier( + const AppSettings(depthUnit: DepthUnit.feet), + ), + ); + + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(390, 844); + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + // Installed before the first frame: an overflow thrown during + // pumpWidget would otherwise escape the handler. + _ignoreOverflowErrors(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + ...overrides, + buddyByIdProvider(buddy.id).overrideWith((ref) async => buddy), + buddyStatsProvider( + buddy.id, + ).overrideWith((ref) async => const BuddyStats(totalDives: 1)), + diveIdsForBuddyProvider( + buddy.id, + ).overrideWith((ref) async => ['buddy-dive-1']), + divesForBuddyProvider(buddy.id).overrideWith((ref) async => dives), + ].cast(), + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: BuddyDetailPage(buddyId: buddy.id, embedded: true), + ), + ), + ); + await tester.pumpAndSettle(); + + // 25 m x 3.28084 = 82.021 ft. + expect(find.text('82.0ft'), findsOneWidget); + expect(find.text('25.0m'), findsNothing); + }); + }); + + group('BuddyDetailPage favorite star (issue #1336)', () { + final buddy = Buddy( + id: 'buddy-1', + name: 'Jane Doe', + notes: '', + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ); + + Future> pageOverrides() async => [ + ...await getBaseOverrides(), + buddyByIdProvider(buddy.id).overrideWith((ref) async => buddy), + buddyStatsProvider( + buddy.id, + ).overrideWith((ref) async => const BuddyStats(totalDives: 0)), + diveIdsForBuddyProvider(buddy.id).overrideWith((ref) async => []), + divesForBuddyProvider(buddy.id).overrideWith((ref) async => []), + ]; + + testWidgets('shows a star in the embedded header, toggling it', ( + tester, + ) async { + final notifier = FakeBuddyListNotifier(); + _ignoreOverflowErrors(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + ...await pageOverrides(), + buddyListNotifierProvider.overrideWith((ref) => notifier), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: BuddyDetailPage(buddyId: buddy.id, embedded: true), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.star_border), findsOneWidget); + + await tester.tap(find.byIcon(Icons.star_border)); + await tester.pumpAndSettle(); + + expect(notifier.toggledFavoriteIds, ['buddy-1']); + }); + + testWidgets('shows a star in the app bar of the standalone page', ( + tester, + ) async { + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(390, 844); + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + final notifier = FakeBuddyListNotifier(); + _ignoreOverflowErrors(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + ...await pageOverrides(), + buddyListNotifierProvider.overrideWith((ref) => notifier), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: BuddyDetailPage(buddyId: buddy.id), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.star_border), findsOneWidget); + + await tester.tap(find.byIcon(Icons.star_border)); + await tester.pumpAndSettle(); + + expect(notifier.toggledFavoriteIds, ['buddy-1']); + }); + + testWidgets('shows a filled star for an already-favorite buddy', ( + tester, + ) async { + final favoriteBuddy = buddy.copyWith(isFavorite: true); + _ignoreOverflowErrors(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + ...await getBaseOverrides(), + buddyByIdProvider( + buddy.id, + ).overrideWith((ref) async => favoriteBuddy), + buddyStatsProvider( + buddy.id, + ).overrideWith((ref) async => const BuddyStats(totalDives: 0)), + diveIdsForBuddyProvider(buddy.id).overrideWith((ref) async => []), + divesForBuddyProvider(buddy.id).overrideWith((ref) async => []), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: BuddyDetailPage(buddyId: buddy.id, embedded: true), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.star), findsOneWidget); + expect(find.byIcon(Icons.star_border), findsNothing); + }); + }); } diff --git a/test/features/buddies/presentation/providers/buddy_providers_test.dart b/test/features/buddies/presentation/providers/buddy_providers_test.dart index b793084aec..0aac944311 100644 --- a/test/features/buddies/presentation/providers/buddy_providers_test.dart +++ b/test/features/buddies/presentation/providers/buddy_providers_test.dart @@ -438,6 +438,64 @@ void main() { }); }); + group('pinFavoriteBuddiesToTop (issue #1336)', () { + test('splits favorites and others, each sorted independently', () { + final buddies = [ + _withCount('Zeta', diveCount: 1, isFavorite: true), + _withCount('Alpha', diveCount: 10), + _withCount('Mid Fave', diveCount: 5, isFavorite: true), + _withCount('Beta', diveCount: 3), + ]; + + final result = pinFavoriteBuddiesToTop( + buddies, + const SortState( + field: BuddySortField.diveCount, + direction: SortDirection.descending, + ), + ); + + expect(result.favorites.map((b) => b.buddy.name), [ + 'Mid Fave', + 'Zeta', + ], reason: 'favorites still follow the chosen sort among themselves'); + expect(result.others.map((b) => b.buddy.name), ['Alpha', 'Beta']); + }); + + test('an empty favorites list yields an empty favorites partition', () { + final buddies = [_withCount('Alpha'), _withCount('Beta')]; + + final result = pinFavoriteBuddiesToTop( + buddies, + const SortState( + field: BuddySortField.name, + direction: SortDirection.descending, + ), + ); + + expect(result.favorites, isEmpty); + expect(result.others.map((b) => b.buddy.name), ['Alpha', 'Beta']); + }); + + test('does not mutate the input list', () { + final buddies = [ + _withCount('Fave', isFavorite: true), + _withCount('Plain'), + ]; + final original = List.of(buddies); + + pinFavoriteBuddiesToTop( + buddies, + const SortState( + field: BuddySortField.name, + direction: SortDirection.descending, + ), + ); + + expect(buddies, original); + }); + }); + group('applyBuddySorting fallback (plain Buddy, no aggregates)', () { test('lastDive falls back to name sorting, like diveCount', () { final buddies = [ diff --git a/test/features/buddies/presentation/widgets/buddy_favorite_button_test.dart b/test/features/buddies/presentation/widgets/buddy_favorite_button_test.dart new file mode 100644 index 0000000000..e955662333 --- /dev/null +++ b/test/features/buddies/presentation/widgets/buddy_favorite_button_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/buddies/presentation/widgets/buddy_favorite_button.dart'; + +import '../../../../helpers/test_app.dart'; + +void main() { + group('BuddyFavoriteButton tap target', () { + // Measured at the smallest icon any caller uses (the dense list tile). + Future pumpAndMeasure( + WidgetTester tester, + TargetPlatform platform, + ) async { + debugDefaultTargetPlatformOverride = platform; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + await tester.pumpWidget( + testApp( + child: const Center( + child: BuddyFavoriteButton( + buddyId: 'buddy-1', + isFavorite: false, + iconSize: 16, + ), + ), + ), + ); + final size = tester.getSize(find.byType(IconButton)); + debugDefaultTargetPlatformOverride = null; + return size; + } + + testWidgets('meets the 48x48 minimum on touch platforms', (tester) async { + expect( + await pumpAndMeasure(tester, TargetPlatform.android), + const Size(48, 48), + ); + expect( + await pumpAndMeasure(tester, TargetPlatform.iOS), + const Size(48, 48), + ); + }); + + testWidgets('keeps a 32x32 floor on pointer-driven desktop', ( + tester, + ) async { + expect( + await pumpAndMeasure(tester, TargetPlatform.macOS), + const Size(32, 32), + ); + expect( + await pumpAndMeasure(tester, TargetPlatform.windows), + const Size(32, 32), + ); + }); + }); +} diff --git a/test/features/buddies/presentation/widgets/buddy_list_content_test.dart b/test/features/buddies/presentation/widgets/buddy_list_content_test.dart index 1b351cebc7..cb739020f6 100644 --- a/test/features/buddies/presentation/widgets/buddy_list_content_test.dart +++ b/test/features/buddies/presentation/widgets/buddy_list_content_test.dart @@ -67,6 +67,7 @@ BuddyWithDiveCount _makeBuddy({ CertificationLevel? certLevel, CertificationAgency? certAgency, int diveCount = 0, + bool isFavorite = false, }) { return BuddyWithDiveCount( buddy: Buddy( @@ -75,6 +76,7 @@ BuddyWithDiveCount _makeBuddy({ email: email, certificationLevel: certLevel, certificationAgency: certAgency, + isFavorite: isFavorite, createdAt: _now, updatedAt: _now, ), @@ -664,4 +666,60 @@ void main() { expect(find.byType(BuddyListTile), findsNothing); }); }); + + group('favorites pinned to top (issue #1336)', () { + testWidgets( + 'a favorite sorts before non-favorites regardless of the alphabet', + (tester) async { + final overrides = await _buildPhoneOverrides( + buddies: [ + _makeBuddy(id: 'b1', name: 'Aaa Buddy'), + _makeBuddy(id: 'b2', name: 'Bbb Buddy'), + _makeBuddy(id: 'b3', name: 'Zzz Favorite', isFavorite: true), + ], + viewMode: ListViewMode.dense, + ); + await tester.pumpWidget( + testApp(overrides: overrides, child: const BuddyListContent()), + ); + await tester.pumpAndSettle(); + + final order = tester + .widgetList(find.byType(DenseBuddyListTile)) + .map((w) => w.buddy.name) + .toList(); + + expect(order, ['Zzz Favorite', 'Aaa Buddy', 'Bbb Buddy']); + }, + ); + + testWidgets('does not pin favorites in table mode', (tester) async { + final overrides = await _buildOverrides( + buddies: [ + _makeBuddy(id: 'b1', name: 'Aaa Buddy'), + _makeBuddy(id: 'b2', name: 'Bbb Buddy'), + _makeBuddy(id: 'b3', name: 'Zzz Favorite', isFavorite: true), + ], + ); + await tester.pumpWidget( + testApp(overrides: overrides, child: const BuddyListContent()), + ); + await tester.pumpAndSettle(); + + // Table mode is untouched by the favorites-pinning change (issue + // #1336): rows stay in the order the provider returned them, with the + // favorite ("Zzz Favorite") last rather than pinned to the top. + expect( + tester.getTopLeft(find.text('Aaa Buddy')).dy < + tester.getTopLeft(find.text('Bbb Buddy')).dy, + isTrue, + ); + expect( + tester.getTopLeft(find.text('Bbb Buddy')).dy < + tester.getTopLeft(find.text('Zzz Favorite')).dy, + isTrue, + reason: 'the favorite must not be pinned to the top in table mode', + ); + }); + }); } diff --git a/test/features/buddies/presentation/widgets/buddy_list_tile_test.dart b/test/features/buddies/presentation/widgets/buddy_list_tile_test.dart index 9bdde710af..e91290e686 100644 --- a/test/features/buddies/presentation/widgets/buddy_list_tile_test.dart +++ b/test/features/buddies/presentation/widgets/buddy_list_tile_test.dart @@ -16,6 +16,7 @@ import 'package:submersion/shared/providers/entity_card_config_providers.dart'; import '../../../../helpers/mock_providers.dart'; import '../../../../helpers/test_app.dart'; +import '../../helpers/fake_buddy_list_notifier.dart'; const _config = EntityCardViewConfig( slots: [ @@ -324,4 +325,96 @@ void main() { reason: 'the checkbox claims the tap, so the row toggles exactly once', ); }); + + group('favorite star (issue #1336)', () { + testWidgets('shows an outlined star for a non-favorite buddy', ( + tester, + ) async { + await tester.pumpWidget( + testApp( + overrides: await _overrides(), + child: BuddyListTile( + entry: BuddyWithDiveCount(buddy: _buddy(), diveCount: 0), + onTap: () {}, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.star_border), findsOneWidget); + expect(find.byIcon(Icons.star), findsNothing); + }); + + testWidgets('shows a filled star for a favorite buddy', (tester) async { + await tester.pumpWidget( + testApp( + overrides: await _overrides(), + child: BuddyListTile( + entry: BuddyWithDiveCount( + buddy: _buddy().copyWith(isFavorite: true), + diveCount: 0, + ), + onTap: () {}, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.star), findsOneWidget); + expect(find.byIcon(Icons.star_border), findsNothing); + }); + + testWidgets('tapping the star toggles favorite without triggering onTap', ( + tester, + ) async { + final notifier = FakeBuddyListNotifier(); + var rowTaps = 0; + await tester.pumpWidget( + testApp( + overrides: [ + ...await _overrides(), + buddyListNotifierProvider.overrideWith((ref) => notifier), + ], + child: BuddyListTile( + entry: BuddyWithDiveCount(buddy: _buddy(), diveCount: 0), + onTap: () => rowTaps++, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.star_border)); + await tester.pumpAndSettle(); + + expect(notifier.toggledFavoriteIds, ['b1']); + expect( + rowTaps, + 0, + reason: 'the star button claims the tap, not the row navigation', + ); + }); + + testWidgets('stays visible in selection mode', (tester) async { + await tester.pumpWidget( + testApp( + overrides: await _overrides(), + child: BuddyListTile( + entry: BuddyWithDiveCount(buddy: _buddy(), diveCount: 0), + isSelectionMode: true, + isChecked: false, + onTap: () {}, + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byIcon(Icons.star_border), + findsOneWidget, + reason: + 'favoriting is independent of bulk selection, unlike the ' + 'navigation chevron it replaces', + ); + }); + }); } diff --git a/test/features/buddies/presentation/widgets/compact_buddy_list_tile_test.dart b/test/features/buddies/presentation/widgets/compact_buddy_list_tile_test.dart index 3e6e748e30..835e5b47b1 100644 --- a/test/features/buddies/presentation/widgets/compact_buddy_list_tile_test.dart +++ b/test/features/buddies/presentation/widgets/compact_buddy_list_tile_test.dart @@ -11,6 +11,7 @@ import 'package:submersion/shared/providers/entity_card_config_providers.dart'; import '../../../../helpers/mock_providers.dart'; import '../../../../helpers/test_app.dart'; +import '../../helpers/fake_buddy_list_notifier.dart'; const _config = EntityCardViewConfig( slots: [ @@ -80,4 +81,48 @@ void main() { expect(find.byType(Checkbox), findsOneWidget); }); + + group('favorite star (issue #1336)', () { + testWidgets('shows a filled star for a favorite buddy', (tester) async { + await tester.pumpWidget( + testApp( + overrides: await _overrides(), + child: CompactBuddyListTile( + entry: BuddyWithDiveCount( + buddy: _entry.buddy.copyWith(isFavorite: true), + diveCount: _entry.diveCount, + ), + onTap: () {}, + ), + ), + ); + await tester.pump(); + + expect(find.byIcon(Icons.star), findsOneWidget); + expect(find.byIcon(Icons.star_border), findsNothing); + }); + + testWidgets('tapping the star toggles favorite without triggering onTap', ( + tester, + ) async { + final notifier = FakeBuddyListNotifier(); + var rowTaps = 0; + await tester.pumpWidget( + testApp( + overrides: [ + ...await _overrides(), + buddyListNotifierProvider.overrideWith((ref) => notifier), + ], + child: CompactBuddyListTile(entry: _entry, onTap: () => rowTaps++), + ), + ); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.star_border)); + await tester.pump(); + + expect(notifier.toggledFavoriteIds, ['b1']); + expect(rowTaps, 0); + }); + }); } diff --git a/test/features/buddies/presentation/widgets/dense_buddy_list_tile_test.dart b/test/features/buddies/presentation/widgets/dense_buddy_list_tile_test.dart index 2adb4e9925..7b95905c0f 100644 --- a/test/features/buddies/presentation/widgets/dense_buddy_list_tile_test.dart +++ b/test/features/buddies/presentation/widgets/dense_buddy_list_tile_test.dart @@ -2,9 +2,11 @@ import 'package:flutter/material.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/dense_buddy_list_tile.dart'; import '../../../../helpers/test_app.dart'; +import '../../helpers/fake_buddy_list_notifier.dart'; Buddy _makeBuddy({ String id = 'test-id', @@ -125,8 +127,51 @@ void main() { ), ); - await tester.tap(find.byType(InkWell)); + // The favorite star's own IconButton also builds an InkWell, so the + // row's is disambiguated by position: it wraps the whole tile and is + // therefore built first. + await tester.tap(find.byType(InkWell).first); expect(tapped, isTrue); }); + + group('favorite star (issue #1336)', () { + testWidgets('shows a filled star for a favorite buddy', (tester) async { + await tester.pumpWidget( + testApp( + child: DenseBuddyListTile( + buddy: _makeBuddy().copyWith(isFavorite: true), + ), + ), + ); + + expect(find.byIcon(Icons.star), findsOneWidget); + expect(find.byIcon(Icons.star_border), findsNothing); + }); + + testWidgets( + 'tapping the star toggles favorite without triggering onTap', + (tester) async { + final notifier = FakeBuddyListNotifier(); + var tapped = false; + await tester.pumpWidget( + testApp( + overrides: [ + buddyListNotifierProvider.overrideWith((ref) => notifier), + ], + child: DenseBuddyListTile( + buddy: _makeBuddy(id: 'dense-1'), + onTap: () => tapped = true, + ), + ), + ); + + await tester.tap(find.byIcon(Icons.star_border)); + await tester.pump(); + + expect(notifier.toggledFavoriteIds, ['dense-1']); + expect(tapped, isFalse); + }, + ); + }); }); }