Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 4 additions & 7 deletions lib/features/dive_log/presentation/pages/dive_detail_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import 'package:latlong2/latlong.dart';
import 'package:libdivecomputer_plugin/libdivecomputer_plugin.dart' as pigeon;
import 'package:submersion/features/equipment/data/services/sensor_summary_scheduler.dart';
import 'package:submersion/features/equipment/presentation/widgets/observation_status_chip.dart';
import 'package:submersion/features/tags/presentation/widgets/tag_chip.dart';
import 'package:submersion/shared/widgets/profile_photo/profile_avatar.dart';
import 'package:submersion/core/constants/dive_detail_layout.dart';
import 'package:submersion/core/constants/dive_detail_section_pairs.dart';
Expand Down Expand Up @@ -4153,14 +4154,10 @@ class _DiveDetailPageState extends ConsumerState<DiveDetailPage> {
runSpacing: 8,
children: dive.tags
.map(
(tag) => ActionChip(
label: Text(tag.name),
(tag) => TagChip(
tag: tag,
tooltip: context.l10n.tags_action_showDives(tag.name),
backgroundColor: tag.color.withValues(alpha: 0.2),
side: BorderSide(color: tag.color),
labelStyle: TextStyle(color: tag.color),
visualDensity: VisualDensity.compact,
onPressed: () => openDivesWithTag(context, ref, tag.id),
onTap: () => openDivesWithTag(context, ref, tag.id),
),
)
.toList(),
Expand Down
13 changes: 8 additions & 5 deletions lib/features/dive_log/presentation/pages/dive_search_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -887,13 +887,16 @@ class _DiveSearchPageState extends ConsumerState<DiveSearchPage> {
children: allTags.map((tag) {
final isSelected = _selectedTagIds.contains(tag.id);
return FilterChip(
// The dot carries the tag's own colour, exactly as the
// site and equipment filter sheets show it. A tinted
// chip body would report a different colour on every
// surface (issue #2254).
avatar: CircleAvatar(
backgroundColor: tag.color,
radius: 6,
),
label: Text(tag.name),
selected: isSelected,
selectedColor: tag.color.withValues(alpha: 0.3),
checkmarkColor: tag.color,
side: BorderSide(
color: isSelected ? tag.color : Colors.grey.shade300,
),
onSelected: (selected) {
setState(() {
if (selected) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -783,17 +783,17 @@ class _DiveFilterSheetState extends ConsumerState<DiveFilterSheet> {
tag.id,
);
return FilterChip(
// The dot carries the tag's own colour,
// exactly as the site and equipment
// filter sheets show it. A tinted chip
// body would report a different colour on
// every surface (issue #2254).
avatar: CircleAvatar(
backgroundColor: tag.color,
radius: 6,
),
label: Text(tag.name),
selected: isSelected,
selectedColor: tag.color.withValues(
alpha: 0.3,
),
checkmarkColor: tag.color,
side: BorderSide(
color: isSelected
? tag.color
: Colors.grey.shade300,
),
onSelected: (selected) {
setState(() {
if (selected) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import 'package:submersion/features/maps/presentation/widgets/trackpad_zoom_map.
import 'package:submersion/features/settings/presentation/providers/settings_providers.dart';
import 'package:submersion/features/site_scape/presentation/site_feature_glyph.dart';
import 'package:submersion/features/site_scape/presentation/site_feature_sheet.dart';
import 'package:submersion/features/tags/presentation/widgets/tag_chip.dart';
import 'package:submersion/l10n/l10n_extension.dart';
import 'package:submersion/shared/selection/selection_inset.dart';
import 'package:submersion/shared/selection/selection_leading.dart';
Expand Down Expand Up @@ -163,14 +164,10 @@ class _SiteListTileState extends ConsumerState<SiteListTile> {
color: SiteFeatureGlyph.styleFor(typeName).$2,
textColor: chipTextColor,
),
// Tags (issue #1765): the first three, then a count of the rest.
for (final tag in shownTags)
_SiteChip(
icon: Icons.sell_outlined,
label: tag.name,
color: tag.color,
textColor: chipTextColor,
),
// Tags (issue #1765): the first three, then a count of the rest. A tag
// carries the diver's own colour, so it is filled with it rather than
// outlined like the type and feature chips (issue #2254).
for (final tag in shownTags) TagChip(tag: tag, dense: true),
if (hiddenTagCount > 0)
_SiteChip(
icon: Icons.sell_outlined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:submersion/core/providers/provider.dart';
import 'package:submersion/features/dive_sites/presentation/providers/site_providers.dart';
import 'package:submersion/features/dive_sites/presentation/site_tag_navigation.dart';
import 'package:submersion/features/tags/presentation/widgets/tag_chip.dart';
import 'package:submersion/l10n/l10n_extension.dart';

/// The Tags card on site detail (issue #1765), built like the dive detail
Expand Down Expand Up @@ -52,14 +53,10 @@ class SiteTagsCard extends ConsumerWidget {
runSpacing: 8,
children: [
for (final tag in tags)
ActionChip(
label: Text(tag.name),
TagChip(
tag: tag,
tooltip: l10n.diveSites_detail_showSitesWith(tag.name),
backgroundColor: tag.color.withValues(alpha: 0.2),
side: BorderSide(color: tag.color),
labelStyle: TextStyle(color: tag.color),
visualDensity: VisualDensity.compact,
onPressed: () => openSitesWithTag(context, ref, tag.id),
onTap: () => openSitesWithTag(context, ref, tag.id),
),
],
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:submersion/core/providers/provider.dart';
import 'package:submersion/features/equipment/presentation/equipment_tag_navigation.dart';
import 'package:submersion/features/equipment/presentation/providers/equipment_tag_providers.dart';
import 'package:submersion/features/tags/presentation/widgets/tag_chip.dart';
import 'package:submersion/l10n/l10n_extension.dart';

/// An equipment item's tags as colored chips, under the name in the detail
Expand All @@ -27,16 +28,12 @@ class EquipmentTagChips extends ConsumerWidget {
runSpacing: 8,
children: [
for (final tag in tags)
ActionChip(
label: Text(tag.name),
TagChip(
tag: tag,
tooltip: context.l10n.equipment_detail_showEquipmentWith(
tag.name,
),
backgroundColor: tag.color.withValues(alpha: 0.2),
side: BorderSide(color: tag.color),
labelStyle: TextStyle(color: tag.color),
visualDensity: VisualDensity.compact,
onPressed: () => openEquipmentWithTag(context, ref, tag.id),
onTap: () => openEquipmentWithTag(context, ref, tag.id),
),
],
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'package:flutter/material.dart';

import 'package:submersion/features/import_wizard/domain/models/tag_selection.dart';
import 'package:submersion/features/tags/domain/entities/tag.dart';
import 'package:submersion/features/tags/presentation/widgets/tag_chip.dart';
import 'package:submersion/l10n/l10n_extension.dart';
import 'package:submersion/shared/widgets/forms/autocomplete_options_list.dart';

Expand Down Expand Up @@ -138,18 +139,11 @@ class _ImportTagsFieldState extends State<ImportTagsField> {
],
),
for (var i = 0; i < widget.tags.length; i++)
() {
final tagColor = _resolveColor(widget.tags[i]);
return Chip(
label: Text(widget.tags[i].name),
backgroundColor: tagColor.withValues(alpha: 0.2),
side: BorderSide(color: tagColor),
labelStyle: TextStyle(color: tagColor),
deleteIcon: Icon(Icons.close, size: 18, color: tagColor),
onDeleted: () => widget.onRemove(i),
visualDensity: VisualDensity.compact,
);
}(),
TagChip.unsaved(
name: widget.tags[i].name,
color: _resolveColor(widget.tags[i]),
onDeleted: () => widget.onRemove(i),
),
IntrinsicWidth(
child: TextField(
controller: controller,
Expand Down
30 changes: 30 additions & 0 deletions lib/features/tags/presentation/tag_color_contrast.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';

/// The label colour for text written on [background] (issue #2254).
///
/// A tag chip is filled with the diver's own colour, so it cannot take its
/// label colour from the theme: the palette runs from a pale yellow to a
/// near-black slate, and either extreme swallows one of the two. The choice
/// is made on WCAG 2.1 contrast ratio rather than a luminance threshold,
/// because the crossover point between black and white sits at a luminance
/// of about 0.18 and a threshold picked by eye lands on the wrong side of
/// the palette's mid tones.
///
/// Both candidates are opaque, so nothing behind the chip shows through the
/// label.
Color tagForegroundColor(Color background) {
return _contrast(Colors.black, background) >=
_contrast(Colors.white, background)
? Colors.black
: Colors.white;
}

/// WCAG 2.1 contrast ratio between two opaque colours, from 1 (identical) to
/// 21 (black on white).
double _contrast(Color a, Color b) {
final la = a.computeLuminance();
final lb = b.computeLuminance();
final lighter = la > lb ? la : lb;
final darker = la > lb ? lb : la;
return (lighter + 0.05) / (darker + 0.05);
}
172 changes: 172 additions & 0 deletions lib/features/tags/presentation/widgets/tag_chip.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import 'package:flutter/material.dart';

import 'package:submersion/features/tags/domain/entities/tag.dart';
import 'package:submersion/features/tags/presentation/tag_color_contrast.dart';

/// A tag shown in the colour the diver gave it (issue #2254).
///
/// Every tag chip in the app renders through this widget, so a tag looks the
/// same in a dive list row, on a detail card, beside a site and beside a
/// piece of equipment. The fill is [Tag.color] at full opacity, never a
/// translucent tint: a tint is not a colour but a recipe, and the chips that
/// used one resolved to a different colour on every surface, turning an amber
/// tag grey-olive on a selected row. The label takes the colour that
/// contrasts with the fill, since the palette spans a pale yellow and a
/// near-black slate.
class TagChip extends StatelessWidget {
TagChip({
super.key,
required Tag tag,
this.onTap,
this.onDeleted,
this.tooltip,
this.deleteTooltip,
this.dense = false,
}) : name = tag.name,
color = tag.color;

/// A tag with no row of its own yet, such as one typed into the import
/// wizard before the import runs.
const TagChip.unsaved({
super.key,
required this.name,
required this.color,
this.onTap,
this.onDeleted,
this.tooltip,
this.deleteTooltip,
this.dense = false,
});

final String name;

/// The fill, painted opaque. [Tag.color] for a stored tag.
final Color color;

/// Tapping the chip, usually to open what carries the tag.
final VoidCallback? onTap;

/// Removing the tag. The chip grows a close button when this is set.
final VoidCallback? onDeleted;

final String? tooltip;
final String? deleteTooltip;

/// The tighter type and padding for a list row, where several chips share
/// a line under the dive's stats.
final bool dense;

@override
Widget build(BuildContext context) {
final foreground = tagForegroundColor(color);
final borderRadius = BorderRadius.circular(dense ? 4 : 8);
final textStyle = Theme.of(context).textTheme.bodySmall?.copyWith(
color: foreground,
fontSize: dense ? 11 : null,
);

Widget chip = Material(
color: color,
borderRadius: borderRadius,
child: InkWell(
onTap: onTap,
borderRadius: borderRadius,
child: Padding(
// A removable chip drops its vertical padding and takes its height
// from the close button's tap target instead, which is how a
// Material chip with a delete button sizes itself. Padding on top
// of a 48 dp target would make the chip 56 dp tall.
padding: EdgeInsets.symmetric(
horizontal: dense ? 6 : 10,
vertical: onDeleted != null
? 0
: dense
? 2
: 4,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
name,
style: textStyle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (onDeleted != null) ...[
const SizedBox(width: 4),
_DeleteButton(
color: foreground,
// Material's own chips label their delete button from the
// same string, so a caller that has nothing better to say
// keeps the wording screen readers already know.
tooltip:
deleteTooltip ??
MaterialLocalizations.of(context).deleteButtonTooltip,
onPressed: onDeleted!,
),
],
],
),
),
),
);

if (tooltip != null) {
chip = Tooltip(message: tooltip!, child: chip);
}
return chip;
}
}

/// The close button of a removable chip.
///
/// The tap target is measured, not assumed: a bare icon with a splash radius
/// leaves a 16 dp target, and `VisualDensity.compact` pulls an [IconButton]
/// below its own constraints floor. The icon stays chip sized while the
/// button is constrained to the platform's minimum.
class _DeleteButton extends StatelessWidget {
const _DeleteButton({
required this.color,
required this.onPressed,
this.tooltip,
});

final Color color;
final VoidCallback onPressed;
final String? tooltip;

/// The floor for the tap target. A finger needs the 48 dp Material touch
/// minimum; a pointer is precise, and a 48 dp box inside a chip is out of
/// scale on a desktop, so it takes the 32 dp pointer minimum instead.
static double targetFor(TargetPlatform platform) => switch (platform) {
TargetPlatform.android ||
TargetPlatform.iOS ||
TargetPlatform.fuchsia => 48,
TargetPlatform.macOS ||
TargetPlatform.linux ||
TargetPlatform.windows => 32,
};

@override
Widget build(BuildContext context) {
final target = targetFor(Theme.of(context).platform);
return IconButton(
onPressed: onPressed,
tooltip: tooltip,
icon: Icon(Icons.close, size: 16, color: color),
iconSize: 16,
padding: EdgeInsets.zero,
// shrinkWrap so the constraints alone decide the box: the padded
// setting would add its own 48 dp on top of them.
style: IconButton.styleFrom(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
minimumSize: Size(target, target),
maximumSize: Size(target, target),
),
constraints: BoxConstraints(minWidth: target, minHeight: target),
);
}
}
Loading
Loading