diff --git a/lib/providers/settings_controller.dart b/lib/providers/settings_controller.dart index 8517fa1..d5d247a 100644 --- a/lib/providers/settings_controller.dart +++ b/lib/providers/settings_controller.dart @@ -37,7 +37,7 @@ class SettingsController extends ChangeNotifier { final Future _prefsFuture = SharedPreferences.getInstance(); - Color _seedColor = AppTheme.defaultNextcloudBlue; + Color _seedColor = AppTheme.defaultAccent; ThemeMode _themeMode = ThemeMode.system; bool _useDynamicColor = true; bool _amoledDark = false; diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index 5db2bb6..749fcfb 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -5,6 +5,15 @@ import 'design_tokens.dart'; class AppTheme { static const Color defaultNextcloudBlue = Color(0xFF0082C9); + /// The design system's own fixed violet - [NooColors.light]/`.dark`'s + /// `accent`, and the accent picker's "Default" swatch. Also + /// [SettingsController]'s initial [SettingsController.seedColor], so a + /// fresh install (or any device without Material You dynamic color) keeps + /// today's violet look rather than picking up the leftover + /// [defaultNextcloudBlue] default from before seed colors actually did + /// anything to the Noo-styled UI (see [NooColors.fromSeed]). + static const Color defaultAccent = Color(0xFF8D0DE3); + // Flutter's Material 3 default (ZoomPageTransitionsBuilder) doesn't // implement Android's predictive-back gesture at all. Opting into // PredictiveBackPageTransitionsBuilder here (Android only) is required — @@ -24,6 +33,7 @@ class AppTheme { static const _sliderTheme = SliderThemeData(year2023: false); static const List seedColors = [ + defaultAccent, // Default (Noo Violet) Color(0xFF0082C9), // Nextcloud Blue Color(0xFF009688), // Ocean Teal Color(0xFF6750A4), // Deep Purple @@ -87,7 +97,7 @@ class AppTheme { ); final nooColors = (useDynamicColor && dynamicScheme != null) ? NooColors.fromDynamicScheme(dynamicScheme) - : NooColors.light; + : NooColors.fromSeed(seedColor, Brightness.light); return ThemeData( useMaterial3: true, @@ -131,7 +141,7 @@ class AppTheme { ); var nooColors = (useDynamicColor && dynamicScheme != null) ? NooColors.fromDynamicScheme(dynamicScheme) - : NooColors.dark; + : NooColors.fromSeed(seedColor, Brightness.dark); if (amoled) { // True OLED black across every surface tone Material 3 hands out - diff --git a/lib/theme/design_tokens.dart b/lib/theme/design_tokens.dart index 58c9d2c..1cc7b1c 100644 --- a/lib/theme/design_tokens.dart +++ b/lib/theme/design_tokens.dart @@ -7,13 +7,13 @@ import 'package:google_fonts/google_fonts.dart'; /// `Theme.of(context).extension()!` (or a `context.nooColors` /// getter, once one exists) instead of the Material [ColorScheme] roles. /// -/// Two independent sources feed this, matching the app's color settings: -/// the fixed [light]/[dark] palettes below (the design system's own -/// default look), or [NooColors.fromDynamicScheme] when the user has -/// Material You dynamic color turned on - there's no third option (no -/// custom accent picker), since the design system explicitly rules out -/// "add new colors". [withAmoled] layers on top of either source when the -/// user's AMOLED toggle is on. +/// Three sources feed this, matching the app's color settings: the fixed +/// [light]/[dark] palettes below (the design system's own default look, +/// also what the accent picker's "Default" violet swatch selects), +/// [NooColors.fromDynamicScheme] when the user has Material You dynamic +/// color turned on, or [NooColors.fromSeed] for a custom accent-picker +/// swatch. [withAmoled] layers on top of any of the three when the user's +/// AMOLED toggle is on. @immutable class NooColors extends ThemeExtension { final Color bg; @@ -161,6 +161,34 @@ class NooColors extends ThemeExtension { ); } + /// Derives an accent-only variant of the fixed [light]/[dark] palette for + /// a user-chosen custom seed color (Settings' accent-color picker) - + /// unlike [fromDynamicScheme], only the accent roles change; bg/surface/ + /// fg etc. stay the Noo warm-neutral palette, since picking a named + /// accent ("Ocean Teal") is meant to retint the brand color, not reshape + /// the whole surface to match a wallpaper the way dynamic color does. + /// [seedColor] equal to the fixed palette's own accent (the picker's + /// "Default" violet swatch) returns the untouched fixed palette, so the + /// out-of-box look stays pixel-identical rather than going through + /// [ColorScheme.fromSeed]'s tonal-palette math for no reason. + factory NooColors.fromSeed(Color seedColor, Brightness brightness) { + final fixed = brightness == Brightness.dark ? dark : light; + if (seedColor == light.accent) return fixed; + final cs = ColorScheme.fromSeed( + seedColor: seedColor, + brightness: brightness, + ); + return fixed.copyWith( + accent: seedColor, + // Light accentText matches accent exactly, same as the fixed + // palette - dark needs a lighter, desaturated tint for contrast + // against a dark surface, which is exactly what Material's tonal + // primary (tone ~80 in dark mode) is designed to give. + accentText: brightness == Brightness.dark ? cs.primary : seedColor, + accentSoft: cs.primaryContainer, + ); + } + /// True OLED black across every surface tone - not just [bg] - so cards, /// bars and sheets all go black too instead of the dark palette's warm /// dark-grey tones. Mirrors the AMOLED handling this replaces in @@ -391,10 +419,8 @@ class NooText { fontWeight: FontWeight.w400, ); - static TextStyle get label => GoogleFonts.instrumentSans( - fontSize: 13, - fontWeight: FontWeight.w600, - ); + static TextStyle get label => + GoogleFonts.instrumentSans(fontSize: 13, fontWeight: FontWeight.w600); static TextStyle get meta => GoogleFonts.instrumentSans( fontSize: 13, diff --git a/lib/widgets/settings/settings_appearance.dart b/lib/widgets/settings/settings_appearance.dart index 8c24209..26d333e 100644 --- a/lib/widgets/settings/settings_appearance.dart +++ b/lib/widgets/settings/settings_appearance.dart @@ -28,12 +28,13 @@ String _seekBarStyleLabel(MediaProgressBarStyle style) { /// Settings section 6: appearance. Only the theme mode (System/Light/Dark) /// is in the design system's own recipe - the accent-color picker, dynamic -/// color, AMOLED and the video seek-bar style are all settings that predate -/// the design system and have no place in it (a single fixed accent, no -/// per-user color choice). They still work, just demoted into a separate -/// "Advanced appearance" group beneath the main card per the rebuild brief, -/// rather than dropped - see the handoff report for whether to remove them -/// outright. +/// color, AMOLED and the video seek-bar style predate the design system, so +/// they're demoted into a separate "Advanced appearance" group beneath the +/// main card rather than folded into it. The accent picker itself picks a +/// [NooColors] variant (see [NooColors.fromSeed]/[NooColors.fromDynamicScheme]): +/// "Default" is the fixed violet palette, the named swatches retint just +/// the accent roles, and "Match wallpaper" (the `wallpaper`-icon swatch) +/// follows Material You dynamic color instead. class SettingsAppearanceSection extends StatelessWidget { const SettingsAppearanceSection({super.key}); @@ -55,9 +56,21 @@ class SettingsAppearanceSection extends StatelessWidget { value: settings.themeMode, onChanged: settings.setThemeMode, options: const [ - NooSegmentOption(value: ThemeMode.system, icon: LucideIcons.monitor, label: 'System'), - NooSegmentOption(value: ThemeMode.light, icon: LucideIcons.sun, label: 'Light'), - NooSegmentOption(value: ThemeMode.dark, icon: LucideIcons.moon, label: 'Dark'), + NooSegmentOption( + value: ThemeMode.system, + icon: LucideIcons.monitor, + label: 'System', + ), + NooSegmentOption( + value: ThemeMode.light, + icon: LucideIcons.sun, + label: 'Light', + ), + NooSegmentOption( + value: ThemeMode.dark, + icon: LucideIcons.moon, + label: 'Dark', + ), ], ), ), @@ -70,20 +83,33 @@ class SettingsAppearanceSection extends StatelessWidget { NooSettingsRow( icon: LucideIcons.palette, label: const Text('Accent color'), - subtitle: Text(settings.useDynamicColor ? 'Matching your wallpaper' : 'Custom color'), - trailing: _AccentSwatchDot(color: settings.useDynamicColor ? null : settings.seedColor), + subtitle: Text( + settings.useDynamicColor + ? 'Matching your wallpaper' + : settings.seedColor == AppTheme.defaultAccent + ? 'Default' + : 'Custom color', + ), + trailing: _AccentSwatchDot( + color: settings.useDynamicColor ? null : settings.seedColor, + ), onTap: () => _openAccentPicker(context, settings), ), NooSettingsRow( icon: LucideIcons.sparkles, label: const Text('AMOLED black'), subtitle: const Text('Use pure black backgrounds in dark mode'), - trailing: NooToggle(checked: settings.amoledDark, onChanged: settings.setAmoledDark), + trailing: NooToggle( + checked: settings.amoledDark, + onChanged: settings.setAmoledDark, + ), ), NooSettingsRow( icon: LucideIcons.waves, label: const Text('Seek bar style'), - subtitle: const Text('The progress bar style used when playing videos'), + subtitle: const Text( + 'The progress bar style used when playing videos', + ), value: _seekBarStyleLabel(settings.mediaProgressBarStyle), onTap: () => _openSeekBarPicker(context, settings), ), @@ -112,7 +138,9 @@ class _AccentSwatchDot extends StatelessWidget { color: color ?? colors.surface3, shape: BoxShape.circle, ), - child: color == null ? Icon(LucideIcons.wallpaper, size: 12, color: colors.fg2) : null, + child: color == null + ? Icon(LucideIcons.wallpaper, size: 12, color: colors.fg2) + : null, ); } } @@ -219,7 +247,11 @@ class _SeekBarStyleOption extends StatelessWidget { final bool isSelected; final VoidCallback onTap; - const _SeekBarStyleOption({required this.style, required this.isSelected, required this.onTap}); + const _SeekBarStyleOption({ + required this.style, + required this.isSelected, + required this.onTap, + }); @override Widget build(BuildContext context) { diff --git a/test/widgets/noo/core_test.dart b/test/widgets/noo/core_test.dart index dc718f1..564b9c4 100644 --- a/test/widgets/noo/core_test.dart +++ b/test/widgets/noo/core_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:noo/theme/app_theme.dart'; import 'package:noo/theme/design_tokens.dart'; import 'package:noo/widgets/noo/core/noo_avatar.dart'; import 'package:noo/widgets/noo/core/noo_badge.dart'; @@ -17,6 +18,45 @@ import 'noo_test_utils.dart'; void main() { setUpNooTests(); + group('NooColors.fromSeed', () { + test('the default accent returns the untouched fixed palette', () { + expect( + identical( + NooColors.fromSeed(AppTheme.defaultAccent, Brightness.light), + NooColors.light, + ), + isTrue, + ); + expect( + identical( + NooColors.fromSeed(AppTheme.defaultAccent, Brightness.dark), + NooColors.dark, + ), + isTrue, + ); + }); + + test('a custom seed retints only the accent roles', () { + const seed = Color(0xFF009688); // Ocean Teal + final light = NooColors.fromSeed(seed, Brightness.light); + expect(light.accent, seed); + expect(light.accentText, seed); + expect(light.accentSoft, isNot(NooColors.light.accentSoft)); + // The rest of the palette is untouched - a named accent choice + // retints the brand color, it doesn't reshape the surface. + expect(light.bg, NooColors.light.bg); + expect(light.surface, NooColors.light.surface); + expect(light.fg1, NooColors.light.fg1); + + final dark = NooColors.fromSeed(seed, Brightness.dark); + expect(dark.accent, seed); + // Dark accentText is a lighter/desaturated tint for contrast, not + // the raw seed itself. + expect(dark.accentText, isNot(seed)); + expect(dark.bg, NooColors.dark.bg); + }); + }); + group('NooAvatar', () { testNooWidgets('current user uses accent-soft/accent-text', ( tester, diff --git a/test/widgets/noo/noo_test_utils.dart b/test/widgets/noo/noo_test_utils.dart index 574776c..184e5d0 100644 --- a/test/widgets/noo/noo_test_utils.dart +++ b/test/widgets/noo/noo_test_utils.dart @@ -15,10 +15,13 @@ void setUpNooTests() { /// Themes built through the real [AppTheme] (non-dynamic), so the /// [NooColors] extension is registered exactly as in the app. Built lazily /// inside test bodies - AppTheme touches Google Fonts, which needs the test -/// binding to exist first. +/// binding to exist first. Seeded with [AppTheme.defaultAccent] (not just +/// any color) so component tests exercise the exact fixed [NooColors.light]/ +/// `.dark` palette their color assertions are written against, rather than +/// a [NooColors.fromSeed] retint - see its doc comment. ThemeData nooTheme(Brightness brightness) => brightness == Brightness.light - ? AppTheme.light(AppTheme.defaultNextcloudBlue, useDynamicColor: false) - : AppTheme.dark(AppTheme.defaultNextcloudBlue, useDynamicColor: false); + ? AppTheme.light(AppTheme.defaultAccent, useDynamicColor: false) + : AppTheme.dark(AppTheme.defaultAccent, useDynamicColor: false); Future pumpNoo( WidgetTester tester, @@ -43,7 +46,8 @@ Future pumpNoo( /// Runs [body] once per theme, each as its own named test. void testNooWidgets( String description, - Future Function(WidgetTester tester, ThemeData theme, NooColors colors) body, + Future Function(WidgetTester tester, ThemeData theme, NooColors colors) + body, ) { for (final brightness in Brightness.values) { testWidgets('$description (${brightness.name})', (tester) async { @@ -59,7 +63,10 @@ BoxDecoration decorationOf(WidgetTester tester, Finder finder) { final boxes = find.ancestor( of: finder, matching: find.byWidgetPredicate( - (w) => w is DecoratedBox && w.decoration is BoxDecoration && (w.decoration as BoxDecoration).color != null, + (w) => + w is DecoratedBox && + w.decoration is BoxDecoration && + (w.decoration as BoxDecoration).color != null, ), matchRoot: true, );