diff --git a/apps/weblibre/lib/features/geckoview/domain/repositories/tab.g.dart b/apps/weblibre/lib/features/geckoview/domain/repositories/tab.g.dart index 433e3e8f..a7511a64 100644 --- a/apps/weblibre/lib/features/geckoview/domain/repositories/tab.g.dart +++ b/apps/weblibre/lib/features/geckoview/domain/repositories/tab.g.dart @@ -41,7 +41,7 @@ final class TabRepositoryProvider } } -String _$tabRepositoryHash() => r'7d41038ac5a20ecf6de0645b99231fb8b2f6963a'; +String _$tabRepositoryHash() => r'd7b67460388c264f477325266a3299fe6df2df07'; abstract class _$TabRepository extends $Notifier { void build(); diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_home.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_home.dart index 9daa2e3c..001576c9 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_home.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_home.dart @@ -248,7 +248,11 @@ class _ContainerHeader extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final containerColor = container.color; - final containerPalette = ContainerColors.palette(context, containerColor); + final containerPalette = ContainerColors.palette( + context, + containerColor, + useCustomColor: container.metadata.useCustomColor, + ); return Container( width: 112, diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart index a0d405f4..399e7bba 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart @@ -40,9 +40,14 @@ import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_ import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart'; class CompactAppBarTitle extends ConsumerWidget { - const CompactAppBarTitle({super.key, this.containerColor}); + const CompactAppBarTitle({ + super.key, + this.containerColor, + this.useCustomColor = false, + }); final Color? containerColor; + final bool useCustomColor; @override Widget build(BuildContext context, WidgetRef ref) { @@ -77,6 +82,7 @@ class CompactAppBarTitle extends ConsumerWidget { siteSettingsBadgeState: siteSettingsBadgeState, longPressUrlCopy: settings.tabBarLongPressUrlCopy, containerColor: containerColor, + useCustomColor: useCustomColor, sandboxSourceUri: sandboxSourceUri, onSiteSettingsTap: () { ref @@ -105,6 +111,7 @@ class CompactAppBarTitleView extends StatelessWidget { this.tabIcon, this.longPressUrlCopy = true, this.containerColor, + this.useCustomColor = false, this.sandboxSourceUri, }); @@ -116,6 +123,7 @@ class CompactAppBarTitleView extends StatelessWidget { final Widget? tabIcon; final bool longPressUrlCopy; final Color? containerColor; + final bool useCustomColor; final Uri? sandboxSourceUri; @override @@ -124,7 +132,11 @@ class CompactAppBarTitleView extends StatelessWidget { final appColors = AppColors.of(context); final containerColor = this.containerColor; final containerPalette = containerColor != null - ? ContainerColors.palette(context, containerColor) + ? ContainerColors.palette( + context, + containerColor, + useCustomColor: useCustomColor, + ) : null; return Row( @@ -241,9 +253,14 @@ class CompactAppBarTitleView extends StatelessWidget { } class AppBarTitle extends ConsumerWidget { - const AppBarTitle({super.key, this.containerColor}); + const AppBarTitle({ + super.key, + this.containerColor, + this.useCustomColor = false, + }); final Color? containerColor; + final bool useCustomColor; @override Widget build(BuildContext context, WidgetRef ref) { @@ -278,6 +295,7 @@ class AppBarTitle extends ConsumerWidget { siteSettingsBadgeState: siteSettingsBadgeState, longPressUrlCopy: settings.tabBarLongPressUrlCopy, containerColor: containerColor, + useCustomColor: useCustomColor, sandboxSourceUri: sandboxSourceUri, onSiteSettingsTap: () { ref @@ -306,6 +324,7 @@ class AppBarTitleView extends StatelessWidget { required this.longPressUrlCopy, this.tabIcon, this.containerColor, + this.useCustomColor = false, this.sandboxSourceUri, }); @@ -317,6 +336,7 @@ class AppBarTitleView extends StatelessWidget { final Widget? tabIcon; final bool longPressUrlCopy; final Color? containerColor; + final bool useCustomColor; final Uri? sandboxSourceUri; @override @@ -325,7 +345,11 @@ class AppBarTitleView extends StatelessWidget { final appColors = AppColors.of(context); final containerColor = this.containerColor; final containerPalette = containerColor != null - ? ContainerColors.palette(context, containerColor) + ? ContainerColors.palette( + context, + containerColor, + useCustomColor: useCustomColor, + ) : null; return Row( diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart index 2ea50b33..55d9a837 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart @@ -236,6 +236,11 @@ class BrowserTabBar extends HookConsumerWidget { selectedTabId, ).select((data) => data.value?.color), ); + final containerUseCustomColor = ref.watch( + watchTabContainerDataProvider( + selectedTabId, + ).select((data) => data.value?.metadata.useCustomColor ?? false), + ); final quickTabSwitcherMode = settings.effectiveUiQuickTabSwitcherMode(); @@ -251,8 +256,14 @@ class BrowserTabBar extends HookConsumerWidget { displayedSheet is! ViewTabsSheet) ? containerColor : null; + final effectiveUseCustomColor = + effectiveContainerColor != null && containerUseCustomColor; final effectiveContainerPalette = effectiveContainerColor != null - ? ContainerColors.palette(context, effectiveContainerColor) + ? ContainerColors.palette( + context, + effectiveContainerColor, + useCustomColor: effectiveUseCustomColor, + ) : null; return BrowserTabBarView( @@ -264,8 +275,14 @@ class BrowserTabBar extends HookConsumerWidget { backgroundColor: effectiveContainerPalette?.surfaceColor, title: showTabTitle ? settings.tabBarLayout == TabBarLayout.compact - ? CompactAppBarTitle(containerColor: effectiveContainerColor) - : AppBarTitle(containerColor: effectiveContainerColor) + ? CompactAppBarTitle( + containerColor: effectiveContainerColor, + useCustomColor: effectiveUseCustomColor, + ) + : AppBarTitle( + containerColor: effectiveContainerColor, + useCustomColor: effectiveUseCustomColor, + ) : null, actions: [ const PinnedAddonBar(), @@ -457,6 +474,7 @@ class BrowserTabBarView extends StatelessWidget { class QuickTabSwitcherItem with FastEquatable { final Color? color; + final bool useCustomColor; final String id; final bool isActive; final TabMode tabMode; @@ -478,6 +496,7 @@ class QuickTabSwitcherItem with FastEquatable { required this.title, required this.url, required this.avatar, + this.useCustomColor = false, this.isSandbox = false, this.depth = 0, }); @@ -485,6 +504,7 @@ class QuickTabSwitcherItem with FastEquatable { @override List get hashParameters => [ color, + useCustomColor, id, isActive, tabMode, @@ -577,6 +597,7 @@ class QuickTabSwitcher extends HookConsumerWidget { : state.$1.titleOrAuthority; return QuickTabSwitcherItem( color: state.$2?.color, + useCustomColor: state.$2?.metadata.useCustomColor ?? false, id: state.$1.id, isActive: state.$1.id == selectedTabId, title: displayTitle, @@ -910,16 +931,26 @@ class QuickTabSwitcherView extends StatelessWidget { final color? when isSelected => ContainerColors.palette( context, color, + useCustomColor: item.useCustomColor, ).selectedBackgroundColor, - final color? => ContainerColors.palette(context, color).backgroundColor, + final color? => ContainerColors.palette( + context, + color, + useCustomColor: item.useCustomColor, + ).backgroundColor, null => null, }, side: (item, isSelected) => switch (item.color) { final color? when isSelected => ContainerColors.palette( context, color, + useCustomColor: item.useCustomColor, ).selectedBorderSide, - final color? => ContainerColors.palette(context, color).borderSide, + final color? => ContainerColors.palette( + context, + color, + useCustomColor: item.useCustomColor, + ).borderSide, null => null, }, labelPadding: (item) => @@ -1015,8 +1046,13 @@ class QuickTabSwitcherView extends StatelessWidget { ? ContainerColors.palette( context, color, + useCustomColor: item.useCustomColor, ).selectedForegroundColor - : ContainerColors.palette(context, color).foregroundColor, + : ContainerColors.palette( + context, + color, + useCustomColor: item.useCustomColor, + ).foregroundColor, fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, ), child: row, diff --git a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/empty_state/recent_tabs_section.dart b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/empty_state/recent_tabs_section.dart index 97690c1c..049dc4a8 100644 --- a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/empty_state/recent_tabs_section.dart +++ b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/empty_state/recent_tabs_section.dart @@ -67,6 +67,8 @@ class RecentTabsSection extends ConsumerWidget { iconSize: UrlListTile.iconSize, ), containerColor: containerData?.color, + useCustomColor: + containerData?.metadata.useCustomColor ?? false, onTap: () => onTabSelected(tabState.id), ); }, diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.dart index ff5ed465..42344fc2 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.dart @@ -53,6 +53,12 @@ class ContainerMetadata with FastEquatable { @JsonKey(defaultValue: false) final bool bypassGlobalProxy; + // When true, ContainerData.color is used directly as primaryContainer + // instead of being fed through ColorScheme.fromSeed. Lets power users pick + // any color (including dark/black) at the cost of M3 harmonization. + @JsonKey(defaultValue: false) + final bool useCustomColor; + final List? assignedSites; ContainerMetadata({ @@ -62,6 +68,7 @@ class ContainerMetadata with FastEquatable { required this.clearDataOnExit, required this.excludeFromIndex, required this.bypassGlobalProxy, + required this.useCustomColor, required this.assignedSites, }); @@ -72,6 +79,7 @@ class ContainerMetadata with FastEquatable { bool? clearDataOnExit, bool? excludeFromIndex, bool? bypassGlobalProxy, + bool? useCustomColor, List? assignedSites, }) : this( iconData: iconData, @@ -80,6 +88,7 @@ class ContainerMetadata with FastEquatable { clearDataOnExit: clearDataOnExit ?? false, excludeFromIndex: excludeFromIndex ?? false, bypassGlobalProxy: bypassGlobalProxy ?? false, + useCustomColor: useCustomColor ?? false, assignedSites: assignedSites, ); @@ -98,6 +107,7 @@ class ContainerMetadata with FastEquatable { clearDataOnExit, excludeFromIndex, bypassGlobalProxy, + useCustomColor, assignedSites, ]; } diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.g.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.g.dart index 76fdef79..8f38e6dc 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.g.dart @@ -19,6 +19,8 @@ abstract class _$ContainerMetadataCWProxy { ContainerMetadata bypassGlobalProxy(bool bypassGlobalProxy); + ContainerMetadata useCustomColor(bool useCustomColor); + ContainerMetadata assignedSites(List? assignedSites); /// Creates a new instance with the provided field values. @@ -35,6 +37,7 @@ abstract class _$ContainerMetadataCWProxy { bool clearDataOnExit, bool excludeFromIndex, bool bypassGlobalProxy, + bool useCustomColor, List? assignedSites, }); } @@ -69,6 +72,10 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy { ContainerMetadata bypassGlobalProxy(bool bypassGlobalProxy) => call(bypassGlobalProxy: bypassGlobalProxy); + @override + ContainerMetadata useCustomColor(bool useCustomColor) => + call(useCustomColor: useCustomColor); + @override ContainerMetadata assignedSites(List? assignedSites) => call(assignedSites: assignedSites); @@ -88,6 +95,7 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy { Object? clearDataOnExit = const $CopyWithPlaceholder(), Object? excludeFromIndex = const $CopyWithPlaceholder(), Object? bypassGlobalProxy = const $CopyWithPlaceholder(), + Object? useCustomColor = const $CopyWithPlaceholder(), Object? assignedSites = const $CopyWithPlaceholder(), }) { return ContainerMetadata( @@ -121,6 +129,12 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy { ? _value.bypassGlobalProxy // ignore: cast_nullable_to_non_nullable : bypassGlobalProxy as bool, + useCustomColor: + useCustomColor == const $CopyWithPlaceholder() || + useCustomColor == null + ? _value.useCustomColor + // ignore: cast_nullable_to_non_nullable + : useCustomColor as bool, assignedSites: assignedSites == const $CopyWithPlaceholder() ? _value.assignedSites // ignore: cast_nullable_to_non_nullable @@ -262,6 +276,7 @@ ContainerMetadata _$ContainerMetadataFromJson(Map json) => clearDataOnExit: json['clearDataOnExit'] as bool? ?? false, excludeFromIndex: json['excludeFromIndex'] as bool? ?? false, bypassGlobalProxy: json['bypassGlobalProxy'] as bool? ?? false, + useCustomColor: json['useCustomColor'] as bool? ?? false, assignedSites: (json['assignedSites'] as List?) ?.map((e) => Uri.parse(e as String)) .toList(), @@ -279,6 +294,7 @@ Map _$ContainerMetadataToJson( 'clearDataOnExit': instance.clearDataOnExit, 'excludeFromIndex': instance.excludeFromIndex, 'bypassGlobalProxy': instance.bypassGlobalProxy, + 'useCustomColor': instance.useCustomColor, 'assignedSites': instance.assignedSites?.map((e) => e.toString()).toList(), }; diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/container.dart b/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/container.dart index ceb3dcfb..1dae86a6 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/container.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/container.dart @@ -17,9 +17,9 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +import 'dart:math'; import 'dart:ui'; -import 'package:collection/collection.dart'; import 'package:nullability/nullability.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:weblibre/core/uuid.dart'; @@ -299,22 +299,12 @@ class ContainerRepository extends _$ContainerRepository { } Future unusedRandomContainerColor() async { - final usedColors = await getDistinctColors(); - final unusedColorTypes = colorTypes.where((colors) { - return !shadingTypes( - colors, - ).any((shade) => usedColors.contains(shade.keys.first)); - }).toList(); - final availableColors = - (unusedColorTypes.isNotEmpty ? unusedColorTypes : colorTypes).flattened - .toList(); - - Color randomColor; - do { - randomColor = randomColorShade(availableColors); - } while (usedColors.contains(randomColor)); - - return randomColor; + final usedColors = (await getDistinctColors()).toSet(); + final unused = containerSeedColors + .where((color) => !usedColors.contains(color)) + .toList(); + final pool = unused.isNotEmpty ? unused : containerSeedColors; + return pool[Random().nextInt(pool.length)]; } Future createNewContainer() async { diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/container.g.dart b/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/container.g.dart index b8ac731d..c0afc14e 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/container.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/container.g.dart @@ -42,7 +42,7 @@ final class ContainerRepositoryProvider } String _$containerRepositoryHash() => - r'14c77b4f3ed21d9511d5bf507db013b02a122595'; + r'b967f8948baf4347346a1369ad48a476da69e3a2'; abstract class _$ContainerRepository extends $Notifier { void build(); diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart index 8c2cb841..1de6f28c 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart @@ -88,6 +88,7 @@ class ContainerEditScreen extends HookConsumerWidget { ); final selectedColor = useState(initialContainer.color); + final useCustomColor = useState(initialContainer.metadata.useCustomColor); final selectedIcon = useState(initialContainer.metadata.iconData); final contextualIdentity = useState( initialContainer.metadata.contextualIdentity, @@ -129,6 +130,7 @@ class ContainerEditScreen extends HookConsumerWidget { contextualIdentity.value != null && proxyConnectionId.value == null && bypassGlobalProxy.value, + useCustomColor: useCustomColor.value, assignedSites: assignedSites.value, ), ); @@ -160,13 +162,17 @@ class ContainerEditScreen extends HookConsumerWidget { } Future openColorPicker() async { - final color = await showDialog( + final result = await showDialog( context: context, - builder: (context) => ColorPickerDialog(selectedColor.value), + builder: (context) => ColorPickerDialog( + selectedColor.value, + initialUseCustomColor: useCustomColor.value, + ), ); - if (color != null) { - selectedColor.value = color; + if (result != null) { + selectedColor.value = result.color; + useCustomColor.value = result.useCustomColor; } } @@ -179,6 +185,7 @@ class ContainerEditScreen extends HookConsumerWidget { heightFactor: 0.92, child: ContainerIconPickerSheet( selectedColor: selectedColor.value, + useCustomColor: useCustomColor.value, selectedIcon: resolveContainerIcon(selectedIcon.value), onSelected: (iconData) => Navigator.of(context).pop(iconData), ), @@ -245,6 +252,7 @@ class ContainerEditScreen extends HookConsumerWidget { final previewPalette = ContainerColors.palette( context, selectedColor.value, + useCustomColor: useCustomColor.value, ); final assignedSiteCount = assignedSites.value?.length ?? 0; final canPickProxy = diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_list.dart b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_list.dart index ab938f45..e9d2aaf1 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_list.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_list.dart @@ -183,7 +183,11 @@ class _ContainerCard extends HookConsumerWidget { final colorScheme = theme.colorScheme; final containerColor = container.color; final tabCount = container.tabCount ?? 0; - final palette = ContainerColors.palette(context, containerColor); + final palette = ContainerColors.palette( + context, + containerColor, + useCustomColor: container.metadata.useCustomColor, + ); final proxyOptions = ref.watch(proxyConnectionOptionsProvider); final proxyOptionsState = ref.watch(singboxProxyProfilesRepositoryProvider); final proxyOptionsLoading = diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_selection.dart b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_selection.dart index fd8904ca..d126194f 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_selection.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_selection.dart @@ -240,7 +240,11 @@ class _SelectionContainerCard extends ConsumerWidget { final colorScheme = theme.colorScheme; final containerColor = container.color; final tabCount = container.tabCount ?? 0; - final palette = ContainerColors.palette(context, containerColor); + final palette = ContainerColors.palette( + context, + containerColor, + useCustomColor: container.metadata.useCustomColor, + ); final proxyOptions = ref.watch(proxyConnectionOptionsProvider); final proxyOptionsState = ref.watch(singboxProxyProfilesRepositoryProvider); diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/color_picker_dialog.dart b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/color_picker_dialog.dart index 82c0ca67..ed0c7363 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/color_picker_dialog.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/color_picker_dialog.dart @@ -19,53 +19,206 @@ */ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/material_color_picker.dart'; +import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/custom_color_picker_dialog.dart'; +import 'package:weblibre/features/geckoview/features/tabs/utils/color_palette.dart'; import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart'; +typedef ColorPickerResult = ({Color color, bool useCustomColor}); + class ColorPickerDialog extends HookWidget { final Color initialColor; + final bool initialUseCustomColor; - const ColorPickerDialog(this.initialColor, {super.key}); + const ColorPickerDialog( + this.initialColor, { + this.initialUseCustomColor = false, + super.key, + }); @override Widget build(BuildContext context) { final selectedColor = useState(initialColor); + final useCustom = useState(initialUseCustomColor); + + Future openCustomPicker() async { + final result = await showDialog( + context: context, + builder: (_) => CustomColorPickerDialog(selectedColor.value), + ); + if (result != null) { + selectedColor.value = result; + useCustom.value = true; + } + } return AlertDialog( titlePadding: const EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 16.0), - contentPadding: const EdgeInsets.only( - left: 20.0, - right: 20.0, - bottom: 24.0, + contentPadding: const EdgeInsets.symmetric( + horizontal: 20.0, + vertical: 8.0, ), insetPadding: const EdgeInsets.symmetric( horizontal: 20.0, vertical: 24.0, ), title: const Text('Select Color'), - content: MaterialPicker( - pickerColor: selectedColor.value, - onColorChanged: (value) { - selectedColor.value = value; - }, - displayColorBuilder: (context, color) { - return ContainerColors.palette(context, color).containerColor; + content: _ContainerColorGrid( + selectedColor: selectedColor.value, + useCustomColor: useCustom.value, + onSeedSelected: (color) { + selectedColor.value = color; + useCustom.value = false; }, + onCustomTapped: openCustomPicker, ), actions: [ TextButton( - onPressed: () { - Navigator.pop(context); - }, + onPressed: () => Navigator.pop(context), child: const Text('Cancel'), ), TextButton( - onPressed: () { - Navigator.pop(context, selectedColor.value); - }, + onPressed: () => Navigator.pop( + context, + (color: selectedColor.value, useCustomColor: useCustom.value), + ), child: const Text('Select'), ), ], ); } } + +class _ContainerColorGrid extends StatelessWidget { + const _ContainerColorGrid({ + required this.selectedColor, + required this.useCustomColor, + required this.onSeedSelected, + required this.onCustomTapped, + }); + + final Color selectedColor; + final bool useCustomColor; + final ValueChanged onSeedSelected; + final VoidCallback onCustomTapped; + + @override + Widget build(BuildContext context) { + final itemCount = containerSeedColors.length + 1; + return SizedBox( + width: 320, + child: GridView.builder( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: 8.0), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 6, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + ), + itemCount: itemCount, + itemBuilder: (context, index) { + if (index == containerSeedColors.length) { + return _CustomSwatch( + isSelected: useCustomColor, + selectedColor: selectedColor, + onTap: onCustomTapped, + ); + } + final seed = containerSeedColors[index]; + final palette = ContainerColors.palette(context, seed); + final isSelected = + !useCustomColor && seed.toARGB32() == selectedColor.toARGB32(); + return _Swatch( + displayColor: palette.containerColor, + checkColor: palette.onContainerColor, + isSelected: isSelected, + onTap: () => onSeedSelected(seed), + ); + }, + ), + ); + } +} + +class _Swatch extends StatelessWidget { + const _Swatch({ + required this.displayColor, + required this.checkColor, + required this.isSelected, + required this.onTap, + }); + + final Color displayColor; + final Color checkColor; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkResponse( + onTap: onTap, + radius: 28, + child: DecoratedBox( + decoration: BoxDecoration( + color: displayColor, + shape: BoxShape.circle, + border: isSelected + ? Border.all( + color: Theme.of(context).colorScheme.onSurface, + width: 2, + ) + : null, + ), + child: isSelected + ? Icon(Icons.check, size: 20, color: checkColor) + : const SizedBox.expand(), + ), + ); + } +} + +class _CustomSwatch extends StatelessWidget { + const _CustomSwatch({ + required this.isSelected, + required this.selectedColor, + required this.onTap, + }); + + final bool isSelected; + final Color selectedColor; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final palette = isSelected + ? ContainerColors.palette( + context, + selectedColor, + useCustomColor: true, + ) + : null; + return InkResponse( + onTap: onTap, + radius: 28, + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: palette?.containerColor ?? Colors.transparent, + border: Border.all( + color: isSelected + ? colorScheme.onSurface + : colorScheme.outline.withValues(alpha: 0.5), + width: 2, + ), + ), + child: isSelected + ? Icon(Icons.check, size: 20, color: palette!.onContainerColor) + : Icon( + Icons.colorize, + size: 18, + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), + ), + ), + ); + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/compact_container_selector.dart b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/compact_container_selector.dart index b5388f96..77345909 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/compact_container_selector.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/compact_container_selector.dart @@ -61,7 +61,11 @@ class CompactContainerSelector extends ConsumerWidget { final isSelected = selectedContainer != null; final accentColor = selectedContainer?.color ?? colorScheme.primary; final showSelectedHighlight = isSelected && emphasizeSelection; - final palette = ContainerColors.palette(context, accentColor); + final palette = ContainerColors.palette( + context, + accentColor, + useCustomColor: selectedContainer?.metadata.useCustomColor ?? false, + ); return GestureDetector( onLongPress: isSelected diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/container_chip_content.dart b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/container_chip_content.dart index b8cf6919..e4ff20f1 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/container_chip_content.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/container_chip_content.dart @@ -31,7 +31,11 @@ Widget? buildContainerChipAvatar( bool isSelected, { double size = 18, }) { - final palette = ContainerColors.palette(context, container.color); + final palette = ContainerColors.palette( + context, + container.color, + useCustomColor: container.metadata.useCustomColor, + ); return chipContainerIcon(container.metadata.iconData).mapNotNull( (iconData) => Icon( @@ -48,7 +52,11 @@ Widget buildContainerChipLabel( bool isSelected, { Widget? trailing, }) { - final palette = ContainerColors.palette(context, container.color); + final palette = ContainerColors.palette( + context, + container.color, + useCustomColor: container.metadata.useCustomColor, + ); final foregroundColor = isSelected ? palette.selectedForegroundColor : palette.foregroundColor; diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart index 000fd8f7..aec55b4f 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart @@ -42,22 +42,45 @@ import 'package:weblibre/features/user/domain/repositories/general_settings.dart import 'package:weblibre/presentation/widgets/inline_count_badge.dart'; import 'package:weblibre/presentation/widgets/selectable_chips.dart'; -ContainerColorPalette _palette(BuildContext context, Color color) { - return ContainerColors.palette(context, color); +ContainerColorPalette _palette( + BuildContext context, + Color color, { + bool useCustomColor = false, +}) { + return ContainerColors.palette( + context, + color, + useCustomColor: useCustomColor, + ); } -Color _chipColor(BuildContext context, Color color, bool isSelected) { - final palette = _palette(context, color); +Color _chipColor( + BuildContext context, + Color color, + bool isSelected, { + bool useCustomColor = false, +}) { + final palette = _palette(context, color, useCustomColor: useCustomColor); return isSelected ? palette.selectedBackgroundColor : palette.backgroundColor; } -BorderSide _chipSide(BuildContext context, Color color, bool isSelected) { - final palette = _palette(context, color); +BorderSide _chipSide( + BuildContext context, + Color color, + bool isSelected, { + bool useCustomColor = false, +}) { + final palette = _palette(context, color, useCustomColor: useCustomColor); return isSelected ? palette.selectedBorderSide : palette.borderSide; } -InlineCountBadge _countBadge(BuildContext context, Color color, int count) { - final palette = _palette(context, color); +InlineCountBadge _countBadge( + BuildContext context, + Color color, + int count, { + bool useCustomColor = false, +}) { + final palette = _palette(context, color, useCustomColor: useCustomColor); return InlineCountBadge( count: count, backgroundColor: palette.badgeBackgroundColor, @@ -404,10 +427,18 @@ class ContainerChips extends HookConsumerWidget { cacheExtent: 500, itemId: (container) => container.id, decoration: SelectableChipDecoration( - color: (container, isSelected) => - _chipColor(context, container.color, isSelected), - side: (container, isSelected) => - _chipSide(context, container.color, isSelected), + color: (container, isSelected) => _chipColor( + context, + container.color, + isSelected, + useCustomColor: container.metadata.useCustomColor, + ), + side: (container, isSelected) => _chipSide( + context, + container.color, + isSelected, + useCustomColor: container.metadata.useCustomColor, + ), ), itemAvatar: (container) { final isSelected = @@ -432,7 +463,13 @@ class ContainerChips extends HookConsumerWidget { container, isSelected, trailing: count != null && count > 0 - ? _countBadge(context, container.color, count) + ? _countBadge( + context, + container.color, + count, + useCustomColor: + container.metadata.useCustomColor, + ) : null, ); }, diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/container_icon_picker_sheet.dart b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/container_icon_picker_sheet.dart index 5cfa3ec5..3ddd5dc2 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/container_icon_picker_sheet.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/container_icon_picker_sheet.dart @@ -42,17 +42,23 @@ class ContainerIconPickerSheet extends HookWidget { required this.selectedColor, required this.selectedIcon, required this.onSelected, + this.useCustomColor = false, super.key, }); final Color selectedColor; final IconData selectedIcon; + final bool useCustomColor; final ValueChanged onSelected; @override Widget build(BuildContext context) { final theme = Theme.of(context); - final palette = ContainerColors.palette(context, selectedColor); + final palette = ContainerColors.palette( + context, + selectedColor, + useCustomColor: useCustomColor, + ); final searchController = useTextEditingController(); useListenable(searchController); diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/container_list_tile.dart b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/container_list_tile.dart index c80a4d4c..1a889a98 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/container_list_tile.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/container_list_tile.dart @@ -38,7 +38,11 @@ class ContainerListTile extends HookWidget { @override Widget build(BuildContext context) { - final palette = ContainerColors.palette(context, container.color); + final palette = ContainerColors.palette( + context, + container.color, + useCustomColor: container.metadata.useCustomColor, + ); return ListTileTheme( selectedColor: palette.onContainerColor, diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/custom_color_picker_dialog.dart b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/custom_color_picker_dialog.dart new file mode 100644 index 00000000..4e1d1b5c --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/custom_color_picker_dialog.dart @@ -0,0 +1,267 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart'; + +/// Freeform color picker for power users. +/// +/// Returns a [Color] (full opacity) intended to be used as the container's +/// stored color with `useCustomColor: true`. The preview swatch shows the +/// actual `containerColor` that [ContainerColors.palette] will produce in +/// custom mode, so what the user sees is what the chip will look like. +class CustomColorPickerDialog extends HookWidget { + final Color initialColor; + + const CustomColorPickerDialog(this.initialColor, {super.key}); + + @override + Widget build(BuildContext context) { + final hsl = useState(HSLColor.fromColor(initialColor)); + final hexController = useTextEditingController(text: _toHex(initialColor)); + + void updateHsl(HSLColor next) { + hsl.value = next; + final hex = _toHex(next.toColor()); + if (hexController.text.toUpperCase() != hex) { + hexController.text = hex; + } + } + + void onHexSubmitted(String value) { + final parsed = _parseHex(value); + if (parsed != null) { + hsl.value = HSLColor.fromColor(parsed); + hexController.text = _toHex(parsed); + } else { + hexController.text = _toHex(hsl.value.toColor()); + } + } + + final color = hsl.value.toColor(); + final palette = ContainerColors.palette( + context, + color, + useCustomColor: true, + ); + + return AlertDialog( + titlePadding: const EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 16.0), + contentPadding: const EdgeInsets.symmetric( + horizontal: 20.0, + vertical: 8.0, + ), + title: const Text('Custom Color'), + content: SizedBox( + width: 320, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _PreviewSwatch(palette: palette), + const SizedBox(height: 16), + TextField( + controller: hexController, + decoration: const InputDecoration( + labelText: 'Hex', + prefixText: '#', + isDense: true, + border: OutlineInputBorder(), + ), + textCapitalization: TextCapitalization.characters, + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp('[0-9a-fA-F]')), + LengthLimitingTextInputFormatter(6), + ], + onSubmitted: onHexSubmitted, + onChanged: (value) { + if (value.length == 6) onHexSubmitted(value); + }, + ), + const SizedBox(height: 12), + _GradientSlider( + label: 'Hue', + value: hsl.value.hue, + max: 360, + gradient: const LinearGradient( + colors: [ + Color(0xFFFF0000), + Color(0xFFFFFF00), + Color(0xFF00FF00), + Color(0xFF00FFFF), + Color(0xFF0000FF), + Color(0xFFFF00FF), + Color(0xFFFF0000), + ], + ), + onChanged: (v) => updateHsl(hsl.value.withHue(v)), + ), + _GradientSlider( + label: 'Saturation', + value: hsl.value.saturation, + max: 1, + gradient: LinearGradient( + colors: [ + HSLColor.fromAHSL( + 1, + hsl.value.hue, + 0, + hsl.value.lightness, + ).toColor(), + HSLColor.fromAHSL( + 1, + hsl.value.hue, + 1, + hsl.value.lightness, + ).toColor(), + ], + ), + onChanged: (v) => updateHsl(hsl.value.withSaturation(v)), + ), + _GradientSlider( + label: 'Lightness', + value: hsl.value.lightness, + max: 1, + gradient: LinearGradient( + colors: [ + Colors.black, + HSLColor.fromAHSL( + 1, + hsl.value.hue, + hsl.value.saturation, + 0.5, + ).toColor(), + Colors.white, + ], + ), + onChanged: (v) => updateHsl(hsl.value.withLightness(v)), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, color), + child: const Text('Select'), + ), + ], + ); + } +} + +class _PreviewSwatch extends StatelessWidget { + const _PreviewSwatch({required this.palette}); + + final ContainerColorPalette palette; + + @override + Widget build(BuildContext context) { + return Container( + width: 96, + height: 96, + decoration: BoxDecoration( + color: palette.containerColor, + shape: BoxShape.circle, + border: Border.all(color: palette.outlineColor, width: 2), + ), + alignment: Alignment.center, + child: Icon(Icons.check, color: palette.onContainerColor, size: 32), + ); + } +} + +class _GradientSlider extends StatelessWidget { + const _GradientSlider({ + required this.label, + required this.value, + required this.max, + required this.gradient, + required this.onChanged, + }); + + final String label; + final double value; + final double max; + final Gradient gradient; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelSmall), + SizedBox( + height: 36, + child: Stack( + alignment: Alignment.center, + children: [ + Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + height: 10, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(5), + gradient: gradient, + ), + ), + SliderTheme( + data: SliderTheme.of(context).copyWith( + activeTrackColor: Colors.transparent, + inactiveTrackColor: Colors.transparent, + overlayColor: Colors.transparent, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 10, + ), + ), + child: Slider( + value: value.clamp(0, max).toDouble(), + max: max, + onChanged: onChanged, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +String _toHex(Color color) { + final r = (color.r * 255).round().toRadixString(16).padLeft(2, '0'); + final g = (color.g * 255).round().toRadixString(16).padLeft(2, '0'); + final b = (color.b * 255).round().toRadixString(16).padLeft(2, '0'); + return '$r$g$b'.toUpperCase(); +} + +Color? _parseHex(String value) { + final cleaned = value.replaceAll('#', '').trim(); + if (cleaned.length != 6) return null; + final parsed = int.tryParse(cleaned, radix: 16); + if (parsed == null) return null; + return Color(0xFF000000 | parsed); +} diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/material_color_picker.dart b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/material_color_picker.dart deleted file mode 100644 index 72a602a2..00000000 --- a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/material_color_picker.dart +++ /dev/null @@ -1,405 +0,0 @@ -/* - * Copyright (c) 2024-2026 Fabian Freund. - * - * This file is part of WebLibre - * (see https://weblibre.eu). - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -// The Color Picker which contains Material Design Color Palette. -import 'package:fading_scroll/fading_scroll.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:weblibre/features/geckoview/features/tabs/utils/color_palette.dart'; - -class MaterialPicker extends StatefulWidget { - const MaterialPicker({ - super.key, - required this.pickerColor, - required this.onColorChanged, - this.onPrimaryChanged, - this.enableLabel = false, - this.portraitOnly = false, - this.displayAlpha, - this.displayColorBuilder, - }); - - final Color pickerColor; - final ValueChanged onColorChanged; - final ValueChanged? onPrimaryChanged; - final bool enableLabel; - final bool portraitOnly; - final double? displayAlpha; - final Color Function(BuildContext context, Color color)? displayColorBuilder; - - @override - State createState() => _MaterialPickerState(); -} - -class _MaterialPickerState extends State { - List _currentColorType = [Colors.red, Colors.redAccent]; - Color _currentShading = Colors.transparent; - - @override - void initState() { - for (final colors in colorTypes) { - shadingTypes(colors).forEach((Map color) { - if (widget.pickerColor.toARGB32() == color.keys.first.toARGB32()) { - return setState(() { - _currentColorType = colors; - _currentShading = color.keys.first; - }); - } - }); - } - super.initState(); - } - - @override - Widget build(BuildContext context) { - final disableAnimations = MediaQuery.disableAnimationsOf(context); - final isPortrait = - MediaQuery.of(context).orientation == Orientation.portrait || - widget.portraitOnly; - - Color resolveDisplayColor(Color color) { - final displayColorBuilder = widget.displayColorBuilder; - if (displayColorBuilder != null) { - return displayColorBuilder(context, color); - } - - return widget.displayAlpha != null - ? color.withValues(alpha: widget.displayAlpha) - : color; - } - - Widget colorList() { - return Container( - clipBehavior: Clip.hardEdge, - decoration: const BoxDecoration(), - child: Container( - margin: isPortrait - ? const EdgeInsets.only(right: 10) - : const EdgeInsets.only(bottom: 10), - width: isPortrait ? 60 : null, - height: isPortrait ? null : 60, - decoration: BoxDecoration( - color: Theme.of(context).cardColor, - boxShadow: [ - BoxShadow( - color: (Theme.of(context).brightness == Brightness.light) - ? (Theme.of(context).brightness == Brightness.light) - ? Colors.grey[300]! - : Colors.black38 - : Colors.black38, - blurRadius: 10, - ), - ], - border: isPortrait - ? Border( - right: BorderSide( - color: (Theme.of(context).brightness == Brightness.light) - ? Colors.grey[300]! - : Colors.black38, - ), - ) - : Border( - top: BorderSide( - color: (Theme.of(context).brightness == Brightness.light) - ? Colors.grey[300]! - : Colors.black38, - ), - ), - ), - child: ScrollConfiguration( - behavior: ScrollConfiguration.of( - context, - ).copyWith(dragDevices: PointerDeviceKind.values.toSet()), - child: FadingScroll( - fadingSize: 25, - builder: (context, controller) { - return ListView( - controller: controller, - scrollDirection: isPortrait ? Axis.vertical : Axis.horizontal, - children: [ - if (isPortrait) - const Padding(padding: EdgeInsets.only(top: 7)) - else - const Padding(padding: EdgeInsets.only(left: 7)), - ...colorTypes.map((List colors) { - final Color colorType = colors[0]; - final Color displayColorType = resolveDisplayColor( - colorType, - ); - return GestureDetector( - onTap: () { - if (widget.onPrimaryChanged != null) { - widget.onPrimaryChanged!.call(colorType); - } - setState(() => _currentColorType = colors); - }, - child: Container( - color: Colors.transparent, - padding: isPortrait - ? const EdgeInsets.fromLTRB(0, 7, 0, 7) - : const EdgeInsets.fromLTRB(7, 0, 7, 0), - child: Align( - child: AnimatedContainer( - duration: disableAnimations - ? Duration.zero - : const Duration(milliseconds: 300), - width: 25, - height: 25, - decoration: BoxDecoration( - color: displayColorType, - shape: BoxShape.circle, - boxShadow: _currentColorType == colors - ? [ - if (colorType == - Theme.of(context).cardColor) - BoxShadow( - color: - (Theme.of(context).brightness == - Brightness.light) - ? Colors.grey[300]! - : Colors.black38, - blurRadius: 10, - ) - else - BoxShadow( - color: displayColorType, - blurRadius: 10, - ), - ] - : null, - border: colorType == Theme.of(context).cardColor - ? Border.all( - color: - (Theme.of(context).brightness == - Brightness.light) - ? Colors.grey[300]! - : Colors.black38, - ) - : null, - ), - ), - ), - ), - ); - }), - if (isPortrait) - const Padding(padding: EdgeInsets.only(top: 5)) - else - const Padding(padding: EdgeInsets.only(left: 5)), - ], - ); - }, - ), - ), - ), - ); - } - - Widget shadingList() { - return ScrollConfiguration( - behavior: ScrollConfiguration.of( - context, - ).copyWith(dragDevices: PointerDeviceKind.values.toSet()), - child: FadingScroll( - fadingSize: 25, - builder: (context, controller) { - return ListView( - controller: controller, - scrollDirection: isPortrait ? Axis.vertical : Axis.horizontal, - children: [ - if (isPortrait) - const Padding(padding: EdgeInsets.only(top: 15)) - else - const Padding(padding: EdgeInsets.only(left: 15)), - ...shadingTypes(_currentColorType).map(( - Map colors, - ) { - final Color color = colors.keys.first; - final Color displayColor = resolveDisplayColor(color); - return GestureDetector( - onTap: () { - setState(() => _currentShading = color); - widget.onColorChanged(color); - }, - child: Container( - color: Colors.transparent, - margin: isPortrait - ? const EdgeInsets.only(right: 10) - : const EdgeInsets.only(bottom: 10), - padding: isPortrait - ? const EdgeInsets.fromLTRB(0, 7, 0, 7) - : const EdgeInsets.fromLTRB(7, 0, 7, 0), - child: Align( - child: AnimatedContainer( - curve: Curves.fastOutSlowIn, - duration: disableAnimations - ? Duration.zero - : const Duration(milliseconds: 500), - width: isPortrait - ? (_currentShading == color ? 250 : 230) - : (_currentShading == color ? 50 : 30), - height: isPortrait ? 50 : 220, - decoration: BoxDecoration( - color: displayColor, - boxShadow: _currentShading == color - ? [ - if ((color == Colors.white) || - (color == Colors.black)) - BoxShadow( - color: - (Theme.of(context).brightness == - Brightness.light) - ? Colors.grey[300]! - : Colors.black38, - blurRadius: 10, - ) - else - BoxShadow( - color: displayColor, - blurRadius: 10, - ), - ] - : null, - border: - (color == Colors.white) || - (color == Colors.black) - ? Border.all( - color: - (Theme.of(context).brightness == - Brightness.light) - ? Colors.grey[300]! - : Colors.black38, - ) - : null, - ), - child: widget.enableLabel - ? isPortrait - ? Row( - children: [ - Text( - ' ${colors.values.first}', - style: TextStyle( - color: - useWhiteForeground( - displayColor, - ) - ? Colors.white - : Colors.black, - ), - ), - Expanded( - child: Align( - alignment: Alignment.centerRight, - child: Text( - '#${color.toString().replaceFirst('Color(0xff', '').replaceFirst(')', '').toUpperCase()} ', - style: TextStyle( - color: - useWhiteForeground( - displayColor, - ) - ? Colors.white - : Colors.black, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ], - ) - : AnimatedOpacity( - duration: disableAnimations - ? Duration.zero - : const Duration(milliseconds: 300), - opacity: _currentShading == color - ? 1 - : 0, - child: Container( - padding: const EdgeInsets.only( - top: 16, - ), - alignment: Alignment.topCenter, - child: Text( - colors.values.first, - style: TextStyle( - color: - useWhiteForeground( - displayColor, - ) - ? Colors.white - : Colors.black, - fontWeight: FontWeight.bold, - fontSize: 14, - ), - softWrap: false, - ), - ), - ) - : const SizedBox(), - ), - ), - ), - ); - }), - if (isPortrait) - const Padding(padding: EdgeInsets.only(top: 15)) - else - const Padding(padding: EdgeInsets.only(left: 15)), - ], - ); - }, - ), - ); - } - - if (isPortrait) { - return SizedBox( - width: 350, - height: 500, - child: Row( - children: [ - colorList(), - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: shadingList(), - ), - ), - ], - ), - ); - } else { - return SizedBox( - width: 500, - height: 300, - child: Column( - children: [ - colorList(), - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: shadingList(), - ), - ), - ], - ), - ); - } - } -} diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/utils/color_palette.dart b/apps/weblibre/lib/features/geckoview/features/tabs/utils/color_palette.dart index 20a9eae1..e9967455 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/utils/color_palette.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/utils/color_palette.dart @@ -17,117 +17,19 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -import 'dart:math'; - import 'package:flutter/material.dart'; +import 'package:material_color_utilities/material_color_utilities.dart'; -final _rnd = Random(); +const int _hueCount = 24; +const double _seedChroma = 60.0; +const double _seedTone = 60.0; -/// Check if is good condition to use white foreground color by passing -/// the background color, and optional bias. -/// -/// Reference: -/// -/// Old: https://www.w3.org/TR/WCAG20-TECHS/G18.html -/// -/// New: https://github.com/mchome/flutter_statusbarcolor/issues/40 -bool useWhiteForeground(Color backgroundColor, {double bias = 0.0}) { - // Old: - // return 1.05 / (color.computeLuminance() + 0.05) > 4.5; - - // New: - final v = sqrt( - pow(backgroundColor.r, 2) * 0.299 + - pow(backgroundColor.g, 2) * 0.587 + - pow(backgroundColor.b, 2) * 0.114, - ).round(); - return v < (130 + bias); -} - -const List> colorTypes = [ - [Colors.red, Colors.redAccent], - [Colors.pink, Colors.pinkAccent], - [Colors.purple, Colors.purpleAccent], - [Colors.deepPurple, Colors.deepPurpleAccent], - [Colors.indigo, Colors.indigoAccent], - [Colors.blue, Colors.blueAccent], - [Colors.lightBlue, Colors.lightBlueAccent], - [Colors.cyan, Colors.cyanAccent], - [Colors.teal, Colors.tealAccent], - [Colors.green, Colors.greenAccent], - [Colors.lightGreen, Colors.lightGreenAccent], - [Colors.lime, Colors.limeAccent], - [Colors.yellow, Colors.yellowAccent], - [Colors.amber, Colors.amberAccent], - [Colors.orange, Colors.orangeAccent], - [Colors.deepOrange, Colors.deepOrangeAccent], - [Colors.brown], - [Colors.grey], - [Colors.blueGrey], - [Colors.black], -]; - -List> shadingTypes(List colors) { - final List> result = []; - - for (final Color colorType in colors) { - if (colorType == Colors.grey) { - result.addAll( - [ - 50, - 100, - 200, - 300, - 350, - 400, - 500, - 600, - 700, - 800, - 850, - 900, - ].map((int shade) => {Colors.grey[shade]!: shade.toString()}).toList(), - ); - } else if (colorType == Colors.black || colorType == Colors.white) { - result.addAll([ - {Colors.black: ''}, - {Colors.white: ''}, - ]); - } else if (colorType is MaterialAccentColor) { - result.addAll( - [ - 100, - 200, - 400, - 700, - ].map((int shade) => {colorType[shade]!: 'A$shade'}).toList(), - ); - } else if (colorType is MaterialColor) { - result.addAll( - [ - 50, - 100, - 200, - 300, - 400, - 500, - 600, - 700, - 800, - 900, - ].map((int shade) => {colorType[shade]!: shade.toString()}).toList(), - ); - } else { - result.add({Colors.transparent: ''}); - } - } - - return result; -} - -Color randomColorShade(List colors) { - final color = colors[_rnd.nextInt(colors.length)]; - final shades = shadingTypes([color]); - - return shades[_rnd.nextInt(shades.length)].keys.first; -} +// Evenly spaced HCT hues. ColorScheme.fromSeed extracts the seed's hue and +// normalizes tone, so spacing in HCT (not HSL) guarantees each swatch yields +// a perceptually distinct primaryContainer. +final List containerSeedColors = List.unmodifiable( + List.generate(_hueCount, (i) { + final hue = i * 360.0 / _hueCount; + return Color(Hct.from(hue, _seedChroma, _seedTone).toInt()); + }), +); diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/utils/container_colors.dart b/apps/weblibre/lib/features/geckoview/features/tabs/utils/container_colors.dart index cea52d4f..f21625c9 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/utils/container_colors.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/utils/container_colors.dart @@ -18,6 +18,7 @@ * along with this program. If not, see . */ import 'package:flutter/material.dart'; +import 'package:material_color_utilities/material_color_utilities.dart'; class ContainerColorPalette { const ContainerColorPalette({ @@ -65,8 +66,17 @@ class ContainerColorPalette { /// Centralized helper for container color display and theming. /// -/// This class converts the stored container seed color into Material 3 roles -/// used consistently across the application. +/// Converts the stored container color into Material 3 roles used +/// consistently across the application. Supports two modes: +/// +/// - **Seed mode** (default): the color is treated as a seed and fed through +/// [ColorScheme.fromSeed], yielding an M3-harmonized palette. The actual +/// container background depends on the theme brightness (T90 light / T30 +/// dark) — picking a dark seed does not produce a dark container. +/// - **Custom mode** (`useCustomColor: true`): the color is used directly as +/// `primaryContainer`. Accent/outline are derived via HCT tone shifts and +/// on-colors via WCAG contrast. Enables true any-color choice (including +/// black/dark grey) at the cost of strict M3 harmonization. class ContainerColors { ContainerColors._(); @@ -74,55 +84,100 @@ class ContainerColors { static const double surfaceHighAlpha = 0.28; static const double outlineBorderAlpha = 0.5; - static ContainerColorPalette palette(BuildContext context, Color seedColor) { + static ContainerColorPalette palette( + BuildContext context, + Color color, { + bool useCustomColor = false, + }) { final theme = Theme.of(context); final appScheme = theme.colorScheme; - final containerScheme = ColorScheme.fromSeed( - seedColor: fullOpacity(seedColor), - brightness: theme.brightness, - ); + final fullColor = fullOpacity(color); + + final containerColor = useCustomColor + ? fullColor + : ColorScheme.fromSeed( + seedColor: fullColor, + brightness: theme.brightness, + ).primaryContainer; + final accentColor = useCustomColor + ? _shiftTone(fullColor, theme.brightness) + : ColorScheme.fromSeed( + seedColor: fullColor, + brightness: theme.brightness, + ).primary; + final onContainerColor = useCustomColor + ? _contrastingForeground(containerColor) + : ColorScheme.fromSeed( + seedColor: fullColor, + brightness: theme.brightness, + ).onPrimaryContainer; + final onAccentColor = useCustomColor + ? _contrastingForeground(accentColor) + : ColorScheme.fromSeed( + seedColor: fullColor, + brightness: theme.brightness, + ).onPrimary; + final surfaceColor = Color.alphaBlend( - containerScheme.primaryContainer.withValues(alpha: surfaceAlpha), + containerColor.withValues(alpha: surfaceAlpha), appScheme.surfaceContainer, ); final surfaceHighColor = Color.alphaBlend( - containerScheme.primaryContainer.withValues(alpha: surfaceHighAlpha), + containerColor.withValues(alpha: surfaceHighAlpha), appScheme.surfaceContainerHighest, ); - final outlineColor = containerScheme.primary.withValues( - alpha: outlineBorderAlpha, - ); + final outlineColor = accentColor.withValues(alpha: outlineBorderAlpha); return ContainerColorPalette( - accentColor: containerScheme.primary, - onAccentColor: containerScheme.onPrimary, - containerColor: containerScheme.primaryContainer, - onContainerColor: containerScheme.onPrimaryContainer, + accentColor: accentColor, + onAccentColor: onAccentColor, + containerColor: containerColor, + onContainerColor: onContainerColor, surfaceColor: surfaceColor, surfaceHighColor: surfaceHighColor, outlineColor: outlineColor, backgroundColor: surfaceColor, - selectedBackgroundColor: containerScheme.primaryContainer, + selectedBackgroundColor: containerColor, borderSide: BorderSide(color: outlineColor), selectedBorderSide: const BorderSide(color: Colors.transparent), foregroundColor: appScheme.onSurfaceVariant, - selectedForegroundColor: containerScheme.onPrimaryContainer, - badgeBackgroundColor: containerScheme.primary, - badgeForegroundColor: containerScheme.onPrimary, - avatarColor: containerScheme.primary, - selectedAvatarColor: containerScheme.onPrimaryContainer, + selectedForegroundColor: onContainerColor, + badgeBackgroundColor: accentColor, + badgeForegroundColor: onAccentColor, + avatarColor: accentColor, + selectedAvatarColor: onContainerColor, avatarBackgroundColor: surfaceHighColor, - avatarForegroundColor: containerScheme.primary, + avatarForegroundColor: accentColor, ); } /// Returns the full opacity version of a container color. - /// - /// Useful when you need the original color for comparison or display - /// in contexts where full opacity is needed. - /// - /// [baseColor] The color to ensure has full opacity static Color fullOpacity(Color baseColor) { return baseColor.withValues(alpha: 1.0); } + + /// Shifts the color in HCT to a tone suitable for use as an accent against + /// the theme surface. Light theme uses T40 (darker); dark theme uses T80 + /// (lighter). Mirrors M3's primary tone targets. + static Color _shiftTone(Color color, Brightness brightness) { + final hct = Hct.fromInt(color.toARGB32()); + final targetTone = brightness == Brightness.light ? 40.0 : 80.0; + return Color(Hct.from(hct.hue, hct.chroma, targetTone).toInt()); + } + + /// Picks whichever of black or white has higher contrast against + /// [background]. + static Color _contrastingForeground(Color background) { + final blackContrast = _contrastRatio(background, Colors.black); + final whiteContrast = _contrastRatio(background, Colors.white); + return blackContrast >= whiteContrast ? Colors.black : Colors.white; + } + + static double _contrastRatio(Color a, Color b) { + final aLuminance = a.computeLuminance(); + final bLuminance = b.computeLuminance(); + final lighter = aLuminance > bLuminance ? aLuminance : bLuminance; + final darker = aLuminance > bLuminance ? bLuminance : aLuminance; + return (lighter + 0.05) / (darker + 0.05); + } } diff --git a/apps/weblibre/lib/presentation/widgets/url_list_tile.dart b/apps/weblibre/lib/presentation/widgets/url_list_tile.dart index b1a0a3f4..194cda78 100644 --- a/apps/weblibre/lib/presentation/widgets/url_list_tile.dart +++ b/apps/weblibre/lib/presentation/widgets/url_list_tile.dart @@ -28,6 +28,7 @@ class UrlListTile extends StatelessWidget { final Widget? leading; final Widget? trailing; final Color? containerColor; + final bool useCustomColor; final bool showHttpScheme; final VoidCallback? onTap; @@ -38,6 +39,7 @@ class UrlListTile extends StatelessWidget { this.leading, this.trailing, this.containerColor, + this.useCustomColor = false, this.showHttpScheme = true, this.onTap, }); @@ -50,7 +52,11 @@ class UrlListTile extends StatelessWidget { final textTheme = Theme.of(context).textTheme; final colorScheme = Theme.of(context).colorScheme; final containerPalette = containerColor != null - ? ContainerColors.palette(context, containerColor!) + ? ContainerColors.palette( + context, + containerColor!, + useCustomColor: useCustomColor, + ) : null; return Container(