diff --git a/app/lib/themes/card_overlay_colors.dart b/app/lib/themes/card_overlay_colors.dart new file mode 100644 index 00000000..73073fc1 --- /dev/null +++ b/app/lib/themes/card_overlay_colors.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +class CardOverlayColors { + CardOverlayColors._(); + + static const background = Color(0x99000000); + + static Color selection(ColorScheme colors) => + _foreground(colors.brightness == Brightness.light ? colors.primaryContainer : colors.primary); + + static Color favorite(ColorScheme colors) => + _foreground(colors.brightness == Brightness.light ? colors.errorContainer : colors.error); + + static Color _foreground(Color preferred) { + // A white cover is the brightest possible backing behind the dark scrim. + // High-contrast and e-ink schemes can supply dark container colors. + final brightestBackground = Color.alphaBlend(background, Colors.white); + final contrast = (preferred.computeLuminance() + 0.05) / (brightestBackground.computeLuminance() + 0.05); + + if (contrast >= 3) { + return preferred; + } + + return Colors.white; + } +} diff --git a/app/lib/widgets/library/acquisition_placeholder_card.dart b/app/lib/widgets/library/acquisition_placeholder_card.dart index 612fa5df..6d2368c9 100644 --- a/app/lib/widgets/library/acquisition_placeholder_card.dart +++ b/app/lib/widgets/library/acquisition_placeholder_card.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:papyrus/acquisition/acquisition_models.dart'; import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/themes/card_overlay_colors.dart'; import 'package:papyrus/widgets/library/acquisition_status_text.dart'; import 'package:papyrus/themes/app_motion.dart'; import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; @@ -187,7 +188,7 @@ class _SelectionIconButton extends StatelessWidget { final colorScheme = Theme.of(context).colorScheme; return Material( - color: Colors.black45, + color: CardOverlayColors.background, borderRadius: BorderRadius.circular(AppRadius.full), child: InkWell( borderRadius: BorderRadius.circular(AppRadius.full), @@ -197,7 +198,7 @@ class _SelectionIconButton extends StatelessWidget { child: Icon( selected ? Icons.check_circle : Icons.radio_button_unchecked, size: IconSizes.small, - color: selected ? colorScheme.primary : Colors.white, + color: selected ? CardOverlayColors.selection(colorScheme) : Colors.white, ), ), ), diff --git a/app/lib/widgets/library/book_card.dart b/app/lib/widgets/library/book_card.dart index 7ccd49bf..886394f9 100644 --- a/app/lib/widgets/library/book_card.dart +++ b/app/lib/widgets/library/book_card.dart @@ -4,6 +4,7 @@ import 'package:papyrus/models/book.dart'; import 'package:papyrus/providers/book_storage_status_controller.dart'; import 'package:papyrus/providers/enums/library_reading_status.dart'; import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/themes/card_overlay_colors.dart'; import 'package:papyrus/utils/book_actions.dart'; import 'package:papyrus/widgets/book/private_book_cover.dart'; import 'package:papyrus/widgets/library/acquisition_status_text.dart'; @@ -108,6 +109,7 @@ class _BookCardState extends State { margin: EdgeInsets.zero, clipBehavior: Clip.antiAlias, color: isUnavailable ? colorScheme.surfaceContainerLow : null, + shadowColor: colorScheme.brightness == Brightness.dark ? colorScheme.outline : null, child: InkWell( onTap: effectiveTap, child: Column( @@ -140,7 +142,7 @@ class _BookCardState extends State { child: _CardIconButton( tooltip: widget.isFavorite ? 'Remove from favorites' : 'Add to favorites', icon: widget.isFavorite ? Icons.favorite : Icons.favorite_border, - color: widget.isFavorite ? colorScheme.error : Colors.white, + color: widget.isFavorite ? CardOverlayColors.favorite(colorScheme) : Colors.white, onTap: widget.onToggleFavorite != null ? () => widget.onToggleFavorite!(widget.isFavorite) : null, @@ -157,7 +159,7 @@ class _BookCardState extends State { ? 'Deselect ${widget.book.title}' : 'Select ${widget.book.title}', icon: widget.isSelected ? Icons.check_circle : Icons.radio_button_unchecked, - color: widget.isSelected ? colorScheme.primary : Colors.white, + color: widget.isSelected ? CardOverlayColors.selection(colorScheme) : Colors.white, onTap: widget.onSelectToggle, ), ), @@ -466,7 +468,7 @@ class _CardIconButton extends StatelessWidget { container: true, button: true, child: Material( - color: Colors.black45, + color: CardOverlayColors.background, borderRadius: BorderRadius.circular(AppRadius.full), child: InkWell( borderRadius: BorderRadius.circular(AppRadius.full), diff --git a/app/lib/widgets/shelves/shelf_card.dart b/app/lib/widgets/shelves/shelf_card.dart index b6964f53..324c1388 100644 --- a/app/lib/widgets/shelves/shelf_card.dart +++ b/app/lib/widgets/shelves/shelf_card.dart @@ -59,6 +59,7 @@ class _ShelfCardState extends State { margin: EdgeInsets.zero, clipBehavior: Clip.antiAlias, elevation: AppElevation.level1, + shadowColor: colorScheme.brightness == Brightness.dark ? colorScheme.outline : null, child: InkWell( onTap: widget.onTap, onLongPress: widget.onLongPress, diff --git a/app/test/widgets/library/card_overlay_contrast_test.dart b/app/test/widgets/library/card_overlay_contrast_test.dart new file mode 100644 index 00000000..75007eca --- /dev/null +++ b/app/test/widgets/library/card_overlay_contrast_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/acquisition/acquisition_models.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:papyrus/widgets/library/acquisition_placeholder_card.dart'; +import 'package:papyrus/widgets/library/book_card.dart'; + +import '../../helpers/test_helpers.dart'; + +void main() { + final themes = { + 'light': AppTheme.light, + 'dark': AppTheme.dark, + 'high contrast light': AppTheme.highContrastLight, + 'high contrast dark': AppTheme.highContrastDark, + 'e-ink': AppTheme.eink, + }; + + final job = AcquisitionJob.fromJson({ + 'job_id': 'contrast-job', + 'title': 'Downloading book', + 'status': 'queued', + }); + + final cards = { + 'favorite': BookCard(book: buildTestBook(), isFavorite: true), + 'selected book': BookCard( + book: buildTestBook(), + isFavorite: false, + isSelectionMode: true, + isSelected: true, + ), + 'selected download': AcquisitionPlaceholderCard(job: job, isSelectionMode: true, isSelected: true), + 'unselected book': BookCard(book: buildTestBook(), isFavorite: false, isSelectionMode: true), + 'unselected download': AcquisitionPlaceholderCard(job: job, isSelectionMode: true), + }; + + for (final theme in themes.entries) { + for (final card in cards.entries) { + testWidgets('${card.key} stays visible over cover colors in ${theme.key}', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: theme.value, + home: Scaffold( + body: SizedBox(width: 200, height: 300, child: card.value), + ), + ), + ); + + final icon = switch (card.key) { + 'favorite' => Icons.favorite, + 'selected book' || 'selected download' => Icons.check_circle, + _ => Icons.radio_button_unchecked, + }; + + final finder = find.byIcon(icon); + final foreground = tester.widget(finder).color!; + final material = tester.widget(find.ancestor(of: finder, matching: find.byType(Material)).first); + + for (final cover in [Colors.white, Colors.black, Colors.red, Colors.green, Colors.blue]) { + final background = Color.alphaBlend(material.color!, cover); + final foregroundLuminance = foreground.computeLuminance(); + final backgroundLuminance = background.computeLuminance(); + final lighter = foregroundLuminance > backgroundLuminance ? foregroundLuminance : backgroundLuminance; + final darker = foregroundLuminance < backgroundLuminance ? foregroundLuminance : backgroundLuminance; + expect((lighter + 0.05) / (darker + 0.05), greaterThanOrEqualTo(3), reason: 'Cover: $cover'); + } + + expect(tester.takeException(), isNull); + }); + } + } +} diff --git a/docs/images/card-contrast/dark.png b/docs/images/card-contrast/dark.png new file mode 100644 index 00000000..f22ecc29 Binary files /dev/null and b/docs/images/card-contrast/dark.png differ diff --git a/docs/images/card-contrast/eink.png b/docs/images/card-contrast/eink.png new file mode 100644 index 00000000..1dff02e7 Binary files /dev/null and b/docs/images/card-contrast/eink.png differ diff --git a/docs/images/card-contrast/high-contrast-dark.png b/docs/images/card-contrast/high-contrast-dark.png new file mode 100644 index 00000000..951e3ed8 Binary files /dev/null and b/docs/images/card-contrast/high-contrast-dark.png differ diff --git a/docs/images/card-contrast/high-contrast-light.png b/docs/images/card-contrast/high-contrast-light.png new file mode 100644 index 00000000..db93db0e Binary files /dev/null and b/docs/images/card-contrast/high-contrast-light.png differ diff --git a/docs/images/card-contrast/light.png b/docs/images/card-contrast/light.png new file mode 100644 index 00000000..c1b02d36 Binary files /dev/null and b/docs/images/card-contrast/light.png differ