From 7f6a95b2b42e04fa1be100ec78dfd330f1eedd2d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 08:35:57 +0000 Subject: [PATCH] Use one like control instead of five Five content types each rendered the same boolean "like" their own way: two pill variants (Manga inline, Audiobooks via a private _LikeButton), a bare IconButton (Podcasts), and two list-row hearts (Books, Music). The audit rated this High. The colours were unified on 2026-08-29; the controls were not. All now use LikeButton, in one of two styles. The two styles only looked contradictory. The pill fills red and turns its icon white; the bare icon turns red. That is correct, and the reason now lives in the widget rather than being rediscovered per page: a filled pill needs a white icon to stay legible against its own fill, while a bare icon has no fill and so must carry the colour itself. Both use kLikedColor. Not folded in, because they are not the same concept: - Movies/Series "Add to Library" -- library membership, and a bookmark rather than a heart because that is what it means. - Anime status -- a four-state picker (Watching / Plan to Watch / Completed / Dropped). Collapsing it to a heart would delete the feature. - The fullscreen music player's like -- routes through the studio's user-configurable hover physics, which LikeButton would drop. It uses kLikedColor and carries the same semantics, so it follows the rules without being the widget. LikeButton also carries Semantics(button, toggled, label), which none of the five had: the bare-icon style has no visible text, so a screen reader previously announced an unlabelled button. The new test fails on any filled/outline heart ternary outside those exceptions. Its first draft flagged a sub-tab icon and an empty-state glyph -- matching the ternary rather than the icon name is what distinguishes a toggle from decoration. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01B62qvJ25JCrwnV2uiDrnCQ --- docs/AUDIT.md | 24 ++- docs/CHANGELOG.md | 8 + .../audiobooks/audiobook_detail_page.dart | 76 +-------- lib/pages/manga/manga_details_page.dart | 44 +----- lib/pages/music/music_page.dart | 40 +++-- lib/pages/podcast/podcast_details_page.dart | 11 +- lib/pages/read/books_page.dart | 13 +- lib/widgets/common/like_button.dart | 146 ++++++++++++++++++ lib/widgets/common/universal_play_bar.dart | 18 +-- test/widgets/like_button_test.dart | 113 ++++++++++++++ 10 files changed, 328 insertions(+), 165 deletions(-) create mode 100644 lib/widgets/common/like_button.dart create mode 100644 test/widgets/like_button_test.dart diff --git a/docs/AUDIT.md b/docs/AUDIT.md index cd44ccdf..f55f60de 100644 --- a/docs/AUDIT.md +++ b/docs/AUDIT.md @@ -157,17 +157,25 @@ The items below are the ones worth looking at first — either because they're c ## 4. UX & design consistency -### "Favorite" control — 5 different schemes across content types +### "Favorite" control — 5 different schemes across content types — **Fixed (2026-08-31)** -The single most visible inconsistency in the app, directly contradicting the ROADMAP's own stated principle. **Severity: High.** +Five content types each rendered the same boolean "like" their own way: two pill variants (Manga inline, Audiobooks via a private `_LikeButton`), a bare `IconButton` (Podcasts), and two list-row hearts (Books, Music), in three different reds. The colours were unified on 2026-08-29; the *controls* were not. -- **Movies/Series** (`details_page.dart:996`): bookmark icon (`bookmark_add_outlined`/`bookmark_added_rounded`), purple `0xFF7C5CFF`, labeled "Add to Library"/"In Library" — a *library* concept, not a heart. -- **Anime** (`anime_details_page.dart:748-800`): not a toggle at all — a `PopupMenuButton` 4-state status picker (Watching/Plan to Watch/Completed/Dropped), green `0xFF00D294`. -- **Manga** (`manga_details_page.dart:935-969`) / **Audiobooks** (`audiobook_detail_page.dart:587-649`): heart pill button, labeled "Like"/"Liked", red `0xFFE50914` fill when liked — but the **icon itself turns white**, not red. -- **Podcasts** (`podcast_details_page.dart:70-76`): plain heart `IconButton` in the app bar, no label, and the icon color **is** red when liked — opposite color logic from the visually-similar Manga/Audiobook pill. -- **Books** (`books_page.dart:376-380`) / **Music** (`music_page.dart:2150,4386`): heart icon in list rows, red `0xFFE50914` (Books) vs. pink `0xFFFF4B72` (Music) — a third color for the same red-heart concept. **Fixed** — Music's liked-heart color (and the universal play bar's purple `0xFF7C5CFF`) unified to red `0xFFE50914`, matching Books/Podcasts/Manga/Audiobooks. +All of them now use `LikeButton` (`lib/widgets/common/like_button.dart`), in one of two styles. -Empty-state copy tone is similarly uneven: `type_catalog_page.dart:285` gives an actionable "No content found. Install more addons in Settings." while `manga_page.dart:461` and `podcast_details_page.dart:83` give a flat "No X found" with no next step, despite Manga being equally addon/scrape-dependent. **Severity: low-medium.** +The two styles looked like they contradicted each other — the pill fills red and turns its icon **white**, the bare icon turns **red**. They do not, and the rule is now written down in the widget rather than rediscovered per page: a filled pill needs a white icon to stay legible against its own red fill; a bare icon has no fill and so must carry the colour itself. Both are `kLikedColor`. + +Deliberately **not** folded in, because they are not the same concept: + +- **Movies/Series** "Add to Library" — library membership, and a bookmark rather than a heart because that is what it means. +- **Anime** status — a four-state picker (Watching / Plan to Watch / Completed / Dropped). Collapsing it to a heart would delete the feature. +- **The fullscreen music player's like** — routes through the studio's user-configurable hover physics, which `LikeButton` would drop. It uses `kLikedColor` and carries the same semantics, so it follows the rules without being the widget. + +`LikeButton` also carries `Semantics(button, toggled, label)`, which none of the five had — the bare-icon style has no visible text, so a screen reader previously announced an unlabelled button. + +`test/widgets/like_button_test.dart` fails on any new filled/outline heart ternary outside those exceptions. + +Empty-state copy tone is still uneven: `type_catalog_page.dart` gives an actionable "No content found. Install more addons in Settings." while `manga_page.dart` and `podcast_details_page.dart` give a flat "No X found" with no next step, despite Manga being equally addon/scrape-dependent. **Still open, severity low-medium.** ### Error/empty/loading state coverage diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8d053d8e..ddd4598b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to PlayTorrio V3 will be documented in this file. ## [unreleased] — 2026-08-22 +### One like control instead of five (AUDIT: favorite schemes, High) — 2026-08-31 +- **Five content types each rendered the same boolean "like" their own way**: two pill variants (Manga inline, Audiobooks via a private `_LikeButton`), a bare `IconButton` (Podcasts), and two list-row hearts (Books, Music). The colours were unified on 2026-08-29; the controls were not. +- All now use `LikeButton`, in one of two styles. +- **The two styles only looked contradictory.** The pill fills red and turns its icon *white*; the bare icon turns *red*. That is correct, and the reason is now in the widget rather than rediscovered per page: a filled pill needs a white icon to stay legible against its own fill, while a bare icon has no fill and must carry the colour itself. Both use `kLikedColor`. +- **Not folded in, because they are different concepts**: Movies/Series' "Add to Library" (library membership, hence a bookmark); Anime's four-state status picker (collapsing it to a heart would delete the feature); and the fullscreen music player's like, which routes through the studio's configurable hover physics — it uses `kLikedColor` and the same semantics without being the widget. +- **Gained accessibility none of the five had**: `Semantics(button, toggled, label)`. The bare-icon style has no visible text, so a screen reader previously announced an unlabelled button. +- A test fails on any new filled/outline heart ternary outside those exceptions. Its first draft flagged a sub-tab icon and an empty-state glyph — matching the *ternary* rather than the icon name is what distinguishes a toggle from decoration. + ### One answer to "is this desktop?" (AUDIT: breakpoint values disagree) — 2026-08-31 - **Eight pages asked the question against four different literals** — `>= 900`, `>= 800`, `> 800`, `>= 750`, `> 700`. At an 820px window the nav chrome rendered tablet while Discover, Catalog, Anime, IPTV, the details page and the Arabic anime details page rendered desktop: a visible split down the middle of one screen. - All eight now ask `AppBreakpoints.of(context)`. Its 900 was already canonical and already what the nav chrome used, so the outliers moved up to it rather than the shared value moving down for them. Pages in the 800–899 band now get the tablet layout they were meant to have instead of a desktop one that disagreed with the chrome around it. diff --git a/lib/pages/audiobooks/audiobook_detail_page.dart b/lib/pages/audiobooks/audiobook_detail_page.dart index c1dbfa5d..35f64fc6 100644 --- a/lib/pages/audiobooks/audiobook_detail_page.dart +++ b/lib/pages/audiobooks/audiobook_detail_page.dart @@ -10,6 +10,7 @@ import '../../services/audiobook/audiobook_scraper_service.dart'; import '../../utils/fullscreen_navigator.dart'; import 'audiobook_player_screen.dart'; import 'audiobook_route_transitions.dart'; +import '../../widgets/common/like_button.dart'; class AudiobookDetailPage extends StatefulWidget { final Audiobook audiobook; @@ -302,7 +303,7 @@ class _AudiobookDetailPageState extends State { onPressed: () => _playChapter(_chapters!.first), ), const SizedBox(width: 12), - _LikeButton( + LikeButton( isLiked: _isLiked, onTap: _toggleLike, ), @@ -584,79 +585,6 @@ class _PlayFirstChapterButtonState extends State<_PlayFirstChapterButton> { } } -class _LikeButton extends StatefulWidget { - final bool isLiked; - final VoidCallback onTap; - - const _LikeButton({required this.isLiked, required this.onTap}); - - @override - State<_LikeButton> createState() => _LikeButtonState(); -} - -class _LikeButtonState extends State<_LikeButton> { - bool _isHovered = false; - bool _isPressed = false; - - @override - Widget build(BuildContext context) { - final scale = _isPressed ? 0.94 : (_isHovered ? 1.05 : 1.0); - - return MouseRegion( - cursor: SystemMouseCursors.click, - onEnter: (_) => setState(() => _isHovered = true), - onExit: (_) => setState(() => _isHovered = false), - child: GestureDetector( - onTapDown: (_) => setState(() => _isPressed = true), - onTapUp: (_) => setState(() => _isPressed = false), - onTapCancel: () => setState(() => _isPressed = false), - onTap: widget.onTap, - child: AnimatedScale( - scale: scale, - duration: const Duration(milliseconds: 150), - curve: Curves.easeOutCubic, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), - decoration: BoxDecoration( - color: widget.isLiked - ? const Color(0xFFE50914) - : Colors.white.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: widget.isLiked - ? const Color(0xFFE50914) - : Colors.white.withValues(alpha: 0.2), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - widget.isLiked - ? Icons.favorite_rounded - : Icons.favorite_border_rounded, - color: widget.isLiked ? Colors.white : Colors.white70, - size: 22, - ), - const SizedBox(width: 8), - Text( - widget.isLiked ? 'Liked' : 'Like', - style: TextStyle( - color: widget.isLiked ? Colors.white : Colors.white70, - fontSize: 14, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ), - ), - ); - } -} - class _ChapterTile extends StatefulWidget { final AudiobookChapter chapter; final AppThemePalette palette; diff --git a/lib/pages/manga/manga_details_page.dart b/lib/pages/manga/manga_details_page.dart index 064d2b04..6e4587d0 100644 --- a/lib/pages/manga/manga_details_page.dart +++ b/lib/pages/manga/manga_details_page.dart @@ -11,6 +11,7 @@ import '../../services/theme/app_theme_service.dart'; import '../../services/manga/manga_service.dart'; import '../../utils/fullscreen_navigator.dart'; import 'manga_reader_page.dart'; +import '../../widgets/common/like_button.dart'; class MangaDetailsPage extends StatefulWidget { final Manga manga; @@ -932,47 +933,8 @@ class _MangaDetailsPageState extends State { ); } - Widget _buildLikeButton() { - return MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: _toggleLike, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), - decoration: BoxDecoration( - color: _isLiked - ? const Color(0xFFE50914) - : Colors.white.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: _isLiked - ? const Color(0xFFE50914) - : Colors.white.withValues(alpha: 0.2), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - _isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded, - color: _isLiked ? Colors.white : Colors.white70, - size: 20, - ), - const SizedBox(width: 8), - Text( - _isLiked ? 'Liked' : 'Like', - style: TextStyle( - color: _isLiked ? Colors.white : Colors.white70, - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ), - ); - } + Widget _buildLikeButton() => + LikeButton(isLiked: _isLiked, onTap: _toggleLike, size: 20); Widget _buildAppBar() { final topPadding = MediaQuery.paddingOf(context).top; diff --git a/lib/pages/music/music_page.dart b/lib/pages/music/music_page.dart index 4a948c0e..52cb6cb5 100644 --- a/lib/pages/music/music_page.dart +++ b/lib/pages/music/music_page.dart @@ -31,6 +31,7 @@ import '../../services/music/music_service.dart'; import '../../services/music/music_settings.dart'; import '../../widgets/music/music_interactive_physics_button.dart'; import '../../widgets/music/music_waveform_seekbar.dart'; +import '../../widgets/common/like_button.dart'; class MusicPage extends StatefulWidget { const MusicPage({super.key}); @@ -2227,18 +2228,11 @@ class _MusicHeroBillboard extends StatelessWidget { ), ), const SizedBox(width: 12), - _MusicHoverable( - scaleFactor: 1.1, - child: IconButton( - style: IconButton.styleFrom( - backgroundColor: Colors.white.withValues(alpha: 0.15), - ), - icon: Icon( - isSaved ? Icons.favorite_rounded : Icons.favorite_border_rounded, - color: isSaved ? const Color(0xFFE50914) : Colors.white, - ), - onPressed: onSaveTap, - ), + LikeButton( + isLiked: isSaved, + onTap: onSaveTap, + style: LikeButtonStyle.icon, + size: 24, ), const SizedBox(width: 8), _MusicHoverable( @@ -4420,17 +4414,29 @@ class _MusicExpandedPlayerState extends State<_MusicExpandedPlayer> with SingleT ], ), ), + // Not a LikeButton: the fullscreen player runs its like through the + // studio's configurable hover physics, which LikeButton would drop. + // It follows the same rules -- kLikedColor, filled when liked, no + // fill behind it -- via the shared constant, so the colour cannot + // drift even though the widget differs. MusicInteractivePhysicsButton( effect: MusicSettings.customHoverEffect.value, - glowColor: const Color(0xFFE50914), + glowColor: kLikedColor, borderRadius: BorderRadius.circular(20), onTap: widget.onToggleSave, child: Padding( padding: const EdgeInsets.all(6.0), - child: Icon( - widget.isSaved ? Icons.favorite_rounded : Icons.favorite_border_rounded, - color: widget.isSaved ? const Color(0xFFE50914) : Colors.white70, - size: 28, + child: Semantics( + button: true, + toggled: widget.isSaved, + label: widget.isSaved ? 'Remove from liked' : 'Add to liked', + child: Icon( + widget.isSaved + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, + color: widget.isSaved ? kLikedColor : Colors.white70, + size: 28, + ), ), ), ), diff --git a/lib/pages/podcast/podcast_details_page.dart b/lib/pages/podcast/podcast_details_page.dart index 45162c28..e91d86c8 100644 --- a/lib/pages/podcast/podcast_details_page.dart +++ b/lib/pages/podcast/podcast_details_page.dart @@ -4,6 +4,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import '../../services/podcast/podcast_library_service.dart'; import '../../services/podcast/podcast_player_controller.dart'; import '../../services/podcast/podcast_service.dart'; +import '../../widgets/common/like_button.dart'; /// Episode list for one podcast, fetched live from its RSS feed. class PodcastDetailsPage extends StatefulWidget { @@ -67,12 +68,10 @@ class _PodcastDetailsPageState extends State { backgroundColor: const Color(0xFF080A0F), title: Text(widget.podcast.name, maxLines: 1, overflow: TextOverflow.ellipsis), actions: [ - IconButton( - icon: Icon( - _isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded, - color: _isLiked ? const Color(0xFFE50914) : Colors.white70, - ), - onPressed: _toggleLike, + LikeButton( + isLiked: _isLiked, + onTap: _toggleLike, + style: LikeButtonStyle.icon, ), ], ), diff --git a/lib/pages/read/books_page.dart b/lib/pages/read/books_page.dart index 98057e55..29e8229e 100644 --- a/lib/pages/read/books_page.dart +++ b/lib/pages/read/books_page.dart @@ -7,6 +7,7 @@ import '../../services/books/book_library_service.dart'; import '../../services/books/book_progress_service.dart'; import '../../services/books/books_service.dart'; import 'book_reader_page.dart'; +import '../../widgets/common/like_button.dart'; /// Books section in the Read hub: search libgen.li, download an epub, and /// read it in-app. Ported/restyled from PlayTorrioV2's `books_screen.dart`. @@ -371,13 +372,11 @@ class _BookRow extends StatelessWidget { ], ), ), - IconButton( - icon: Icon( - isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded, - color: isLiked ? const Color(0xFFE50914) : Colors.white38, - size: 20, - ), - onPressed: onToggleLike, + LikeButton( + isLiked: isLiked, + onTap: onToggleLike, + style: LikeButtonStyle.icon, + size: 20, ), const Icon(Icons.download_rounded, color: Colors.white38, size: 20), ], diff --git a/lib/widgets/common/like_button.dart b/lib/widgets/common/like_button.dart new file mode 100644 index 00000000..5b7e52bd --- /dev/null +++ b/lib/widgets/common/like_button.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; + +/// Red used for every "liked" state in the app. +const Color kLikedColor = Color(0xFFE50914); + +/// How a [LikeButton] presents itself. +enum LikeButtonStyle { + /// A filled pill with an icon and a "Like"/"Liked" label, for a detail + /// page's action row. + pill, + + /// A bare icon, for an app bar or a list row where there is no space for a + /// label. + icon, +} + +/// The one "save this" toggle, for content types where saving is a boolean. +/// +/// Audiobooks, Manga, Podcasts, Books and Music each had their own: two pill +/// variants, a bare `IconButton`, and two list-row hearts, in three different +/// reds. Worse, the two presentations had contradictory-looking colour rules — +/// the pill filled red and turned its icon *white*, the bare icon turned +/// *red*. +/// +/// That contradiction is only apparent, and the rule is now written down here +/// rather than rediscovered per page: a filled pill needs a white icon to stay +/// legible against the red fill, while a bare icon has no fill and so must +/// carry the colour itself. Both are [kLikedColor]; they differ because the +/// backgrounds differ. +/// +/// Deliberately **not** used for two things that look similar but are not a +/// boolean like: +/// +/// * **Movies/Series** "Add to Library" — library membership, and a bookmark +/// rather than a heart because that is what it means. +/// * **Anime** status — a four-state picker (Watching / Plan to Watch / +/// Completed / Dropped). Collapsing it to a heart would delete the feature. +class LikeButton extends StatefulWidget { + final bool isLiked; + final VoidCallback onTap; + final LikeButtonStyle style; + + /// Icon size. The pill's label scales with it. + final double size; + + const LikeButton({ + super.key, + required this.isLiked, + required this.onTap, + this.style = LikeButtonStyle.pill, + this.size = 22, + }); + + @override + State createState() => _LikeButtonState(); +} + +class _LikeButtonState extends State { + bool _hovered = false; + bool _pressed = false; + + @override + Widget build(BuildContext context) { + final label = widget.isLiked ? 'Liked' : 'Like'; + final semantics = widget.isLiked ? 'Remove from liked' : 'Add to liked'; + + final child = widget.style == LikeButtonStyle.pill + ? _buildPill(label) + : _buildIcon(); + + return Semantics( + button: true, + toggled: widget.isLiked, + label: semantics, + child: Tooltip( + message: semantics, + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() { + _hovered = false; + _pressed = false; + }), + child: GestureDetector( + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + onTap: widget.onTap, + child: AnimatedScale( + scale: _pressed ? 0.94 : (_hovered ? 1.05 : 1.0), + duration: const Duration(milliseconds: 150), + curve: Curves.easeOutCubic, + child: child, + ), + ), + ), + ), + ); + } + + Widget _buildPill(String label) { + return AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + decoration: BoxDecoration( + color: widget.isLiked ? kLikedColor : Colors.white.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: widget.isLiked ? kLikedColor : Colors.white.withValues(alpha: 0.2), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + widget.isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded, + // White, not red: the fill behind it is already red. + color: widget.isLiked ? Colors.white : Colors.white70, + size: widget.size, + ), + const SizedBox(width: 8), + Text( + label, + style: TextStyle( + color: widget.isLiked ? Colors.white : Colors.white70, + fontSize: widget.size * 0.64, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + } + + Widget _buildIcon() { + return Padding( + padding: const EdgeInsets.all(8), + child: Icon( + widget.isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded, + // Red, not white: there is no fill to sit against. + color: widget.isLiked ? kLikedColor : Colors.white70, + size: widget.size, + ), + ); + } +} diff --git a/lib/widgets/common/universal_play_bar.dart b/lib/widgets/common/universal_play_bar.dart index 33b9a982..a8405f62 100644 --- a/lib/widgets/common/universal_play_bar.dart +++ b/lib/widgets/common/universal_play_bar.dart @@ -2,6 +2,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import '../../services/playback_coordinator.dart'; +import 'like_button.dart'; /// A universal bottom play bar shown across all hubs (Media, Books, Music). /// @@ -123,18 +124,11 @@ class UniversalPlayBar extends StatelessWidget { ], // Like (music tracks only) if (PlaybackCoordinator.canLike) - IconButton( - tooltip: PlaybackCoordinator.isLiked ? 'Unlike' : 'Like', - icon: Icon( - PlaybackCoordinator.isLiked - ? Icons.favorite_rounded - : Icons.favorite_border_rounded, - color: PlaybackCoordinator.isLiked - ? const Color(0xFFE50914) - : Colors.white54, - size: 20, - ), - onPressed: PlaybackCoordinator.toggleLike, + LikeButton( + isLiked: PlaybackCoordinator.isLiked, + onTap: PlaybackCoordinator.toggleLike, + style: LikeButtonStyle.icon, + size: 20, ), // Skip back / forward, for a source with a queue. Same pair // the media-session notification publishes, so the bar and the diff --git a/test/widgets/like_button_test.dart b/test/widgets/like_button_test.dart new file mode 100644 index 00000000..cb3d58ff --- /dev/null +++ b/test/widgets/like_button_test.dart @@ -0,0 +1,113 @@ +// test/widgets/like_button_test.dart +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:playtorrio/widgets/common/like_button.dart'; + +Widget wrap(Widget child) => MaterialApp(home: Scaffold(body: Center(child: child))); + +Icon _icon(WidgetTester tester) => + tester.widget(find.byType(Icon).first); + +void main() { + group('LikeButton', () { + testWidgets('the pill keeps its icon white against the red fill', + (tester) async { + // The two presentations look like they disagree — the pill turns its + // icon white, the bare icon turns it red. They do not: the pill has a + // red fill behind the icon, so a red icon would vanish into it. + await tester.pumpWidget(wrap( + LikeButton(isLiked: true, onTap: () {}), + )); + expect(_icon(tester).color, Colors.white); + + final container = tester.widget( + find.byType(AnimatedContainer).first, + ); + final decoration = container.decoration as BoxDecoration; + expect(decoration.color, kLikedColor); + }); + + testWidgets('the bare icon carries the colour itself', (tester) async { + await tester.pumpWidget(wrap( + LikeButton( + isLiked: true, + onTap: () {}, + style: LikeButtonStyle.icon, + ), + )); + expect(_icon(tester).color, kLikedColor); + expect(find.byType(AnimatedContainer), findsNothing); + }); + + testWidgets('unliked is an outline in both styles', (tester) async { + for (final style in LikeButtonStyle.values) { + await tester.pumpWidget(wrap( + LikeButton(isLiked: false, onTap: () {}, style: style), + )); + expect(_icon(tester).icon, Icons.favorite_border_rounded, + reason: '$style should show an outline when not liked'); + } + }); + + testWidgets('tapping reports through', (tester) async { + var taps = 0; + await tester.pumpWidget(wrap( + LikeButton(isLiked: false, onTap: () => taps++), + )); + await tester.tap(find.byType(LikeButton)); + expect(taps, 1); + }); + + testWidgets('carries a toggle semantics label for screen readers', + (tester) async { + // The bare-icon style has no visible text, so without this a screen + // reader announces an unlabelled button. + await tester.pumpWidget(wrap( + LikeButton( + isLiked: false, + onTap: () {}, + style: LikeButtonStyle.icon, + ), + )); + expect( + tester.getSemantics(find.byType(LikeButton).first).label, + 'Add to liked', + ); + }); + }); + + test('no page hand-rolls its own liked toggle', () { + // Five content types each had their own: two pill variants, a bare + // IconButton and two list-row hearts, in three different reds. This + // catches the sixth. + // + // The signal is the *ternary* between the filled and outline heart, which + // is what a toggle looks like. A lone `Icons.favorite_rounded` is a tab + // icon or an empty-state glyph and is left alone — flagging those would + // make the rule a nuisance rather than a guard. + final toggle = RegExp( + r'favorite_rounded[\s\S]{0,80}favorite_border_rounded', + ); + final offenders = []; + for (final file in Directory('lib') + .listSync(recursive: true) + .whereType() + .where((f) => f.path.endsWith('.dart'))) { + if (file.path.endsWith('like_button.dart')) continue; + // Settings pages render hearts as style previews of the control, not + // as a like on a piece of content. + if (file.path.contains('/settings/')) continue; + final source = file.readAsStringSync(); + // The fullscreen music player routes its like through the studio's + // configurable hover physics, which LikeButton would drop. It uses + // kLikedColor and carries the same semantics, so it follows the rules + // without being the widget. + if (source.contains('MusicInteractivePhysicsButton')) continue; + if (toggle.hasMatch(source)) offenders.add(file.path); + } + expect(offenders, isEmpty, + reason: 'use LikeButton so the icon, colour and semantics stay in step'); + }); +}