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> {
void build();
@@ -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,
@@ -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(
@@ -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<Object?> 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,
@@ -67,6 +67,8 @@ class RecentTabsSection extends ConsumerWidget {
iconSize: UrlListTile.iconSize,
),
containerColor: containerData?.color,
useCustomColor:
containerData?.metadata.useCustomColor ?? false,
onTap: () => onTabSelected(tabState.id),
);
},
@@ -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<Uri>? 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<Uri>? 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,
];
}
@@ -19,6 +19,8 @@ abstract class _$ContainerMetadataCWProxy {
ContainerMetadata bypassGlobalProxy(bool bypassGlobalProxy);
ContainerMetadata useCustomColor(bool useCustomColor);
ContainerMetadata assignedSites(List<Uri>? 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<Uri>? 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<Uri>? 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<String, dynamic> 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<dynamic>?)
?.map((e) => Uri.parse(e as String))
.toList(),
@@ -279,6 +294,7 @@ Map<String, dynamic> _$ContainerMetadataToJson(
'clearDataOnExit': instance.clearDataOnExit,
'excludeFromIndex': instance.excludeFromIndex,
'bypassGlobalProxy': instance.bypassGlobalProxy,
'useCustomColor': instance.useCustomColor,
'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
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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<Color> 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
final usedColors = (await getDistinctColors()).toSet();
final unused = containerSeedColors
.where((color) => !usedColors.contains(color))
.toList();
Color randomColor;
do {
randomColor = randomColorShade(availableColors);
} while (usedColors.contains(randomColor));
return randomColor;
final pool = unused.isNotEmpty ? unused : containerSeedColors;
return pool[Random().nextInt(pool.length)];
}
Future<ContainerData> createNewContainer() async {
@@ -42,7 +42,7 @@ final class ContainerRepositoryProvider
}
String _$containerRepositoryHash() =>
r'14c77b4f3ed21d9511d5bf507db013b02a122595';
r'b967f8948baf4347346a1369ad48a476da69e3a2';
abstract class _$ContainerRepository extends $Notifier<void> {
void build();
@@ -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<void> openColorPicker() async {
final color = await showDialog<Color?>(
final result = await showDialog<ColorPickerResult?>(
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 =
@@ -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 =
@@ -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);
@@ -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<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(
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<Color?>(context);
},
onPressed: () => Navigator.pop<ColorPickerResult?>(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Navigator.pop<Color?>(context, selectedColor.value);
},
onPressed: () => Navigator.pop<ColorPickerResult?>(
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<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 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
@@ -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;
@@ -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,
);
},
@@ -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<IconData> 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);
@@ -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,
@@ -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
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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<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(),
// 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<Color> containerSeedColors = List<Color>.unmodifiable(
List.generate(_hueCount, (i) {
final hue = i * 360.0 / _hueCount;
return Color(Hct.from(hue, _seedChroma, _seedTone).toInt());
}),
);
} 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/>.
*/
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),
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);
}
}
@@ -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(