From ae0256b006814f4c2cf6fb28b67002e77c4f0650 Mon Sep 17 00:00:00 2001 From: Julian Dice <19397727+windoze95@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:08:19 -0500 Subject: [PATCH] Refine the color system: violet base with deliberate lime signal moments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full audit of every color pairing in the app (WCAG-verified), applying one doctrine: violet is the calm brand base — primaryColor for fills, borders, and interactive surfaces; accentColor as the only violet used for small text (primary tops out at ~4.1:1 and failed the 4.5:1 floor in 20+ places). The lime successColor is the loud signal that breaks the violet silence, reserved for genuine resume/ready moments and never used as ambiance or per-row state. Legibility fixes: - AppStatusPill gains an onImage variant (opaque dark backing) — the spotlight CONTINUE WATCHING pill was invisible over thumbnails; it is now Home's green moment. Discover's FOR YOU pill gets the same backing. Default pill ink is accentColor (the primary default failed AA on every surface). - Player: the controls scrim now dims the center of the frame (white play/skip controls had zero scrim over bright video); scrub-preview timestamp accent+bold instead of primary; hold-to-seek label white; 360p badge on black87; resume banner is the player's green moment (lime history icon + timestamp, accent Start over). - Theme-level violet-as-text: text buttons, snackbar actions, floating input labels -> accentColor; onSecondary dark ink; eyebrows and onboarding numerals -> accentColor. - Fallback avatar initials unified: primary 20% fill + accent letter (was primary-on-primary at 2.4-3.7:1 in five drifting recipes). - progressBackground merged into cardHoverColor so the violet fill clears the 3:1 component floor; delete-profile confirm uses the onError ink (white on errorColor was 2.6:1); unsubscribe border alpha raised; in-button busy spinners get a legible explicit color. Green re-homed as signal: - Watch-progress slivers on thumbnails + the spotlight resume bar use a new watchProgressColor (= successColor): green owns "how far you are" while the interactive player scrubber stays violet. - Watched checkmarks demoted from lime to muted metadata (dozens of green dots per list diluted the signal); channel membership pill demoted (state, not a moment) while a Resume button gets a lime play icon; settings shows one green per event; add-profile gains a PROFILE FOUND lime pill when a lookup succeeds. - The 6%-alpha lime ambient glow is repainted violet — green is never wallpaper. Both backdrop glows now derive from theme tokens. Token hygiene: infoColor + violetDeepColor tokens replace stranded hexes in the badge maps and fallback gradients (amber near-duplicate merged into warningColor); selectionFill shared by nav bar, rail, chips, and the desktop sidebar; dialogs and sheets standardize on cardColor at the theme with all ten per-call overrides removed. Test: the import happy-path test scrolls suggestions into view before tapping (the new pill shifts them below the test viewport). Co-Authored-By: Claude Fable 5 --- lib/config/routes.dart | 4 +- lib/config/theme.dart | 62 +++++++++++++++-------- lib/screens/add_profile_screen.dart | 18 ++++++- lib/screens/channel_detail_screen.dart | 28 +++++++--- lib/screens/discover_screen.dart | 6 +-- lib/screens/downloads_screen.dart | 1 - lib/screens/home_screen.dart | 6 ++- lib/screens/library_screen.dart | 2 - lib/screens/profile_picker_screen.dart | 4 +- lib/screens/search_screen.dart | 2 +- lib/screens/settings_screen.dart | 14 +++-- lib/screens/video_player_screen.dart | 45 +++++++++++----- lib/widgets/app_ui.dart | 35 +++++++++---- lib/widgets/channel_card.dart | 4 +- lib/widgets/cinematic_banner.dart | 2 +- lib/widgets/content_type_badge.dart | 8 +-- lib/widgets/pin_entry_dialog.dart | 1 - lib/widgets/profile_avatar.dart | 4 +- lib/widgets/queue_action.dart | 1 - lib/widgets/skip_control.dart | 7 +-- lib/widgets/unplayable_badge.dart | 8 +-- lib/widgets/video_card.dart | 1 + lib/widgets/video_list_tile.dart | 7 ++- test/widgets/add_profile_screen_test.dart | 2 + 24 files changed, 181 insertions(+), 91 deletions(-) diff --git a/lib/config/routes.dart b/lib/config/routes.dart index ea30595..60689e4 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -400,9 +400,7 @@ class _SidebarDestination extends StatelessWidget { @override Widget build(BuildContext context) { return Material( - color: selected - ? NullFeedTheme.primaryColor.withValues(alpha: 0.13) - : Colors.transparent, + color: selected ? NullFeedTheme.selectionFill : Colors.transparent, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(13)), clipBehavior: Clip.antiAlias, child: InkWell( diff --git a/lib/config/theme.dart b/lib/config/theme.dart index a0a5de6..95e5d3b 100644 --- a/lib/config/theme.dart +++ b/lib/config/theme.dart @@ -1,8 +1,17 @@ import 'package:flutter/material.dart'; -/// NullFeed's visual language: quiet, cinematic surfaces with a purple -/// brand accent for actions and interactive state, plus a bright lime -/// "ready" signal reserved for success states (downloaded, connected). +/// NullFeed's visual language: quiet, cinematic surfaces with a violet +/// brand base and one loud lime signal. +/// +/// The violet has two jobs: [primaryColor] paints fills, borders, and +/// interactive surfaces; [accentColor] is violet-as-text — the only violet +/// legible as small text on the dark surfaces (primary tops out at ~4.1:1). +/// [successColor] is intentionally loud and reserved for signal moments — +/// resume/ready cues, watch progress, offline pins, connection confirmed — +/// never ambiance, and never states that appear on every row of a list +/// (on the downloads screen every row is saved, so "Saved" stays muted). +/// Over imagery or video, use pure Colors.white on a solid dark scrim or +/// black chip; textPrimary is for app surfaces. class NullFeedTheme { NullFeedTheme._(); @@ -21,9 +30,18 @@ class NullFeedTheme { static const Color errorColor = Color(0xFFFF7185); static const Color successColor = Color(0xFFB8FF5C); static const Color warningColor = Color(0xFFFFCC66); - static const Color progressBackground = Color(0xFF303B49); + static const Color infoColor = Color(0xFF4DB6AC); + static const Color violetDeepColor = Color(0xFF171526); + static const Color progressBackground = cardHoverColor; static const Color progressForeground = primaryColor; + /// Passive "how far you are" bars (thumbnail slivers, resume bars) — the + /// lime signal. The interactive player scrubber keeps [progressForeground]. + static const Color watchProgressColor = successColor; + + /// Shared fill for selected navigation/chip states. + static final Color selectionFill = primaryColor.withValues(alpha: 0.16); + static const LinearGradient ambientGradient = LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, @@ -38,7 +56,7 @@ class NullFeedTheme { surface: surfaceColor, error: errorColor, onPrimary: Colors.white, - onSecondary: Colors.white, + onSecondary: Color(0xFF1B0E33), onSurface: textPrimary, onError: Color(0xFF210006), outline: borderColor, @@ -85,7 +103,7 @@ class NullFeedTheme { height: 68, backgroundColor: surfaceColor.withValues(alpha: 0.96), surfaceTintColor: Colors.transparent, - indicatorColor: primaryColor.withValues(alpha: 0.16), + indicatorColor: selectionFill, indicatorShape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(14), ), @@ -110,16 +128,16 @@ class NullFeedTheme { ); }), ), - navigationRailTheme: const NavigationRailThemeData( + navigationRailTheme: NavigationRailThemeData( backgroundColor: Colors.transparent, - indicatorColor: Color(0x297C4DFF), - selectedIconTheme: IconThemeData(color: primaryColor), - unselectedIconTheme: IconThemeData(color: textMuted), - selectedLabelTextStyle: TextStyle( + indicatorColor: selectionFill, + selectedIconTheme: const IconThemeData(color: primaryColor), + unselectedIconTheme: const IconThemeData(color: textMuted), + selectedLabelTextStyle: const TextStyle( color: textPrimary, fontWeight: FontWeight.w700, ), - unselectedLabelTextStyle: TextStyle(color: textMuted), + unselectedLabelTextStyle: const TextStyle(color: textMuted), ), textTheme: base.textTheme.copyWith( displaySmall: const TextStyle( @@ -232,7 +250,7 @@ class NullFeedTheme { ), textButtonTheme: TextButtonThemeData( style: TextButton.styleFrom( - foregroundColor: primaryColor, + foregroundColor: accentColor, minimumSize: const Size(44, 44), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), @@ -253,7 +271,7 @@ class NullFeedTheme { filled: true, fillColor: cardColor, labelStyle: const TextStyle(color: textSecondary), - floatingLabelStyle: const TextStyle(color: primaryColor), + floatingLabelStyle: const TextStyle(color: accentColor), prefixIconColor: textMuted, suffixIconColor: textMuted, border: OutlineInputBorder( @@ -280,7 +298,7 @@ class NullFeedTheme { ), chipTheme: base.chipTheme.copyWith( backgroundColor: cardColor, - selectedColor: primaryColor.withValues(alpha: 0.16), + selectedColor: selectionFill, disabledColor: cardColor, side: const BorderSide(color: borderColor), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), @@ -292,19 +310,19 @@ class NullFeedTheme { padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), ), dialogTheme: const DialogThemeData( - backgroundColor: surfaceColor, + backgroundColor: cardColor, surfaceTintColor: Colors.transparent, shape: RoundedRectangleBorder( side: BorderSide(color: borderColor), borderRadius: BorderRadius.all(Radius.circular(24)), ), ), - bottomSheetTheme: const BottomSheetThemeData( - backgroundColor: surfaceColor, + bottomSheetTheme: BottomSheetThemeData( + backgroundColor: cardColor, surfaceTintColor: Colors.transparent, - modalBackgroundColor: surfaceColor, - modalBarrierColor: Color(0xB307090D), - shape: RoundedRectangleBorder( + modalBackgroundColor: cardColor, + modalBarrierColor: backgroundColor.withValues(alpha: 0.7), + shape: const RoundedRectangleBorder( side: BorderSide(color: borderColor), borderRadius: BorderRadius.vertical(top: Radius.circular(28)), ), @@ -315,7 +333,7 @@ class NullFeedTheme { color: textPrimary, fontWeight: FontWeight.w600, ), - actionTextColor: primaryColor, + actionTextColor: accentColor, shape: RoundedRectangleBorder( side: const BorderSide(color: borderColor), borderRadius: BorderRadius.circular(14), diff --git a/lib/screens/add_profile_screen.dart b/lib/screens/add_profile_screen.dart index 575167c..b174294 100644 --- a/lib/screens/add_profile_screen.dart +++ b/lib/screens/add_profile_screen.dart @@ -7,6 +7,7 @@ import '../models/youtube_import.dart'; import '../providers/auth_provider.dart'; import '../services/api_service.dart'; import '../services/storage_service.dart'; +import '../widgets/app_ui.dart'; import '../widgets/profile_avatar.dart'; /// Full-screen profile creation flow. @@ -304,7 +305,10 @@ class _AddProfileScreenState extends ConsumerState { ? const SizedBox( width: 16, height: 16, - child: CircularProgressIndicator(strokeWidth: 2), + child: CircularProgressIndicator( + strokeWidth: 2, + color: NullFeedTheme.textSecondary, + ), ) : const Text('Lookup'), ), @@ -322,6 +326,13 @@ class _AddProfileScreenState extends ConsumerState { ], if (_resolvedProfile != null) ...[ const SizedBox(height: 16), + // The flow's earned green moment: the lookup succeeded. + const AppStatusPill( + label: 'PROFILE FOUND', + icon: Icons.check_circle_rounded, + color: NullFeedTheme.successColor, + ), + const SizedBox(height: 10), _IdentityPreviewCard( profile: _resolvedProfile!, onClear: _creating ? null : _clearImport, @@ -380,7 +391,10 @@ class _AddProfileScreenState extends ConsumerState { const SizedBox( width: 16, height: 16, - child: CircularProgressIndicator(strokeWidth: 2), + child: CircularProgressIndicator( + strokeWidth: 2, + color: NullFeedTheme.textSecondary, + ), ), const SizedBox(width: 12), Text(_busyStatus ?? 'Creating profile…'), diff --git a/lib/screens/channel_detail_screen.dart b/lib/screens/channel_detail_screen.dart index fe802ad..1dd0d4b 100644 --- a/lib/screens/channel_detail_screen.dart +++ b/lib/screens/channel_detail_screen.dart @@ -183,7 +183,10 @@ class _ChannelDetailScreenState extends ConsumerState { SizedBox( width: 18, height: 18, - child: CircularProgressIndicator(strokeWidth: 2), + child: CircularProgressIndicator( + strokeWidth: 2, + color: NullFeedTheme.textSecondary, + ), ), SizedBox(width: 10), Text('Updating…'), @@ -206,7 +209,7 @@ class _ChannelDetailScreenState extends ConsumerState { style: OutlinedButton.styleFrom( foregroundColor: NullFeedTheme.errorColor, side: BorderSide( - color: NullFeedTheme.errorColor.withValues(alpha: 0.5), + color: NullFeedTheme.errorColor.withValues(alpha: 0.7), ), ), child: content, @@ -354,7 +357,7 @@ class _ChannelDetailScreenState extends ConsumerState { style: const TextStyle( fontSize: 28, fontWeight: FontWeight.bold, - color: NullFeedTheme.primaryColor, + color: NullFeedTheme.accentColor, ), ), ), @@ -380,6 +383,9 @@ class _ChannelDetailScreenState extends ConsumerState { ).textTheme.bodyMedium, ), const SizedBox(height: 8), + // Membership is persistent state, not a + // signal moment — no lime, and "not in + // library" is neutral, not a warning. AppStatusPill( label: channel.isSubscribed ? 'IN YOUR LIBRARY' @@ -388,8 +394,8 @@ class _ChannelDetailScreenState extends ConsumerState { ? Icons.check_circle_rounded : Icons.add_circle_outline_rounded, color: channel.isSubscribed - ? NullFeedTheme.successColor - : NullFeedTheme.warningColor, + ? NullFeedTheme.textSecondary + : NullFeedTheme.textMuted, ), ], ), @@ -416,7 +422,15 @@ class _ChannelDetailScreenState extends ConsumerState { ); invalidateFeedProviders(ref); }, - icon: const Icon(Icons.play_arrow, size: 24), + // A lime icon marks the resume signal; "Play + // latest" stays plain violet. + icon: Icon( + Icons.play_arrow, + size: 24, + color: target.label == 'Resume' + ? NullFeedTheme.successColor + : null, + ), label: Text(target.label), ), ); @@ -592,7 +606,6 @@ class _ChannelDetailScreenState extends ConsumerState { showModalBottomSheet( context: context, - backgroundColor: NullFeedTheme.cardColor, builder: (sheetContext) => StatefulBuilder( builder: (context, setSheetState) => SafeArea( child: Column( @@ -661,7 +674,6 @@ class _ChannelDetailScreenState extends ConsumerState { void _showVideoMenu(Video video) { showModalBottomSheet( context: context, - backgroundColor: NullFeedTheme.cardColor, builder: (sheetContext) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 9ecb0c1..395b6eb 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -301,7 +301,7 @@ class _RecommendationCardState extends State<_RecommendationCard> { child: AppStatusPill( label: 'FOR YOU', icon: Icons.auto_awesome_rounded, - color: NullFeedTheme.accentColor, + onImage: true, ), ), ], @@ -378,7 +378,7 @@ class _RecommendationAvatar extends StatelessWidget { width: 54, height: 54, decoration: BoxDecoration( - color: NullFeedTheme.elevatedSurfaceColor, + color: NullFeedTheme.primaryColor.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(17), border: Border.all(color: NullFeedTheme.surfaceColor, width: 3), image: url == null @@ -394,7 +394,7 @@ class _RecommendationAvatar extends StatelessWidget { ? Text( rec.channelName.isEmpty ? '?' : rec.channelName[0].toUpperCase(), style: const TextStyle( - color: NullFeedTheme.primaryColor, + color: NullFeedTheme.accentColor, fontSize: 20, fontWeight: FontWeight.w800, ), diff --git a/lib/screens/downloads_screen.dart b/lib/screens/downloads_screen.dart index 7ace31d..ed543d3 100644 --- a/lib/screens/downloads_screen.dart +++ b/lib/screens/downloads_screen.dart @@ -32,7 +32,6 @@ class _DownloadsScreenState extends ConsumerState { final confirmed = await showDialog( context: context, builder: (dialogContext) => AlertDialog( - backgroundColor: NullFeedTheme.cardColor, title: const Text('Delete from this device?'), content: Text( title.isEmpty diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index a779c0c..d1dded4 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -304,6 +304,8 @@ class _SpotlightCard extends StatelessWidget { ? Icons.history_rounded : Icons.auto_awesome_rounded, label: canResume ? 'CONTINUE WATCHING' : 'READY TO WATCH', + color: NullFeedTheme.successColor, + onImage: true, ), const SizedBox(height: 13), Text( @@ -330,6 +332,7 @@ class _SpotlightCard extends StatelessWidget { child: NullFeedProgressBar( progress: item.video.watchProgress, height: 4, + foregroundColor: NullFeedTheme.watchProgressColor, ), ), ], @@ -381,7 +384,7 @@ class _SpotlightFallback extends StatelessWidget { colors: [ NullFeedTheme.elevatedSurfaceColor, NullFeedTheme.cardColor, - Color(0xFF19162B), + NullFeedTheme.violetDeepColor, ], ), ), @@ -596,7 +599,6 @@ class _RecommendationRailCard extends StatelessWidget { const AppStatusPill( label: 'WHY IT FITS', icon: Icons.auto_awesome_rounded, - color: NullFeedTheme.accentColor, ), const SizedBox(height: 9), Expanded( diff --git a/lib/screens/library_screen.dart b/lib/screens/library_screen.dart index cf73493..18bb6f3 100644 --- a/lib/screens/library_screen.dart +++ b/lib/screens/library_screen.dart @@ -131,7 +131,6 @@ class _LibraryScreenState extends ConsumerState { final confirmed = await showDialog( context: context, builder: (dialogContext) => AlertDialog( - backgroundColor: NullFeedTheme.cardColor, title: Text('Unsubscribe from ${channel.name}?'), content: const Text( 'This removes the channel from this profile\'s Library. New uploads ' @@ -376,7 +375,6 @@ class _SubscribeDialogState extends ConsumerState<_SubscribeDialog> { @override Widget build(BuildContext context) { return AlertDialog( - backgroundColor: NullFeedTheme.cardColor, title: const Text('Add a YouTube channel'), content: Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/screens/profile_picker_screen.dart b/lib/screens/profile_picker_screen.dart index 6641345..a2bb61c 100644 --- a/lib/screens/profile_picker_screen.dart +++ b/lib/screens/profile_picker_screen.dart @@ -224,7 +224,6 @@ class _ProfilePickerScreenState extends ConsumerState { final serverUrl = ref.read(settingsProvider).serverUrl; showModalBottomSheet( context: context, - backgroundColor: NullFeedTheme.surfaceColor, builder: (sheetContext) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, @@ -278,7 +277,6 @@ class _ProfilePickerScreenState extends ConsumerState { await showModalBottomSheet( context: context, isScrollControlled: true, - backgroundColor: NullFeedTheme.surfaceColor, builder: (sheetContext) => _EditProfileSheet(profile: profile, managementToken: token), ); @@ -292,7 +290,6 @@ class _ProfilePickerScreenState extends ConsumerState { await showDialog( context: context, builder: (dialogContext) => AlertDialog( - backgroundColor: NullFeedTheme.cardColor, title: const Text('Delete profile?'), content: Text( 'Delete ${profile.displayName}? Their subscriptions and watch ' @@ -306,6 +303,7 @@ class _ProfilePickerScreenState extends ConsumerState { ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: NullFeedTheme.errorColor, + foregroundColor: Theme.of(dialogContext).colorScheme.onError, ), onPressed: () => Navigator.of(dialogContext).pop(true), child: const Text('Delete'), diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 4d23eec..5a1c995 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -293,7 +293,7 @@ class _ChannelChip extends StatelessWidget { style: const TextStyle( fontSize: 24, fontWeight: FontWeight.bold, - color: NullFeedTheme.primaryColor, + color: NullFeedTheme.accentColor, ), ), ), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index b0e8dd9..e33082e 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -502,8 +502,10 @@ class _SettingsScreenState extends ConsumerState { _draftReachable! ? Icons.check_circle_rounded : Icons.error_rounded, + // The "Connection confirmed" pill is this event's one + // green; the suffix check is a quiet echo. color: _draftReachable! - ? NullFeedTheme.successColor + ? NullFeedTheme.textSecondary : NullFeedTheme.errorColor, ), ), @@ -532,7 +534,10 @@ class _SettingsScreenState extends ConsumerState { ? const SizedBox( width: 17, height: 17, - child: CircularProgressIndicator(strokeWidth: 2), + child: CircularProgressIndicator( + strokeWidth: 2, + color: NullFeedTheme.textSecondary, + ), ) : const Icon(Icons.swap_horiz_rounded, size: 18), label: Text( @@ -895,7 +900,10 @@ class _YoutubeCookiesSectionState ? const SizedBox( height: 16, width: 16, - child: CircularProgressIndicator(strokeWidth: 2), + child: CircularProgressIndicator( + strokeWidth: 2, + color: NullFeedTheme.textSecondary, + ), ) : const Text('Save'), ), diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 4986c08..d7fe40a 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -1232,7 +1232,7 @@ class _VideoPlayerScreenState extends ConsumerState { children: [ const Icon( Icons.error_outline, - color: Colors.red, + color: NullFeedTheme.errorColor, size: 48, ), const SizedBox(height: 16), @@ -1304,13 +1304,13 @@ class _VideoPlayerScreenState extends ConsumerState { vertical: 4, ), decoration: BoxDecoration( - color: Colors.black54, + color: Colors.black.withValues(alpha: 0.87), borderRadius: BorderRadius.circular(4), ), child: const Text( '360p', style: TextStyle( - color: Colors.white70, + color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold, ), @@ -1516,17 +1516,33 @@ class _ResumeBanner extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.history, color: Colors.white70, size: 18), + // The resume signal — the player's one lime moment. + const Icon( + Icons.history, + color: NullFeedTheme.successColor, + size: 18, + ), const SizedBox(width: 8), - Text( - 'Resuming at ${_formatTimestamp(position)}', + Text.rich( + TextSpan( + text: 'Resuming at ', + children: [ + TextSpan( + text: _formatTimestamp(position), + style: const TextStyle( + color: NullFeedTheme.successColor, + fontWeight: FontWeight.w700, + ), + ), + ], + ), style: const TextStyle(color: Colors.white, fontSize: 14), ), const SizedBox(width: 8), TextButton( onPressed: onRestart, style: TextButton.styleFrom( - foregroundColor: NullFeedTheme.primaryColor, + foregroundColor: NullFeedTheme.accentColor, minimumSize: const Size(0, 44), padding: const EdgeInsets.symmetric(horizontal: 12), ), @@ -1616,14 +1632,16 @@ class _ControlsOverlay extends StatelessWidget { @override Widget build(BuildContext context) { return Container( + // The middle stops dim too: the center play/skip controls are white and + // must survive a bright frame (sky, snow) — never leave them unscrimmed. decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.black54, - Colors.transparent, - Colors.transparent, + Colors.black45, + Colors.black45, Colors.black54, ], stops: [0.0, 0.3, 0.7, 1.0], @@ -1797,15 +1815,18 @@ class _ControlsOverlay extends StatelessWidget { _formatTimestamp(position), style: TextStyle( color: preview != null - ? NullFeedTheme.primaryColor - : Colors.white70, + ? NullFeedTheme.accentColor + : Colors.white, + fontWeight: preview != null + ? FontWeight.w700 + : FontWeight.w400, fontSize: 12, ), ), Text( _formatTimestamp(duration), style: const TextStyle( - color: Colors.white70, + color: Colors.white, fontSize: 12, ), ), diff --git a/lib/widgets/app_ui.dart b/lib/widgets/app_ui.dart index 4682583..482c53f 100644 --- a/lib/widgets/app_ui.dart +++ b/lib/widgets/app_ui.dart @@ -38,8 +38,11 @@ class _AmbientGlowPainter extends CustomPainter { void paint(Canvas canvas, Size size) { final primaryPaint = Paint() ..shader = - const RadialGradient( - colors: [Color(0x187C4DFF), Color(0x007C4DFF)], + RadialGradient( + colors: [ + NullFeedTheme.primaryColor.withValues(alpha: 0.09), + NullFeedTheme.primaryColor.withValues(alpha: 0), + ], ).createShader( Rect.fromCircle( center: Offset(size.width * 0.84, size.height * 0.06), @@ -54,8 +57,11 @@ class _AmbientGlowPainter extends CustomPainter { final accentPaint = Paint() ..shader = - const RadialGradient( - colors: [Color(0x10B8FF5C), Color(0x00B8FF5C)], + RadialGradient( + colors: [ + NullFeedTheme.accentColor.withValues(alpha: 0.06), + NullFeedTheme.accentColor.withValues(alpha: 0), + ], ).createShader( Rect.fromCircle( center: Offset(size.width * 0.08, size.height * 0.72), @@ -169,7 +175,7 @@ class PageIntro extends StatelessWidget { Text( eyebrow.toUpperCase(), style: const TextStyle( - color: NullFeedTheme.primaryColor, + color: NullFeedTheme.accentColor, fontSize: 11, fontWeight: FontWeight.w800, letterSpacing: 1.4, @@ -326,7 +332,7 @@ class EmptyStatePanel extends StatelessWidget { Text( eyebrow!.toUpperCase(), style: const TextStyle( - color: NullFeedTheme.primaryColor, + color: NullFeedTheme.accentColor, fontSize: 10, fontWeight: FontWeight.w800, letterSpacing: 1.3, @@ -400,7 +406,7 @@ class _OnboardingStep extends StatelessWidget { child: Text( '$number', style: const TextStyle( - color: NullFeedTheme.primaryColor, + color: NullFeedTheme.accentColor, fontSize: 12, fontWeight: FontWeight.w800, ), @@ -423,21 +429,30 @@ class AppStatusPill extends StatelessWidget { super.key, required this.label, this.icon, - this.color = NullFeedTheme.primaryColor, + this.color = NullFeedTheme.accentColor, + this.onImage = false, }); final String label; final IconData? icon; final Color color; + /// Alpha-tinted fills disappear over arbitrary imagery. When the pill sits + /// on a thumbnail or artwork, this switches to an opaque dark backing. + final bool onImage; + @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( - color: color.withValues(alpha: 0.12), + color: onImage + ? NullFeedTheme.backgroundColor + : color.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(999), - border: Border.all(color: color.withValues(alpha: 0.24)), + border: Border.all( + color: color.withValues(alpha: onImage ? 0.45 : 0.24), + ), ), child: Row( mainAxisSize: MainAxisSize.min, diff --git a/lib/widgets/channel_card.dart b/lib/widgets/channel_card.dart index 0e65333..6792634 100644 --- a/lib/widgets/channel_card.dart +++ b/lib/widgets/channel_card.dart @@ -91,13 +91,13 @@ class _ChannelCardState extends State { CircleAvatar( radius: 18, backgroundColor: NullFeedTheme.primaryColor - .withValues(alpha: 0.16), + .withValues(alpha: 0.2), child: Text( widget.channel.name.isNotEmpty ? widget.channel.name[0].toUpperCase() : '?', style: const TextStyle( - color: NullFeedTheme.primaryColor, + color: NullFeedTheme.accentColor, fontWeight: FontWeight.bold, fontSize: 14, ), diff --git a/lib/widgets/cinematic_banner.dart b/lib/widgets/cinematic_banner.dart index b843b78..5425a2f 100644 --- a/lib/widgets/cinematic_banner.dart +++ b/lib/widgets/cinematic_banner.dart @@ -125,7 +125,7 @@ class _BannerFallback extends StatelessWidget { colors: [ NullFeedTheme.elevatedSurfaceColor, NullFeedTheme.cardColor, - Color(0xFF171526), + NullFeedTheme.violetDeepColor, ], ), ), diff --git a/lib/widgets/content_type_badge.dart b/lib/widgets/content_type_badge.dart index 51c5965..97ed018 100644 --- a/lib/widgets/content_type_badge.dart +++ b/lib/widgets/content_type_badge.dart @@ -6,12 +6,12 @@ import '../models/video.dart'; /// background so it reads like the other thumbnail badges; the icon carries the /// color. Reuses the unplayable-badge colors where types overlap. Color contentTypeColor(ContentType type) => switch (type) { - ContentType.short => NullFeedTheme.primaryColor, + ContentType.short => NullFeedTheme.accentColor, ContentType.live => NullFeedTheme.errorColor, - ContentType.premiere => const Color(0xFF4DB6AC), + ContentType.premiere => NullFeedTheme.infoColor, ContentType.ageRestricted => NullFeedTheme.errorColor, - ContentType.membersOnly => const Color(0xFFFFB74D), - ContentType.premium => NullFeedTheme.primaryColor, + ContentType.membersOnly => NullFeedTheme.warningColor, + ContentType.premium => NullFeedTheme.accentColor, ContentType.regular || ContentType.unknown => NullFeedTheme.textMuted, }; diff --git a/lib/widgets/pin_entry_dialog.dart b/lib/widgets/pin_entry_dialog.dart index e98392b..930239d 100644 --- a/lib/widgets/pin_entry_dialog.dart +++ b/lib/widgets/pin_entry_dialog.dart @@ -82,7 +82,6 @@ class _PinEntryDialogState extends State @override Widget build(BuildContext context) { return AlertDialog( - backgroundColor: NullFeedTheme.cardColor, title: Text(widget.title), content: Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/widgets/profile_avatar.dart b/lib/widgets/profile_avatar.dart index 01d6d1d..5f5e6c5 100644 --- a/lib/widgets/profile_avatar.dart +++ b/lib/widgets/profile_avatar.dart @@ -49,7 +49,7 @@ class ProfileAvatar extends StatelessWidget { height: size, clipBehavior: Clip.antiAlias, decoration: BoxDecoration( - color: NullFeedTheme.primaryColor.withValues(alpha: 0.3), + color: NullFeedTheme.primaryColor.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(borderRadius ?? size / 2), ), child: url == null @@ -78,7 +78,7 @@ class _FallbackInitial extends StatelessWidget { style: TextStyle( fontSize: size * 0.4, fontWeight: FontWeight.bold, - color: NullFeedTheme.primaryColor, + color: NullFeedTheme.accentColor, ), ), ); diff --git a/lib/widgets/queue_action.dart b/lib/widgets/queue_action.dart index 8d9a627..7ec9090 100644 --- a/lib/widgets/queue_action.dart +++ b/lib/widgets/queue_action.dart @@ -91,7 +91,6 @@ Future showVideoActionsSheet( }) { return showModalBottomSheet( context: context, - backgroundColor: NullFeedTheme.cardColor, builder: (sheetContext) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/widgets/skip_control.dart b/lib/widgets/skip_control.dart index 31c0c32..3cbdd64 100644 --- a/lib/widgets/skip_control.dart +++ b/lib/widgets/skip_control.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import '../config/theme.dart'; /// Skip-back / skip-forward control for the player overlay: a trio of /// chevrons, replacing the stock replay_10 / forward_10 icons. @@ -128,12 +127,14 @@ class _SkipControlState extends State ), if (widget.holdLabel case final holdLabel?) ...[ const SizedBox(height: 4), + // White like the chevrons — violet can't hold 4.5:1 over + // arbitrary video, even through the overlay scrim. Text( holdLabel, style: const TextStyle( - color: NullFeedTheme.primaryColor, + color: Colors.white, fontSize: 13, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, fontFeatures: [FontFeature.tabularFigures()], ), ), diff --git a/lib/widgets/unplayable_badge.dart b/lib/widgets/unplayable_badge.dart index ae835ef..6e3f344 100644 --- a/lib/widgets/unplayable_badge.dart +++ b/lib/widgets/unplayable_badge.dart @@ -7,10 +7,10 @@ import '../models/video.dart'; /// icon carries the color coding. Color unplayableReasonColor(UnplayableReason reason) => switch (reason) { UnplayableReason.ageRestricted => NullFeedTheme.errorColor, - UnplayableReason.membersOnly => const Color(0xFFFFB74D), - UnplayableReason.premium => NullFeedTheme.primaryColor, - UnplayableReason.geoBlocked => const Color(0xFF64B5F6), - UnplayableReason.upcoming => const Color(0xFF4DB6AC), + UnplayableReason.membersOnly => NullFeedTheme.warningColor, + UnplayableReason.premium => NullFeedTheme.accentColor, + UnplayableReason.geoBlocked => NullFeedTheme.infoColor, + UnplayableReason.upcoming => NullFeedTheme.infoColor, UnplayableReason.private || UnplayableReason.removed || UnplayableReason.drm || diff --git a/lib/widgets/video_card.dart b/lib/widgets/video_card.dart index 57265e2..8f11501 100644 --- a/lib/widgets/video_card.dart +++ b/lib/widgets/video_card.dart @@ -280,6 +280,7 @@ class _VideoCardState extends ConsumerState { child: NullFeedProgressBar( progress: widget.video.watchProgress, height: 3, + foregroundColor: NullFeedTheme.watchProgressColor, ), ), diff --git a/lib/widgets/video_list_tile.dart b/lib/widgets/video_list_tile.dart index a525c0f..331a9ea 100644 --- a/lib/widgets/video_list_tile.dart +++ b/lib/widgets/video_list_tile.dart @@ -269,6 +269,8 @@ class _VideoListTileState extends ConsumerState { child: NullFeedProgressBar( progress: widget.video.watchProgress, height: 3, + foregroundColor: + NullFeedTheme.watchProgressColor, ), ), ], @@ -306,10 +308,13 @@ class _VideoListTileState extends ConsumerState { ), if (widget.video.isWatched) ...[ const SizedBox(width: 8), + // Watched is inert metadata — muted, so the + // lime offline pin stays the row's only + // signal. const Icon( Icons.check_circle, size: 14, - color: NullFeedTheme.successColor, + color: NullFeedTheme.textMuted, ), ], ], diff --git a/test/widgets/add_profile_screen_test.dart b/test/widgets/add_profile_screen_test.dart index 946d7f6..e57bb78 100644 --- a/test/widgets/add_profile_screen_test.dart +++ b/test/widgets/add_profile_screen_test.dart @@ -128,9 +128,11 @@ void main() { .widgetList(find.byType(CheckboxListTile)) .toList(); expect(checkboxes.every((tile) => !(tile.value ?? false)), isTrue); + await tester.ensureVisible(find.text('Channel One')); await tester.tap(find.text('Channel One')); await tester.pumpAndSettle(); expect(find.text('1 of 2 selected'), findsOneWidget); + await tester.ensureVisible(find.text('Channel Two')); await tester.tap(find.text('Channel Two')); await tester.pumpAndSettle(); expect(find.text('2 of 2 selected'), findsOneWidget);