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 be2efb99..952dcd0d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,13 @@ 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. ### Remove the Music Player Studio's Mini Bar half — 2026-08-31 - **It configured a widget that does not exist.** The studio's "Mini Bar" target let you pick a preset, drag components into an order and watch a live preview, then told you it was "set as active bottom bar". This fork replaced upstream's per-page mini players with the shared `UniversalPlayBar`, which reads none of `MusicSettings` — so none of it ever reached the screen. - Wiring it up instead was the other option and was rejected: the bar is shared across music, video, audiobooks and podcasts, so a music-only component order would make it rearrange itself whenever you switched source type. The one-shared-bar architecture is the fork's design; a music-only mini-player customiser contradicts 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'); + }); +}