improve container color selector

This commit is contained in:
Fabian Freund
2026-05-28 20:13:44 +02:00
parent 880a704309
commit c78405dd8c
23 changed files with 753 additions and 618 deletions
@@ -41,7 +41,7 @@ final class TabRepositoryProvider
} }
} }
String _$tabRepositoryHash() => r'7d41038ac5a20ecf6de0645b99231fb8b2f6963a'; String _$tabRepositoryHash() => r'd7b67460388c264f477325266a3299fe6df2df07';
abstract class _$TabRepository extends $Notifier<void> { abstract class _$TabRepository extends $Notifier<void> {
void build(); void build();
@@ -248,7 +248,11 @@ class _ContainerHeader extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final containerColor = container.color; final containerColor = container.color;
final containerPalette = ContainerColors.palette(context, containerColor); final containerPalette = ContainerColors.palette(
context,
containerColor,
useCustomColor: container.metadata.useCustomColor,
);
return Container( return Container(
width: 112, width: 112,
@@ -40,9 +40,14 @@ import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart'; import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
class CompactAppBarTitle extends ConsumerWidget { class CompactAppBarTitle extends ConsumerWidget {
const CompactAppBarTitle({super.key, this.containerColor}); const CompactAppBarTitle({
super.key,
this.containerColor,
this.useCustomColor = false,
});
final Color? containerColor; final Color? containerColor;
final bool useCustomColor;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -77,6 +82,7 @@ class CompactAppBarTitle extends ConsumerWidget {
siteSettingsBadgeState: siteSettingsBadgeState, siteSettingsBadgeState: siteSettingsBadgeState,
longPressUrlCopy: settings.tabBarLongPressUrlCopy, longPressUrlCopy: settings.tabBarLongPressUrlCopy,
containerColor: containerColor, containerColor: containerColor,
useCustomColor: useCustomColor,
sandboxSourceUri: sandboxSourceUri, sandboxSourceUri: sandboxSourceUri,
onSiteSettingsTap: () { onSiteSettingsTap: () {
ref ref
@@ -105,6 +111,7 @@ class CompactAppBarTitleView extends StatelessWidget {
this.tabIcon, this.tabIcon,
this.longPressUrlCopy = true, this.longPressUrlCopy = true,
this.containerColor, this.containerColor,
this.useCustomColor = false,
this.sandboxSourceUri, this.sandboxSourceUri,
}); });
@@ -116,6 +123,7 @@ class CompactAppBarTitleView extends StatelessWidget {
final Widget? tabIcon; final Widget? tabIcon;
final bool longPressUrlCopy; final bool longPressUrlCopy;
final Color? containerColor; final Color? containerColor;
final bool useCustomColor;
final Uri? sandboxSourceUri; final Uri? sandboxSourceUri;
@override @override
@@ -124,7 +132,11 @@ class CompactAppBarTitleView extends StatelessWidget {
final appColors = AppColors.of(context); final appColors = AppColors.of(context);
final containerColor = this.containerColor; final containerColor = this.containerColor;
final containerPalette = containerColor != null final containerPalette = containerColor != null
? ContainerColors.palette(context, containerColor) ? ContainerColors.palette(
context,
containerColor,
useCustomColor: useCustomColor,
)
: null; : null;
return Row( return Row(
@@ -241,9 +253,14 @@ class CompactAppBarTitleView extends StatelessWidget {
} }
class AppBarTitle extends ConsumerWidget { class AppBarTitle extends ConsumerWidget {
const AppBarTitle({super.key, this.containerColor}); const AppBarTitle({
super.key,
this.containerColor,
this.useCustomColor = false,
});
final Color? containerColor; final Color? containerColor;
final bool useCustomColor;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -278,6 +295,7 @@ class AppBarTitle extends ConsumerWidget {
siteSettingsBadgeState: siteSettingsBadgeState, siteSettingsBadgeState: siteSettingsBadgeState,
longPressUrlCopy: settings.tabBarLongPressUrlCopy, longPressUrlCopy: settings.tabBarLongPressUrlCopy,
containerColor: containerColor, containerColor: containerColor,
useCustomColor: useCustomColor,
sandboxSourceUri: sandboxSourceUri, sandboxSourceUri: sandboxSourceUri,
onSiteSettingsTap: () { onSiteSettingsTap: () {
ref ref
@@ -306,6 +324,7 @@ class AppBarTitleView extends StatelessWidget {
required this.longPressUrlCopy, required this.longPressUrlCopy,
this.tabIcon, this.tabIcon,
this.containerColor, this.containerColor,
this.useCustomColor = false,
this.sandboxSourceUri, this.sandboxSourceUri,
}); });
@@ -317,6 +336,7 @@ class AppBarTitleView extends StatelessWidget {
final Widget? tabIcon; final Widget? tabIcon;
final bool longPressUrlCopy; final bool longPressUrlCopy;
final Color? containerColor; final Color? containerColor;
final bool useCustomColor;
final Uri? sandboxSourceUri; final Uri? sandboxSourceUri;
@override @override
@@ -325,7 +345,11 @@ class AppBarTitleView extends StatelessWidget {
final appColors = AppColors.of(context); final appColors = AppColors.of(context);
final containerColor = this.containerColor; final containerColor = this.containerColor;
final containerPalette = containerColor != null final containerPalette = containerColor != null
? ContainerColors.palette(context, containerColor) ? ContainerColors.palette(
context,
containerColor,
useCustomColor: useCustomColor,
)
: null; : null;
return Row( return Row(
@@ -236,6 +236,11 @@ class BrowserTabBar extends HookConsumerWidget {
selectedTabId, selectedTabId,
).select((data) => data.value?.color), ).select((data) => data.value?.color),
); );
final containerUseCustomColor = ref.watch(
watchTabContainerDataProvider(
selectedTabId,
).select((data) => data.value?.metadata.useCustomColor ?? false),
);
final quickTabSwitcherMode = settings.effectiveUiQuickTabSwitcherMode(); final quickTabSwitcherMode = settings.effectiveUiQuickTabSwitcherMode();
@@ -251,8 +256,14 @@ class BrowserTabBar extends HookConsumerWidget {
displayedSheet is! ViewTabsSheet) displayedSheet is! ViewTabsSheet)
? containerColor ? containerColor
: null; : null;
final effectiveUseCustomColor =
effectiveContainerColor != null && containerUseCustomColor;
final effectiveContainerPalette = effectiveContainerColor != null final effectiveContainerPalette = effectiveContainerColor != null
? ContainerColors.palette(context, effectiveContainerColor) ? ContainerColors.palette(
context,
effectiveContainerColor,
useCustomColor: effectiveUseCustomColor,
)
: null; : null;
return BrowserTabBarView( return BrowserTabBarView(
@@ -264,8 +275,14 @@ class BrowserTabBar extends HookConsumerWidget {
backgroundColor: effectiveContainerPalette?.surfaceColor, backgroundColor: effectiveContainerPalette?.surfaceColor,
title: showTabTitle title: showTabTitle
? settings.tabBarLayout == TabBarLayout.compact ? settings.tabBarLayout == TabBarLayout.compact
? CompactAppBarTitle(containerColor: effectiveContainerColor) ? CompactAppBarTitle(
: AppBarTitle(containerColor: effectiveContainerColor) containerColor: effectiveContainerColor,
useCustomColor: effectiveUseCustomColor,
)
: AppBarTitle(
containerColor: effectiveContainerColor,
useCustomColor: effectiveUseCustomColor,
)
: null, : null,
actions: [ actions: [
const PinnedAddonBar(), const PinnedAddonBar(),
@@ -457,6 +474,7 @@ class BrowserTabBarView extends StatelessWidget {
class QuickTabSwitcherItem with FastEquatable { class QuickTabSwitcherItem with FastEquatable {
final Color? color; final Color? color;
final bool useCustomColor;
final String id; final String id;
final bool isActive; final bool isActive;
final TabMode tabMode; final TabMode tabMode;
@@ -478,6 +496,7 @@ class QuickTabSwitcherItem with FastEquatable {
required this.title, required this.title,
required this.url, required this.url,
required this.avatar, required this.avatar,
this.useCustomColor = false,
this.isSandbox = false, this.isSandbox = false,
this.depth = 0, this.depth = 0,
}); });
@@ -485,6 +504,7 @@ class QuickTabSwitcherItem with FastEquatable {
@override @override
List<Object?> get hashParameters => [ List<Object?> get hashParameters => [
color, color,
useCustomColor,
id, id,
isActive, isActive,
tabMode, tabMode,
@@ -577,6 +597,7 @@ class QuickTabSwitcher extends HookConsumerWidget {
: state.$1.titleOrAuthority; : state.$1.titleOrAuthority;
return QuickTabSwitcherItem( return QuickTabSwitcherItem(
color: state.$2?.color, color: state.$2?.color,
useCustomColor: state.$2?.metadata.useCustomColor ?? false,
id: state.$1.id, id: state.$1.id,
isActive: state.$1.id == selectedTabId, isActive: state.$1.id == selectedTabId,
title: displayTitle, title: displayTitle,
@@ -910,16 +931,26 @@ class QuickTabSwitcherView extends StatelessWidget {
final color? when isSelected => ContainerColors.palette( final color? when isSelected => ContainerColors.palette(
context, context,
color, color,
useCustomColor: item.useCustomColor,
).selectedBackgroundColor, ).selectedBackgroundColor,
final color? => ContainerColors.palette(context, color).backgroundColor, final color? => ContainerColors.palette(
context,
color,
useCustomColor: item.useCustomColor,
).backgroundColor,
null => null, null => null,
}, },
side: (item, isSelected) => switch (item.color) { side: (item, isSelected) => switch (item.color) {
final color? when isSelected => ContainerColors.palette( final color? when isSelected => ContainerColors.palette(
context, context,
color, color,
useCustomColor: item.useCustomColor,
).selectedBorderSide, ).selectedBorderSide,
final color? => ContainerColors.palette(context, color).borderSide, final color? => ContainerColors.palette(
context,
color,
useCustomColor: item.useCustomColor,
).borderSide,
null => null, null => null,
}, },
labelPadding: (item) => labelPadding: (item) =>
@@ -1015,8 +1046,13 @@ class QuickTabSwitcherView extends StatelessWidget {
? ContainerColors.palette( ? ContainerColors.palette(
context, context,
color, color,
useCustomColor: item.useCustomColor,
).selectedForegroundColor ).selectedForegroundColor
: ContainerColors.palette(context, color).foregroundColor, : ContainerColors.palette(
context,
color,
useCustomColor: item.useCustomColor,
).foregroundColor,
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
), ),
child: row, child: row,
@@ -67,6 +67,8 @@ class RecentTabsSection extends ConsumerWidget {
iconSize: UrlListTile.iconSize, iconSize: UrlListTile.iconSize,
), ),
containerColor: containerData?.color, containerColor: containerData?.color,
useCustomColor:
containerData?.metadata.useCustomColor ?? false,
onTap: () => onTabSelected(tabState.id), onTap: () => onTabSelected(tabState.id),
); );
}, },
@@ -53,6 +53,12 @@ class ContainerMetadata with FastEquatable {
@JsonKey(defaultValue: false) @JsonKey(defaultValue: false)
final bool bypassGlobalProxy; 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<Uri>? assignedSites; final List<Uri>? assignedSites;
ContainerMetadata({ ContainerMetadata({
@@ -62,6 +68,7 @@ class ContainerMetadata with FastEquatable {
required this.clearDataOnExit, required this.clearDataOnExit,
required this.excludeFromIndex, required this.excludeFromIndex,
required this.bypassGlobalProxy, required this.bypassGlobalProxy,
required this.useCustomColor,
required this.assignedSites, required this.assignedSites,
}); });
@@ -72,6 +79,7 @@ class ContainerMetadata with FastEquatable {
bool? clearDataOnExit, bool? clearDataOnExit,
bool? excludeFromIndex, bool? excludeFromIndex,
bool? bypassGlobalProxy, bool? bypassGlobalProxy,
bool? useCustomColor,
List<Uri>? assignedSites, List<Uri>? assignedSites,
}) : this( }) : this(
iconData: iconData, iconData: iconData,
@@ -80,6 +88,7 @@ class ContainerMetadata with FastEquatable {
clearDataOnExit: clearDataOnExit ?? false, clearDataOnExit: clearDataOnExit ?? false,
excludeFromIndex: excludeFromIndex ?? false, excludeFromIndex: excludeFromIndex ?? false,
bypassGlobalProxy: bypassGlobalProxy ?? false, bypassGlobalProxy: bypassGlobalProxy ?? false,
useCustomColor: useCustomColor ?? false,
assignedSites: assignedSites, assignedSites: assignedSites,
); );
@@ -98,6 +107,7 @@ class ContainerMetadata with FastEquatable {
clearDataOnExit, clearDataOnExit,
excludeFromIndex, excludeFromIndex,
bypassGlobalProxy, bypassGlobalProxy,
useCustomColor,
assignedSites, assignedSites,
]; ];
} }
@@ -19,6 +19,8 @@ abstract class _$ContainerMetadataCWProxy {
ContainerMetadata bypassGlobalProxy(bool bypassGlobalProxy); ContainerMetadata bypassGlobalProxy(bool bypassGlobalProxy);
ContainerMetadata useCustomColor(bool useCustomColor);
ContainerMetadata assignedSites(List<Uri>? assignedSites); ContainerMetadata assignedSites(List<Uri>? assignedSites);
/// Creates a new instance with the provided field values. /// Creates a new instance with the provided field values.
@@ -35,6 +37,7 @@ abstract class _$ContainerMetadataCWProxy {
bool clearDataOnExit, bool clearDataOnExit,
bool excludeFromIndex, bool excludeFromIndex,
bool bypassGlobalProxy, bool bypassGlobalProxy,
bool useCustomColor,
List<Uri>? assignedSites, List<Uri>? assignedSites,
}); });
} }
@@ -69,6 +72,10 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy {
ContainerMetadata bypassGlobalProxy(bool bypassGlobalProxy) => ContainerMetadata bypassGlobalProxy(bool bypassGlobalProxy) =>
call(bypassGlobalProxy: bypassGlobalProxy); call(bypassGlobalProxy: bypassGlobalProxy);
@override
ContainerMetadata useCustomColor(bool useCustomColor) =>
call(useCustomColor: useCustomColor);
@override @override
ContainerMetadata assignedSites(List<Uri>? assignedSites) => ContainerMetadata assignedSites(List<Uri>? assignedSites) =>
call(assignedSites: assignedSites); call(assignedSites: assignedSites);
@@ -88,6 +95,7 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy {
Object? clearDataOnExit = const $CopyWithPlaceholder(), Object? clearDataOnExit = const $CopyWithPlaceholder(),
Object? excludeFromIndex = const $CopyWithPlaceholder(), Object? excludeFromIndex = const $CopyWithPlaceholder(),
Object? bypassGlobalProxy = const $CopyWithPlaceholder(), Object? bypassGlobalProxy = const $CopyWithPlaceholder(),
Object? useCustomColor = const $CopyWithPlaceholder(),
Object? assignedSites = const $CopyWithPlaceholder(), Object? assignedSites = const $CopyWithPlaceholder(),
}) { }) {
return ContainerMetadata( return ContainerMetadata(
@@ -121,6 +129,12 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy {
? _value.bypassGlobalProxy ? _value.bypassGlobalProxy
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
: bypassGlobalProxy as bool, : bypassGlobalProxy as bool,
useCustomColor:
useCustomColor == const $CopyWithPlaceholder() ||
useCustomColor == null
? _value.useCustomColor
// ignore: cast_nullable_to_non_nullable
: useCustomColor as bool,
assignedSites: assignedSites == const $CopyWithPlaceholder() assignedSites: assignedSites == const $CopyWithPlaceholder()
? _value.assignedSites ? _value.assignedSites
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
@@ -262,6 +276,7 @@ ContainerMetadata _$ContainerMetadataFromJson(Map<String, dynamic> json) =>
clearDataOnExit: json['clearDataOnExit'] as bool? ?? false, clearDataOnExit: json['clearDataOnExit'] as bool? ?? false,
excludeFromIndex: json['excludeFromIndex'] as bool? ?? false, excludeFromIndex: json['excludeFromIndex'] as bool? ?? false,
bypassGlobalProxy: json['bypassGlobalProxy'] as bool? ?? false, bypassGlobalProxy: json['bypassGlobalProxy'] as bool? ?? false,
useCustomColor: json['useCustomColor'] as bool? ?? false,
assignedSites: (json['assignedSites'] as List<dynamic>?) assignedSites: (json['assignedSites'] as List<dynamic>?)
?.map((e) => Uri.parse(e as String)) ?.map((e) => Uri.parse(e as String))
.toList(), .toList(),
@@ -279,6 +294,7 @@ Map<String, dynamic> _$ContainerMetadataToJson(
'clearDataOnExit': instance.clearDataOnExit, 'clearDataOnExit': instance.clearDataOnExit,
'excludeFromIndex': instance.excludeFromIndex, 'excludeFromIndex': instance.excludeFromIndex,
'bypassGlobalProxy': instance.bypassGlobalProxy, 'bypassGlobalProxy': instance.bypassGlobalProxy,
'useCustomColor': instance.useCustomColor,
'assignedSites': instance.assignedSites?.map((e) => e.toString()).toList(), 'assignedSites': instance.assignedSites?.map((e) => e.toString()).toList(),
}; };
@@ -17,9 +17,9 @@
* You should have received a copy of the GNU Affero General Public License * You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import 'dart:math';
import 'dart:ui'; import 'dart:ui';
import 'package:collection/collection.dart';
import 'package:nullability/nullability.dart'; import 'package:nullability/nullability.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/uuid.dart'; import 'package:weblibre/core/uuid.dart';
@@ -299,22 +299,12 @@ class ContainerRepository extends _$ContainerRepository {
} }
Future<Color> unusedRandomContainerColor() async { Future<Color> unusedRandomContainerColor() async {
final usedColors = await getDistinctColors(); final usedColors = (await getDistinctColors()).toSet();
final unusedColorTypes = colorTypes.where((colors) { final unused = containerSeedColors
return !shadingTypes( .where((color) => !usedColors.contains(color))
colors, .toList();
).any((shade) => usedColors.contains(shade.keys.first)); final pool = unused.isNotEmpty ? unused : containerSeedColors;
}).toList(); return pool[Random().nextInt(pool.length)];
final availableColors =
(unusedColorTypes.isNotEmpty ? unusedColorTypes : colorTypes).flattened
.toList();
Color randomColor;
do {
randomColor = randomColorShade(availableColors);
} while (usedColors.contains(randomColor));
return randomColor;
} }
Future<ContainerData> createNewContainer() async { Future<ContainerData> createNewContainer() async {
@@ -42,7 +42,7 @@ final class ContainerRepositoryProvider
} }
String _$containerRepositoryHash() => String _$containerRepositoryHash() =>
r'14c77b4f3ed21d9511d5bf507db013b02a122595'; r'b967f8948baf4347346a1369ad48a476da69e3a2';
abstract class _$ContainerRepository extends $Notifier<void> { abstract class _$ContainerRepository extends $Notifier<void> {
void build(); void build();
@@ -88,6 +88,7 @@ class ContainerEditScreen extends HookConsumerWidget {
); );
final selectedColor = useState(initialContainer.color); final selectedColor = useState(initialContainer.color);
final useCustomColor = useState(initialContainer.metadata.useCustomColor);
final selectedIcon = useState(initialContainer.metadata.iconData); final selectedIcon = useState(initialContainer.metadata.iconData);
final contextualIdentity = useState( final contextualIdentity = useState(
initialContainer.metadata.contextualIdentity, initialContainer.metadata.contextualIdentity,
@@ -129,6 +130,7 @@ class ContainerEditScreen extends HookConsumerWidget {
contextualIdentity.value != null && contextualIdentity.value != null &&
proxyConnectionId.value == null && proxyConnectionId.value == null &&
bypassGlobalProxy.value, bypassGlobalProxy.value,
useCustomColor: useCustomColor.value,
assignedSites: assignedSites.value, assignedSites: assignedSites.value,
), ),
); );
@@ -160,13 +162,17 @@ class ContainerEditScreen extends HookConsumerWidget {
} }
Future<void> openColorPicker() async { Future<void> openColorPicker() async {
final color = await showDialog<Color?>( final result = await showDialog<ColorPickerResult?>(
context: context, context: context,
builder: (context) => ColorPickerDialog(selectedColor.value), builder: (context) => ColorPickerDialog(
selectedColor.value,
initialUseCustomColor: useCustomColor.value,
),
); );
if (color != null) { if (result != null) {
selectedColor.value = color; selectedColor.value = result.color;
useCustomColor.value = result.useCustomColor;
} }
} }
@@ -179,6 +185,7 @@ class ContainerEditScreen extends HookConsumerWidget {
heightFactor: 0.92, heightFactor: 0.92,
child: ContainerIconPickerSheet( child: ContainerIconPickerSheet(
selectedColor: selectedColor.value, selectedColor: selectedColor.value,
useCustomColor: useCustomColor.value,
selectedIcon: resolveContainerIcon(selectedIcon.value), selectedIcon: resolveContainerIcon(selectedIcon.value),
onSelected: (iconData) => Navigator.of(context).pop(iconData), onSelected: (iconData) => Navigator.of(context).pop(iconData),
), ),
@@ -245,6 +252,7 @@ class ContainerEditScreen extends HookConsumerWidget {
final previewPalette = ContainerColors.palette( final previewPalette = ContainerColors.palette(
context, context,
selectedColor.value, selectedColor.value,
useCustomColor: useCustomColor.value,
); );
final assignedSiteCount = assignedSites.value?.length ?? 0; final assignedSiteCount = assignedSites.value?.length ?? 0;
final canPickProxy = final canPickProxy =
@@ -183,7 +183,11 @@ class _ContainerCard extends HookConsumerWidget {
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
final containerColor = container.color; final containerColor = container.color;
final tabCount = container.tabCount ?? 0; 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 proxyOptions = ref.watch(proxyConnectionOptionsProvider);
final proxyOptionsState = ref.watch(singboxProxyProfilesRepositoryProvider); final proxyOptionsState = ref.watch(singboxProxyProfilesRepositoryProvider);
final proxyOptionsLoading = final proxyOptionsLoading =
@@ -240,7 +240,11 @@ class _SelectionContainerCard extends ConsumerWidget {
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
final containerColor = container.color; final containerColor = container.color;
final tabCount = container.tabCount ?? 0; 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 proxyOptions = ref.watch(proxyConnectionOptionsProvider);
final proxyOptionsState = ref.watch(singboxProxyProfilesRepositoryProvider); final proxyOptionsState = ref.watch(singboxProxyProfilesRepositoryProvider);
@@ -19,53 +19,206 @@
*/ */
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.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'; import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart';
typedef ColorPickerResult = ({Color color, bool useCustomColor});
class ColorPickerDialog extends HookWidget { class ColorPickerDialog extends HookWidget {
final Color initialColor; final Color initialColor;
final bool initialUseCustomColor;
const ColorPickerDialog(this.initialColor, {super.key}); const ColorPickerDialog(
this.initialColor, {
this.initialUseCustomColor = false,
super.key,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final selectedColor = useState<Color>(initialColor); final selectedColor = useState<Color>(initialColor);
final useCustom = useState<bool>(initialUseCustomColor);
Future<void> openCustomPicker() async {
final result = await showDialog<Color?>(
context: context,
builder: (_) => CustomColorPickerDialog(selectedColor.value),
);
if (result != null) {
selectedColor.value = result;
useCustom.value = true;
}
}
return AlertDialog( return AlertDialog(
titlePadding: const EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 16.0), titlePadding: const EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 16.0),
contentPadding: const EdgeInsets.only( contentPadding: const EdgeInsets.symmetric(
left: 20.0, horizontal: 20.0,
right: 20.0, vertical: 8.0,
bottom: 24.0,
), ),
insetPadding: const EdgeInsets.symmetric( insetPadding: const EdgeInsets.symmetric(
horizontal: 20.0, horizontal: 20.0,
vertical: 24.0, vertical: 24.0,
), ),
title: const Text('Select Color'), title: const Text('Select Color'),
content: MaterialPicker( content: _ContainerColorGrid(
pickerColor: selectedColor.value, selectedColor: selectedColor.value,
onColorChanged: (value) { useCustomColor: useCustom.value,
selectedColor.value = value; onSeedSelected: (color) {
}, selectedColor.value = color;
displayColorBuilder: (context, color) { useCustom.value = false;
return ContainerColors.palette(context, color).containerColor;
}, },
onCustomTapped: openCustomPicker,
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: () { onPressed: () => Navigator.pop<ColorPickerResult?>(context),
Navigator.pop<Color?>(context);
},
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
TextButton( TextButton(
onPressed: () { onPressed: () => Navigator.pop<ColorPickerResult?>(
Navigator.pop<Color?>(context, selectedColor.value); context,
}, (color: selectedColor.value, useCustomColor: useCustom.value),
),
child: const Text('Select'), 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<Color> 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),
),
),
);
}
}
@@ -61,7 +61,11 @@ class CompactContainerSelector extends ConsumerWidget {
final isSelected = selectedContainer != null; final isSelected = selectedContainer != null;
final accentColor = selectedContainer?.color ?? colorScheme.primary; final accentColor = selectedContainer?.color ?? colorScheme.primary;
final showSelectedHighlight = isSelected && emphasizeSelection; final showSelectedHighlight = isSelected && emphasizeSelection;
final palette = ContainerColors.palette(context, accentColor); final palette = ContainerColors.palette(
context,
accentColor,
useCustomColor: selectedContainer?.metadata.useCustomColor ?? false,
);
return GestureDetector( return GestureDetector(
onLongPress: isSelected onLongPress: isSelected
@@ -31,7 +31,11 @@ Widget? buildContainerChipAvatar(
bool isSelected, { bool isSelected, {
double size = 18, 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( return chipContainerIcon(container.metadata.iconData).mapNotNull(
(iconData) => Icon( (iconData) => Icon(
@@ -48,7 +52,11 @@ Widget buildContainerChipLabel(
bool isSelected, { bool isSelected, {
Widget? trailing, Widget? trailing,
}) { }) {
final palette = ContainerColors.palette(context, container.color); final palette = ContainerColors.palette(
context,
container.color,
useCustomColor: container.metadata.useCustomColor,
);
final foregroundColor = isSelected final foregroundColor = isSelected
? palette.selectedForegroundColor ? palette.selectedForegroundColor
: palette.foregroundColor; : palette.foregroundColor;
@@ -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/inline_count_badge.dart';
import 'package:weblibre/presentation/widgets/selectable_chips.dart'; import 'package:weblibre/presentation/widgets/selectable_chips.dart';
ContainerColorPalette _palette(BuildContext context, Color color) { ContainerColorPalette _palette(
return ContainerColors.palette(context, color); BuildContext context,
Color color, {
bool useCustomColor = false,
}) {
return ContainerColors.palette(
context,
color,
useCustomColor: useCustomColor,
);
} }
Color _chipColor(BuildContext context, Color color, bool isSelected) { Color _chipColor(
final palette = _palette(context, color); BuildContext context,
Color color,
bool isSelected, {
bool useCustomColor = false,
}) {
final palette = _palette(context, color, useCustomColor: useCustomColor);
return isSelected ? palette.selectedBackgroundColor : palette.backgroundColor; return isSelected ? palette.selectedBackgroundColor : palette.backgroundColor;
} }
BorderSide _chipSide(BuildContext context, Color color, bool isSelected) { BorderSide _chipSide(
final palette = _palette(context, color); BuildContext context,
Color color,
bool isSelected, {
bool useCustomColor = false,
}) {
final palette = _palette(context, color, useCustomColor: useCustomColor);
return isSelected ? palette.selectedBorderSide : palette.borderSide; return isSelected ? palette.selectedBorderSide : palette.borderSide;
} }
InlineCountBadge _countBadge(BuildContext context, Color color, int count) { InlineCountBadge _countBadge(
final palette = _palette(context, color); BuildContext context,
Color color,
int count, {
bool useCustomColor = false,
}) {
final palette = _palette(context, color, useCustomColor: useCustomColor);
return InlineCountBadge( return InlineCountBadge(
count: count, count: count,
backgroundColor: palette.badgeBackgroundColor, backgroundColor: palette.badgeBackgroundColor,
@@ -404,10 +427,18 @@ class ContainerChips extends HookConsumerWidget {
cacheExtent: 500, cacheExtent: 500,
itemId: (container) => container.id, itemId: (container) => container.id,
decoration: SelectableChipDecoration( decoration: SelectableChipDecoration(
color: (container, isSelected) => color: (container, isSelected) => _chipColor(
_chipColor(context, container.color, isSelected), context,
side: (container, isSelected) => container.color,
_chipSide(context, container.color, isSelected), isSelected,
useCustomColor: container.metadata.useCustomColor,
),
side: (container, isSelected) => _chipSide(
context,
container.color,
isSelected,
useCustomColor: container.metadata.useCustomColor,
),
), ),
itemAvatar: (container) { itemAvatar: (container) {
final isSelected = final isSelected =
@@ -432,7 +463,13 @@ class ContainerChips extends HookConsumerWidget {
container, container,
isSelected, isSelected,
trailing: count != null && count > 0 trailing: count != null && count > 0
? _countBadge(context, container.color, count) ? _countBadge(
context,
container.color,
count,
useCustomColor:
container.metadata.useCustomColor,
)
: null, : null,
); );
}, },
@@ -42,17 +42,23 @@ class ContainerIconPickerSheet extends HookWidget {
required this.selectedColor, required this.selectedColor,
required this.selectedIcon, required this.selectedIcon,
required this.onSelected, required this.onSelected,
this.useCustomColor = false,
super.key, super.key,
}); });
final Color selectedColor; final Color selectedColor;
final IconData selectedIcon; final IconData selectedIcon;
final bool useCustomColor;
final ValueChanged<IconData> onSelected; final ValueChanged<IconData> onSelected;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final palette = ContainerColors.palette(context, selectedColor); final palette = ContainerColors.palette(
context,
selectedColor,
useCustomColor: useCustomColor,
);
final searchController = useTextEditingController(); final searchController = useTextEditingController();
useListenable(searchController); useListenable(searchController);
@@ -38,7 +38,11 @@ class ContainerListTile extends HookWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final palette = ContainerColors.palette(context, container.color); final palette = ContainerColors.palette(
context,
container.color,
useCustomColor: container.metadata.useCustomColor,
);
return ListTileTheme( return ListTileTheme(
selectedColor: palette.onContainerColor, selectedColor: palette.onContainerColor,
@@ -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 <http://www.gnu.org/licenses/>.
*/
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>(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<Color?>(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop<Color?>(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<double> 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);
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
// 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<Color> onColorChanged;
final ValueChanged<Color>? onPrimaryChanged;
final bool enableLabel;
final bool portraitOnly;
final double? displayAlpha;
final Color Function(BuildContext context, Color color)? displayColorBuilder;
@override
State<StatefulWidget> createState() => _MaterialPickerState();
}
class _MaterialPickerState extends State<MaterialPicker> {
List<Color> _currentColorType = [Colors.red, Colors.redAccent];
Color _currentShading = Colors.transparent;
@override
void initState() {
for (final colors in colorTypes) {
shadingTypes(colors).forEach((Map<Color, String> 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<Color> 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<Color, String> 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: <Widget>[
colorList(),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: shadingList(),
),
),
],
),
);
} else {
return SizedBox(
width: 500,
height: 300,
child: Column(
children: <Widget>[
colorList(),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: shadingList(),
),
),
],
),
);
}
}
}
@@ -17,117 +17,19 @@
* You should have received a copy of the GNU Affero General Public License * You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import 'dart:math';
import 'package:flutter/material.dart'; 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 // Evenly spaced HCT hues. ColorScheme.fromSeed extracts the seed's hue and
/// the background color, and optional bias. // normalizes tone, so spacing in HCT (not HSL) guarantees each swatch yields
/// // a perceptually distinct primaryContainer.
/// Reference: final List<Color> containerSeedColors = List<Color>.unmodifiable(
/// List.generate(_hueCount, (i) {
/// Old: https://www.w3.org/TR/WCAG20-TECHS/G18.html final hue = i * 360.0 / _hueCount;
/// return Color(Hct.from(hue, _seedChroma, _seedTone).toInt());
/// 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<List<Color>> 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<Map<Color, String>> shadingTypes(List<Color> colors) {
final List<Map<Color, String>> 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<Color> colors) {
final color = colors[_rnd.nextInt(colors.length)];
final shades = shadingTypes([color]);
return shades[_rnd.nextInt(shades.length)].keys.first;
}
@@ -18,6 +18,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:material_color_utilities/material_color_utilities.dart';
class ContainerColorPalette { class ContainerColorPalette {
const ContainerColorPalette({ const ContainerColorPalette({
@@ -65,8 +66,17 @@ class ContainerColorPalette {
/// Centralized helper for container color display and theming. /// Centralized helper for container color display and theming.
/// ///
/// This class converts the stored container seed color into Material 3 roles /// Converts the stored container color into Material 3 roles used
/// used consistently across the application. /// 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 { class ContainerColors {
ContainerColors._(); ContainerColors._();
@@ -74,55 +84,100 @@ class ContainerColors {
static const double surfaceHighAlpha = 0.28; static const double surfaceHighAlpha = 0.28;
static const double outlineBorderAlpha = 0.5; 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 theme = Theme.of(context);
final appScheme = theme.colorScheme; final appScheme = theme.colorScheme;
final containerScheme = ColorScheme.fromSeed( final fullColor = fullOpacity(color);
seedColor: fullOpacity(seedColor),
brightness: theme.brightness, 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( final surfaceColor = Color.alphaBlend(
containerScheme.primaryContainer.withValues(alpha: surfaceAlpha), containerColor.withValues(alpha: surfaceAlpha),
appScheme.surfaceContainer, appScheme.surfaceContainer,
); );
final surfaceHighColor = Color.alphaBlend( final surfaceHighColor = Color.alphaBlend(
containerScheme.primaryContainer.withValues(alpha: surfaceHighAlpha), containerColor.withValues(alpha: surfaceHighAlpha),
appScheme.surfaceContainerHighest, appScheme.surfaceContainerHighest,
); );
final outlineColor = containerScheme.primary.withValues( final outlineColor = accentColor.withValues(alpha: outlineBorderAlpha);
alpha: outlineBorderAlpha,
);
return ContainerColorPalette( return ContainerColorPalette(
accentColor: containerScheme.primary, accentColor: accentColor,
onAccentColor: containerScheme.onPrimary, onAccentColor: onAccentColor,
containerColor: containerScheme.primaryContainer, containerColor: containerColor,
onContainerColor: containerScheme.onPrimaryContainer, onContainerColor: onContainerColor,
surfaceColor: surfaceColor, surfaceColor: surfaceColor,
surfaceHighColor: surfaceHighColor, surfaceHighColor: surfaceHighColor,
outlineColor: outlineColor, outlineColor: outlineColor,
backgroundColor: surfaceColor, backgroundColor: surfaceColor,
selectedBackgroundColor: containerScheme.primaryContainer, selectedBackgroundColor: containerColor,
borderSide: BorderSide(color: outlineColor), borderSide: BorderSide(color: outlineColor),
selectedBorderSide: const BorderSide(color: Colors.transparent), selectedBorderSide: const BorderSide(color: Colors.transparent),
foregroundColor: appScheme.onSurfaceVariant, foregroundColor: appScheme.onSurfaceVariant,
selectedForegroundColor: containerScheme.onPrimaryContainer, selectedForegroundColor: onContainerColor,
badgeBackgroundColor: containerScheme.primary, badgeBackgroundColor: accentColor,
badgeForegroundColor: containerScheme.onPrimary, badgeForegroundColor: onAccentColor,
avatarColor: containerScheme.primary, avatarColor: accentColor,
selectedAvatarColor: containerScheme.onPrimaryContainer, selectedAvatarColor: onContainerColor,
avatarBackgroundColor: surfaceHighColor, avatarBackgroundColor: surfaceHighColor,
avatarForegroundColor: containerScheme.primary, avatarForegroundColor: accentColor,
); );
} }
/// Returns the full opacity version of a container color. /// 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) { static Color fullOpacity(Color baseColor) {
return baseColor.withValues(alpha: 1.0); 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);
}
} }
@@ -28,6 +28,7 @@ class UrlListTile extends StatelessWidget {
final Widget? leading; final Widget? leading;
final Widget? trailing; final Widget? trailing;
final Color? containerColor; final Color? containerColor;
final bool useCustomColor;
final bool showHttpScheme; final bool showHttpScheme;
final VoidCallback? onTap; final VoidCallback? onTap;
@@ -38,6 +39,7 @@ class UrlListTile extends StatelessWidget {
this.leading, this.leading,
this.trailing, this.trailing,
this.containerColor, this.containerColor,
this.useCustomColor = false,
this.showHttpScheme = true, this.showHttpScheme = true,
this.onTap, this.onTap,
}); });
@@ -50,7 +52,11 @@ class UrlListTile extends StatelessWidget {
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final containerPalette = containerColor != null final containerPalette = containerColor != null
? ContainerColors.palette(context, containerColor!) ? ContainerColors.palette(
context,
containerColor!,
useCustomColor: useCustomColor,
)
: null; : null;
return Container( return Container(