Make toolbar icons and settings surfaces follow the active palette

Toolbar icons rendered three different ways under Liquid Glass. They now
all color the SF Symbol with the exact theme text color, and the sidebar
glass group rebuilds when brightness changes.

Settings screens take palette-derived grouped page and card colors on
iOS and Android, shared with the native UIKit sheets. Material container
roles, snackbars, form fields, and native sheet titles and bar buttons
now come from the palette instead of system or seed colors.

Tweakcn presets now match upstream exactly. The app keeps error text
legible where a preset's destructive color is meant only as a fill, and
softens saturated sidebar accents under sidebar text.
This commit is contained in:
cogwheel 2026-09-28 11:53:29 +05:30
parent 5814328427
commit d51ba8dcb5
38 changed files with 488 additions and 158 deletions

View file

@ -3391,7 +3391,12 @@ private final class NativeSheetSegmentTableViewCell: UITableViewCell {
}
func configure(item: NativeSheetItem, onValueChanged: @escaping (String) -> Void) {
// Re-read the theme on every bind: reused cells otherwise keep the
// palette that was active when they were first created.
NativeSheetSettingsStyle.applyCellStyle(self)
applyNativeSheetSegmentedControlTheme(segmentedControl)
titleLabel.textColor = NativeSheetTheme.shared.foreground
subtitleLabel.textColor = NativeSheetTheme.shared.secondaryForeground
titleLabel.text = item.title
if let subtitle = item.subtitle, !subtitle.isEmpty {
subtitleLabel.text = subtitle

View file

@ -59,10 +59,11 @@ final class NativeSheetTheme {
if controller is UIAlertController { return }
controller.view.tintColor = accent
applyBarButtonTheme(controller.navigationItem)
apply(to: controller.view)
if let navigationController = controller as? UINavigationController {
navigationController.navigationBar.tintColor = accent
applyNavigationBarTheme(navigationController.navigationBar)
navigationController.toolbar.tintColor = accent
}
@ -72,6 +73,47 @@ final class NativeSheetTheme {
}
}
/// Title and back chevron follow the palette foreground, matching the
/// Flutter toolbar icons. iOS 26 draws the back button as a glass control
/// that ignores the bar's tint, so the chevron is supplied pre-colored.
private func applyNavigationBarTheme(_ bar: UINavigationBar) {
bar.tintColor = accent
let titleAttributes: [NSAttributedString.Key: Any] = [
.foregroundColor: foreground,
]
bar.titleTextAttributes = titleAttributes
bar.largeTitleTextAttributes = titleAttributes
let chevron = UIImage(systemName: "chevron.backward")?
.withTintColor(foreground, renderingMode: .alwaysOriginal)
func themed(_ appearance: UINavigationBarAppearance) -> UINavigationBarAppearance {
let copy = appearance.copy()
copy.titleTextAttributes = titleAttributes
copy.largeTitleTextAttributes = titleAttributes
copy.setBackIndicatorImage(chevron, transitionMaskImage: chevron)
return copy
}
bar.standardAppearance = themed(bar.standardAppearance)
if let scrollEdge = bar.scrollEdgeAppearance {
bar.scrollEdgeAppearance = themed(scrollEdge)
}
if let compact = bar.compactAppearance {
bar.compactAppearance = themed(compact)
}
}
/// Icon-only bar buttons (close, ellipsis, add) match the title and back
/// chevron; titled actions such as Save keep the accent. iOS 26 renders
/// glass bar buttons with the label color unless the item sets a tint.
func applyBarButtonTheme(_ item: UINavigationItem) {
let items = (item.leftBarButtonItems ?? []) + (item.rightBarButtonItems ?? [])
for button in items
where button.title == nil && button.customView == nil && button.style != .done {
// `.done` confirm buttons render as accent-filled glass; keep them.
button.tintColor = foreground
}
}
/// Tints a presented alert without walking its private subviews. Call this
/// after `present(_:animated:)`, when UIKit has loaded the alert's view for
/// the presentation it is part of.
@ -120,6 +162,15 @@ final class NativeSheetNavigationController: UINavigationController {
navigationBar.prefersLargeTitles = false
NativeSheetTheme.shared.apply(to: self)
}
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
// Pushed controllers install their bar buttons in viewDidLoad, after
// the sheet was themed, so theme the visible items on each layout.
if let item = topViewController?.navigationItem {
NativeSheetTheme.shared.applyBarButtonTheme(item)
}
}
}
func applyNativeSheetSegmentedControlTheme(_ control: UISegmentedControl) {

View file

@ -23,18 +23,6 @@ void _logNativeSheetBridgeError(
);
}
/// Orders a light palette's page and card colors for a grouped sheet: the
/// darker (tinted) color becomes the page and the lighter one the cards.
///
/// Palettes disagree on which token is tinted, so an unconditional swap
/// would invert palettes that already put white cards on a tinted page.
(Color, Color) resolveGroupedSheetColors({
required Color page,
required Color card,
}) => page.computeLuminance() <= card.computeLuminance()
? (page, card)
: (card, page);
@immutable
class NativeSheetThemeConfig {
const NativeSheetThemeConfig({

View file

@ -4567,7 +4567,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
? (Platform.isIOS ? CupertinoIcons.eye_slash : Icons.visibility_off)
: (Platform.isIOS ? CupertinoIcons.eye : Icons.visibility_outlined),
accessibilityLabel: AppLocalizations.of(context)!.temporaryChat,
tintColor: isTemporary ? Colors.blue : tintColor,
tintColor: isTemporary ? context.conduitTheme.info : tintColor,
onPressed: () {
ConduitHaptics.selectionClick();
final current = ref.read(temporaryChatEnabledProvider);

View file

@ -295,9 +295,10 @@ class _StatusDot extends StatelessWidget {
@override
Widget build(BuildContext context) {
final color = switch (snapshot.phase) {
ChatVoiceModePhase.listening => Colors.green,
ChatVoiceModePhase.listening => context.conduitTheme.success,
ChatVoiceModePhase.speaking => context.conduitTheme.buttonPrimary,
ChatVoiceModePhase.paused || ChatVoiceModePhase.muted => Colors.orange,
ChatVoiceModePhase.paused || ChatVoiceModePhase.muted =>
context.conduitTheme.warning,
ChatVoiceModePhase.error => Theme.of(context).colorScheme.error,
_ => context.conduitTheme.textSecondary,
};

View file

@ -26,9 +26,6 @@ final class DirectConnectionAdvancedSettingsPage extends StatelessWidget {
animation: form,
builder: (context, _) => UtilityPageScaffold.settings(
title: l10n.advancedSettings,
backgroundColor: CupertinoColors.systemGroupedBackground.resolveFrom(
context,
),
contentPadding: const EdgeInsets.fromLTRB(
Spacing.screenPadding,
Spacing.sm,
@ -201,7 +198,7 @@ final class _NativeAdvancedSettingsContent extends StatelessWidget {
color: context.conduitTheme.textPrimary,
),
placeholderStyle: AppTypography.bodyMediumStyle.copyWith(
color: CupertinoColors.placeholderText.resolveFrom(context),
color: context.conduitTheme.inputPlaceholder,
),
decoration: const BoxDecoration(color: Colors.transparent),
),
@ -294,7 +291,7 @@ final class _NativeCustomHeadersGroup extends StatelessWidget {
key: const ValueKey<String>('add-direct-custom-header-button'),
title: l10n.addHeader,
titleFontWeight: FontWeight.w400,
foregroundColor: CupertinoColors.activeBlue.resolveFrom(context),
foregroundColor: context.conduitTheme.buttonPrimary,
enabled: form.canAddCustomHeader,
onTap: form.canAddCustomHeader ? form.addCustomHeader : null,
),
@ -309,7 +306,7 @@ final class _NativeCustomHeadersGroup extends StatelessWidget {
tooltip: l10n.removeHeader,
onPressed: () => form.removeCustomHeader(entry.key),
backgroundColor: Colors.transparent,
iconColor: CupertinoColors.systemRed.resolveFrom(context),
iconColor: context.conduitTheme.error,
isCompact: true,
),
),

View file

@ -297,9 +297,6 @@ class _DirectConnectionEditorPageState
final l10n = AppLocalizations.of(context)!;
return UtilityPageScaffold.auth(
title: title,
backgroundColor: PlatformInfo.isIOS
? CupertinoColors.systemGroupedBackground.resolveFrom(context)
: null,
backNavigation: UtilityBackNavigation(
label: l10n.back,
buttonKey: const ValueKey<String>('direct-editor-back-button'),
@ -322,9 +319,6 @@ class _DirectConnectionEditorPageState
return UtilityPageScaffold.settings(
title: title,
backgroundColor: PlatformInfo.isIOS
? CupertinoColors.systemGroupedBackground.resolveFrom(context)
: null,
trailing: trailing,
contentPadding: PlatformInfo.isIOS
? const EdgeInsets.fromLTRB(
@ -430,9 +424,7 @@ class _DirectConnectionEditorPageState
),
title: l10n.testDirectConnection,
titleFontWeight: FontWeight.w400,
foregroundColor: CupertinoColors.activeBlue.resolveFrom(
context,
),
foregroundColor: context.conduitTheme.buttonPrimary,
enabled:
!_saving &&
!_deleting &&

View file

@ -595,7 +595,7 @@ final class DirectConnectionAdvancedSettingsSection extends StatelessWidget {
titleFontWeight: FontWeight.w400,
foregroundColor: headerError == null
? null
: CupertinoColors.systemRed.resolveFrom(context),
: context.conduitTheme.error,
showChevron: true,
onTap: () => showDirectConnectionAdvancedSettings(context, form),
),

View file

@ -54,9 +54,6 @@ Widget _buildDirectConnectionsScaffold(
if (isOnboarding) {
return UtilityPageScaffold.auth(
title: l10n.backendChooserDirectTitle,
backgroundColor: PlatformInfo.isIOS
? CupertinoColors.systemGroupedBackground.resolveFrom(context)
: null,
backNavigation: UtilityBackNavigation(
label: l10n.back,
buttonKey: const ValueKey<String>('direct-onboarding-back-button'),
@ -71,9 +68,6 @@ Widget _buildDirectConnectionsScaffold(
}
return UtilityPageScaffold.settings(
title: l10n.directConnectionsTitle,
backgroundColor: PlatformInfo.isIOS
? CupertinoColors.systemGroupedBackground.resolveFrom(context)
: null,
children: children,
);
}

View file

@ -188,9 +188,10 @@ class _JobCardState extends ConsumerState<_JobCard> {
return Container(
padding: const EdgeInsets.all(Spacing.md),
decoration: BoxDecoration(
color: theme.surfaceBackground,
borderRadius: BorderRadius.circular(AppBorderRadius.card),
border: Border.all(color: theme.cardBorder),
color: theme.groupedSurface,
borderRadius: BorderRadius.circular(
context.usesCupertinoChrome ? AppBorderRadius.md : AppBorderRadius.card,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,

View file

@ -372,9 +372,7 @@ class _HermesSettingsPageState extends ConsumerState<HermesSettingsPage> {
UtilityRow(
title: l10n.testDirectConnection,
titleFontWeight: FontWeight.w400,
foregroundColor: CupertinoColors.activeBlue.resolveFrom(
context,
),
foregroundColor: context.conduitTheme.buttonPrimary,
enabled:
_connectionController.draftIsUsable(config) &&
!_connectionController.operation.isBusy,
@ -510,8 +508,8 @@ class _HermesSettingsPageState extends ConsumerState<HermesSettingsPage> {
color:
_connectionController.draftIsUsable(config) &&
!_connectionController.operation.isBusy
? CupertinoColors.activeBlue.resolveFrom(context)
: CupertinoColors.inactiveGray.resolveFrom(context),
? context.conduitTheme.buttonPrimary
: context.conduitTheme.textDisabled,
),
),
)

View file

@ -201,7 +201,7 @@ class _FolderPageState extends ConsumerState<FolderPage> {
ConduitNativeToolbarAction(
iosSymbol: isTemporary ? 'eye.slash' : 'eye',
accessibilityLabel: l10n.temporaryChat,
tintColor: isTemporary ? Colors.blue : tintColor,
tintColor: isTemporary ? context.conduitTheme.info : tintColor,
onPressed: _toggleTemporaryChat,
),
// A read-grant shared folder cannot receive new chats.
@ -268,7 +268,9 @@ class _FolderPageState extends ConsumerState<FolderPage> {
icon: isTemporary
? (Platform.isIOS ? CupertinoIcons.eye_slash : Icons.visibility_off)
: (Platform.isIOS ? CupertinoIcons.eye : Icons.visibility_outlined),
iconColor: isTemporary ? Colors.blue : context.conduitTheme.textPrimary,
iconColor: isTemporary
? context.conduitTheme.info
: context.conduitTheme.textPrimary,
onPressed: _toggleTemporaryChat,
),
// A read-grant shared folder cannot receive new chats.
@ -2229,7 +2231,7 @@ class _SectionHeader extends StatelessWidget {
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: context.sidebarTheme.accent.withValues(alpha: 0.7),
color: context.sidebarTheme.tint.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
border: Border.all(
color: context.sidebarTheme.border.withValues(alpha: 0.35),

View file

@ -283,6 +283,7 @@ Widget buildSidebarProfileButton({
required VoidCallback onPressed,
required AdaptiveButtonStyle fallbackStyle,
Color? fallbackColor,
Color? nativeSymbolColor,
Uint8List? nativeAvatarBytes,
required Widget child,
}) {
@ -298,7 +299,7 @@ Widget buildSidebarProfileButton({
? CNSymbol(
'person.crop.circle.fill',
size: IconSize.large,
color: fallbackColor,
color: nativeSymbolColor ?? fallbackColor,
)
: null,
imageAsset: avatarBytes == null
@ -460,6 +461,7 @@ class SidebarProfileAppBarLeading extends ConsumerWidget {
onPressed: openProfile,
fallbackStyle: style,
fallbackColor: useOpaqueFallback ? iconColor : null,
nativeSymbolColor: iconColor,
nativeAvatarBytes: nativeAvatarBytes,
child: ClipRRect(
borderRadius: BorderRadius.circular(AppBorderRadius.avatar),

View file

@ -2911,7 +2911,7 @@ class _NoteEditorPageState extends ConsumerState<NoteEditorPage> {
width: 80,
height: 80,
decoration: BoxDecoration(
color: sidebarTheme.accent.withValues(alpha: 0.5),
color: sidebarTheme.tint.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(AppBorderRadius.xl),
),
child: Icon(

View file

@ -439,7 +439,7 @@ class _NotesListPageState extends ConsumerState<NotesListPage> {
// Compute opaque background for proper context menu snapshot rendering
final cardBackground = Color.alphaBlend(
sidebarTheme.accent.withValues(alpha: 0.5),
sidebarTheme.tint.withValues(alpha: 0.5),
sidebarTheme.background,
);
@ -488,7 +488,7 @@ class _NotesListPageState extends ConsumerState<NotesListPage> {
width: 40,
height: 40,
decoration: BoxDecoration(
color: sidebarTheme.accent,
color: sidebarTheme.tint,
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
border: Border.all(
color: sidebarTheme.border.withValues(alpha: 0.2),
@ -660,7 +660,7 @@ class _NotesListPageState extends ConsumerState<NotesListPage> {
width: 72,
height: 72,
decoration: BoxDecoration(
color: sidebarTheme.accent.withValues(alpha: 0.6),
color: sidebarTheme.tint.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(AppBorderRadius.lg),
),
child: Icon(
@ -755,7 +755,7 @@ class _NotesListPageState extends ConsumerState<NotesListPage> {
width: 64,
height: 64,
decoration: BoxDecoration(
color: sidebarTheme.accent.withValues(alpha: 0.6),
color: sidebarTheme.tint.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(AppBorderRadius.lg),
),
child: Icon(

View file

@ -11,6 +11,7 @@ import 'package:conduit/l10n/app_localizations.dart';
import '../../../shared/widgets/conduit_loading.dart';
import '../../../shared/widgets/adaptive_route_shell.dart';
import '../../../shared/widgets/adaptive_toolbar_components.dart';
import '../../../shared/utils/ui_utils.dart';
import '../../../shared/utils/external_link_launcher.dart';
@ -73,9 +74,35 @@ class ProfilePage extends ConsumerWidget {
Widget _buildScaffold(BuildContext context, {required Widget body}) {
final l10n = AppLocalizations.of(context)!;
// Use the same floating back control as the settings subpages so the
// hub and its destinations share one toolbar style.
final backLabel = MaterialLocalizations.of(context).backButtonTooltip;
final backButton = Navigator.of(context).canPop()
? AdaptiveTooltip(
message: backLabel,
child: ConduitAdaptiveAppBarIconButton(
icon: context.usesCupertinoChrome
? CupertinoIcons.chevron_back
: Icons.arrow_back,
semanticLabel: backLabel,
onPressed: () => Navigator.of(context).maybePop(),
),
)
: null;
return AdaptiveRouteShell(
backgroundColor: context.conduitTheme.surfaceBackground,
appBar: AdaptiveAppBar(title: l10n.you),
backgroundColor: context.conduitTheme.groupedBackground,
appBar: AdaptiveAppBar(
title: l10n.you,
leading: backButton == null || context.usesCupertinoChrome
? backButton
: Center(
child: SizedBox.square(
dimension: TouchTarget.minimum,
child: backButton,
),
),
),
body: body,
);
}

View file

@ -7,7 +7,9 @@ TextStyle? profileTitleTextStyle(BuildContext context, {bool large = false}) {
final baseStyle = large ? theme.headingMedium : theme.bodyMedium;
return baseStyle?.copyWith(
color: theme.sidebarForeground,
// Page text, not the sidebar's: these tiles sit on settings surfaces, and
// some palettes tint the sidebar foreground (T3 Chat uses pink).
color: theme.textPrimary,
fontWeight: FontWeight.w600,
);
}
@ -17,6 +19,6 @@ TextStyle? profileSubtitleTextStyle(BuildContext context) {
final baseStyle = theme.bodySmall;
return baseStyle?.copyWith(
color: theme.sidebarForeground.withValues(alpha: 0.75),
color: theme.textSecondary,
);
}

View file

@ -323,9 +323,7 @@ class _WorkspaceIosCollectionShellState
_CollectionBinding<T> binding, {
required bool canCreate,
}) {
final pageBackground = CupertinoColors.systemGroupedBackground.resolveFrom(
context,
);
final pageBackground = context.conduitTheme.groupedBackground;
final section = widget.section;
// Keep the pagination snapshot current for the scroll listener.

View file

@ -133,9 +133,7 @@ class _WorkspaceGateState extends StatelessWidget {
final l10n = AppLocalizations.of(context)!;
final theme = context.conduitTheme;
final usesCupertinoChrome = context.usesCupertinoChrome;
final pageBackground = usesCupertinoChrome
? CupertinoColors.systemGroupedBackground.resolveFrom(context)
: theme.surfaceBackground;
final pageBackground = theme.groupedBackground;
final topInset = usesCupertinoChrome
? MediaQuery.paddingOf(context).top +
conduitAdaptiveToolbarHeightOf(context)
@ -261,9 +259,7 @@ class WorkspaceScaffold extends ConsumerWidget {
// fall back to the single-pane compact layout there.
final wide = MediaQuery.sizeOf(context).width >= 840;
final theme = context.conduitTheme;
final pageBackground = context.usesCupertinoChrome
? CupertinoColors.systemGroupedBackground.resolveFrom(context)
: theme.surfaceBackground;
final pageBackground = theme.groupedBackground;
// iOS compact collection uses native Cupertino chrome (a sliver navigation
// bar with search + a pinned segmented switcher), so it hosts its own

View file

@ -102,9 +102,7 @@ class WorkspaceEditorScaffold extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = context.conduitTheme;
final pageBackground = context.usesCupertinoChrome
? CupertinoColors.systemGroupedBackground.resolveFrom(context)
: theme.surfaceBackground;
final pageBackground = theme.groupedBackground;
final compact = MediaQuery.sizeOf(context).width < 840;
// The route title already identifies compact editors. Repeating the same
// icon and title at the top of the form made these screens read like web

View file

@ -139,32 +139,36 @@ class WorkspaceCollectionResourceTile extends StatelessWidget {
child: tile,
);
}
// Rows join into one grouped card like InsetGroupedList: the palette's
// grouped surface with inset hairlines between rows. A per-side border
// with mixed colors cannot be painted with rounded corners, which left
// the first row of a multi-row list blank.
final theme = context.conduitTheme;
return Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: theme.surfaceContainer.withValues(alpha: 0.68),
border: Border(
left: BorderSide(color: theme.cardBorder, width: BorderWidth.thin),
top: index == 0
? BorderSide(color: theme.cardBorder, width: BorderWidth.thin)
: BorderSide.none,
right: BorderSide(color: theme.cardBorder, width: BorderWidth.thin),
bottom: BorderSide(
color: groupedLast ? theme.cardBorder : theme.dividerColor,
width: BorderWidth.thin,
),
),
borderRadius: BorderRadius.vertical(
top: index == 0
? const Radius.circular(AppBorderRadius.card)
: Radius.zero,
bottom: groupedLast
? const Radius.circular(AppBorderRadius.card)
: Radius.zero,
final radius = Radius.circular(
context.usesCupertinoChrome ? AppBorderRadius.md : AppBorderRadius.card,
);
return ClipRRect(
borderRadius: BorderRadius.vertical(
top: index == 0 ? radius : Radius.zero,
bottom: groupedLast ? radius : Radius.zero,
),
child: ColoredBox(
color: theme.groupedSurface,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
tile,
if (!groupedLast)
Divider(
height: BorderWidth.thin,
thickness: BorderWidth.thin,
indent: Spacing.md,
color: theme.dividerColor,
),
],
),
),
child: tile,
);
}
}

View file

@ -1090,17 +1090,11 @@ class _ConduitAppState extends ConsumerState<ConduitApp> {
: lightTheme;
final nativeTheme = materialTheme.extension<ConduitThemeExtension>();
if (nativeTheme != null) {
// Light sheets use the grouped iOS settings look: white cards on
// a tinted page. Palettes differ on which token is the tinted one
// (Conduit tints its card, Catppuccin its page), so pick by
// luminance rather than swapping. Dark keeps cards lifted off black.
// Sheets share the grouped settings colors with the Flutter
// settings screens so both read as one surface system.
final isDarkSheet = brightness == Brightness.dark;
final (sheetBackground, sheetSurface) = isDarkSheet
? (nativeTheme.surfaceBackground, nativeTheme.cardBackground)
: resolveGroupedSheetColors(
page: nativeTheme.surfaceBackground,
card: nativeTheme.cardBackground,
);
final sheetBackground = nativeTheme.groupedBackground;
final sheetSurface = nativeTheme.groupedSurface;
unawaited(
NativeSheetBridge.instance.syncTheme(
NativeSheetThemeConfig(

View file

@ -77,6 +77,29 @@ class AppTheme {
tertiary: variant.accent,
onTertiary: _pickOnColor(variant.accent, tokens),
surfaceTint: variant.primary,
// Roles left to `ColorScheme.fromSeed` come out hue-shifted from the
// seed (pink/teal containers on the monochrome Conduit palette), and
// Material components such as segmented buttons, chips, menus, and
// switches read them directly. Derive every one from the palette.
// shadcn draws selected tabs/toggles with `secondary`; `accent` is a
// hover color (Catppuccin's is saturated sky blue).
secondaryContainer: variant.secondary,
onSecondaryContainer: variant.secondaryForeground,
tertiaryContainer: variant.muted,
onTertiaryContainer: variant.foreground,
errorContainer: Color.alphaBlend(
tokens.statusError60.withValues(alpha: isDark ? 0.24 : 0.12),
surfaces.background,
),
onErrorContainer: tokens.statusError60,
surfaceContainerLowest: isDark ? surfaces.background : surfaces.popover,
surfaceContainer: surfaces.container,
surfaceContainerHigh: Color.lerp(surfaces.container, surfaces.card, 0.5),
surfaceDim: isDark ? surfaces.background : surfaces.card,
surfaceBright: isDark ? surfaces.card : surfaces.background,
inverseSurface: variant.foreground,
onInverseSurface: variant.background,
inversePrimary: variant.primaryForeground,
);
final OutlineInputBorder baseInputBorder = OutlineInputBorder(

View file

@ -1,7 +1,38 @@
import 'dart:math' as math;
import 'package:material_ui/material_ui.dart';
import 'tweakcn_themes.dart';
/// The app's error color for a palette.
///
/// Palettes keep tweakcn's `destructive` verbatim, and shadcn uses it as a
/// button fill under white text. Conduit also draws the error color directly
/// on the page as text and icons, where some presets vanish (T3 Chat dark:
/// #301015 on #221D27) or stop reading as an error (Claude light: near-black
/// #141413). Those fall back to a legible red for the brightness.
Color legibleDestructive({
required Color destructive,
required Color background,
required Brightness brightness,
}) {
final readsAsRed =
destructive.r > destructive.g && destructive.r > destructive.b;
final lighter = math.max(
destructive.computeLuminance(),
background.computeLuminance(),
);
final darker = math.min(
destructive.computeLuminance(),
background.computeLuminance(),
);
final contrast = (lighter + 0.05) / (darker + 0.05);
if (readsAsRed && contrast >= 3) return destructive;
return brightness == Brightness.dark
? const Color(0xFFD9476B)
: const Color(0xFFB53333);
}
/// Immutable set of semantic color tokens exposed through [ThemeExtension].
///
/// The tokens are derived from the Conduit color specification and provide
@ -169,7 +200,11 @@ class AppColorTokens extends ThemeExtension<AppColorTokens> {
isLight ? 0.18 : 0.24,
);
final Color statusError60 = variant.destructive;
final Color statusError60 = legibleDestructive(
destructive: variant.destructive,
background: variant.background,
brightness: brightness,
);
final Color statusOnError60 = _ensureContrast(
surface: statusError60,
foreground: variant.destructiveForeground,

View file

@ -6,6 +6,28 @@ import 'package:material_ui/material_ui.dart';
import 'tweakcn_themes.dart';
import 'color_tokens.dart';
/// Orders a palette's page and card colors for grouped settings surfaces: the darker
/// (tinted) color becomes the page and the lighter one the cards.
///
/// Palettes disagree on which token is tinted, so an unconditional swap
/// would invert palettes that already put white cards on a tinted page.
/// Some palettes (T3 Chat light, Claude) give the page and card the same
/// color; the popover color then supplies the cards so grouped sections stay
/// visible.
(Color, Color) resolveGroupedSheetColors({
required Color page,
required Color card,
Color? popover,
}) {
final (darker, lighter) = page.computeLuminance() <= card.computeLuminance()
? (page, card)
: (card, page);
if (darker.toARGB32() != lighter.toARGB32() || popover == null) {
return (darker, lighter);
}
return resolveGroupedSheetColors(page: darker, card: popover);
}
/// Extended theme data for consistent styling across the app
@immutable
class ConduitThemeExtension extends ThemeExtension<ConduitThemeExtension> {
@ -63,6 +85,17 @@ class ConduitThemeExtension extends ThemeExtension<ConduitThemeExtension> {
Color get inputPlaceholder => textSecondary.withValues(alpha: 0.5);
Color get inputError => tokens.statusError60;
/// Page and card colors for grouped settings screens (inset-grouped lists
/// on iOS, grouped cards on Android, and the native UIKit sheets). The
/// darker palette color becomes the page so cards always read as lifted.
(Color, Color) get _groupedColors => resolveGroupedSheetColors(
page: surfaces.background,
card: surfaces.card,
popover: surfaces.popover,
);
Color get groupedBackground => _groupedColors.$1;
Color get groupedSurface => _groupedColors.$2;
Color get cardBackground => surfaces.card;
Color get cardBorder => surfaces.border;
Color get cardShadow => shadows.shadowSm.first.color;
@ -670,6 +703,25 @@ class SidebarThemeExtension extends ThemeExtension<SidebarThemeExtension> {
final Color accent;
final Color accentForeground;
final Color border;
/// Fill for decorative tiles under sidebar text (note cards, badges,
/// empty-state tiles). shadcn's `sidebar-accent` is a hover fill meant for
/// `sidebar-accent-foreground`; where it would sink regular sidebar text
/// (Catppuccin's saturated sky blue) the tile uses a neutral lift of the
/// sidebar instead, matching how those presets render at rest.
Color get tint {
final underText = Color.alphaBlend(
accent.withValues(alpha: 0.5),
background,
);
final a = foreground.computeLuminance();
final b = underText.computeLuminance();
final contrast = (math.max(a, b) + 0.05) / (math.min(a, b) + 0.05);
return contrast >= 4.5
? accent
: Color.lerp(background, foreground, 0.12)!;
}
final Color ring;
@override

View file

@ -130,10 +130,11 @@ class AccessibleFormField extends StatelessWidget {
hintStyle: AppTypography.inputHintStyle.copyWith(
color: context.conduitTheme.inputPlaceholder,
),
// Outlined field: page-colored fill, a hairline
// outline, and a firmer ink outline while focused.
// Outlined field: grouped-card fill that lifts off the
// grouped settings page, a hairline outline, and a firmer ink
// outline while focused.
filled: true,
fillColor: context.conduitTheme.surfaceBackground,
fillColor: context.conduitTheme.groupedSurface,
border: _outline(context.conduitTheme.inputBorder),
enabledBorder: _outline(context.conduitTheme.inputBorder),
focusedBorder: _outline(
@ -158,12 +159,12 @@ class AccessibleFormField extends StatelessWidget {
),
cupertinoBoxDecoration: BoxDecoration(
color: enabled
? CupertinoColors.systemBackground.resolveFrom(context)
: CupertinoColors.quaternarySystemFill.resolveFrom(context),
? context.conduitTheme.groupedSurface
: context.conduitTheme.buttonDisabled,
border: Border.all(
color: hasExternalError
? CupertinoColors.systemRed.resolveFrom(context)
: CupertinoColors.separator.resolveFrom(context),
? context.conduitTheme.error
: context.conduitTheme.inputBorder,
width: BorderWidth.regular,
),
borderRadius: BorderRadius.circular(_fieldRadius),

View file

@ -1,5 +1,5 @@
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/widgets.dart';
import 'package:material_ui/material_ui.dart';
/// Shared adaptive page shell for routes that only need common scaffold
/// configuration.
@ -70,11 +70,24 @@ class AdaptiveRouteShell extends StatelessWidget {
content = ColoredBox(color: color, child: content);
}
return AdaptiveScaffold(
final scaffold = AdaptiveScaffold(
appBar: appBar,
body: content,
extendBodyBehindAppBar: extendBodyBehindAppBar,
floatingActionButton: floatingActionButton,
);
final color = backgroundColor;
if (color == null) return scaffold;
// Material app bars and the scaffold default to the plain page color;
// match them to the route background (e.g. the grouped settings page) so
// the bar does not sit as a separate band above the content.
final theme = Theme.of(context);
return Theme(
data: theme.copyWith(
scaffoldBackgroundColor: color,
appBarTheme: theme.appBarTheme.copyWith(backgroundColor: color),
),
child: scaffold,
);
}
}

View file

@ -705,6 +705,16 @@ class ConduitNativeToolbarActionGroup extends StatelessWidget {
);
}
/// Colors the symbol itself so trailing actions render the exact theme
/// color, like the leading button and the sidebar's glass group. A bare
/// control tint lets Liquid Glass lift the foreground to a brighter shade.
/// Pre-colored symbols skip UIKit's disabled dimming, so apply it here.
static Color? _symbolColor(ConduitNativeToolbarAction action) {
final color = action.tintColor;
if (color == null || action.enabled) return color;
return color.withValues(alpha: color.a * 0.35);
}
Widget _buildAction(ConduitNativeToolbarAction action, double extent) {
if (action.menuItems.isEmpty) {
return Semantics(
@ -716,6 +726,7 @@ class ConduitNativeToolbarActionGroup extends StatelessWidget {
icon: CNSymbol(
action.iosSymbol,
size: kConduitNativeSingleActionSymbolExtent,
color: _symbolColor(action),
),
onPressed: action.onPressed,
enabled: action.enabled,
@ -738,6 +749,7 @@ class ConduitNativeToolbarActionGroup extends StatelessWidget {
child: AdaptivePopupMenuButton.icon<int>(
icon: action.iosSymbol,
iconSize: kConduitNativeSingleActionSymbolExtent,
iconColor: _symbolColor(action),
tint: action.tintColor,
size: extent,
enabled: action.enabled,
@ -1265,6 +1277,7 @@ class ConduitAdaptiveToolbarOverflowButton<T> extends StatelessWidget {
size: Size.square(controlExtent),
child: AdaptivePopupMenuButton.icon<T>(
icon: Platform.isIOS ? iosIcon : materialIcon,
iconColor: tintColor,
tint: tintColor,
size: controlExtent,
buttonStyle: PopupButtonStyle.glass,

View file

@ -99,6 +99,7 @@ class ConnectionWebAuthScaffold extends StatelessWidget {
appBar: AdaptiveAppBar(
title: title,
leading: backButton,
tintColor: context.conduitTheme.textPrimary,
actions: [
if (onRefresh != null)
AdaptiveAppBarAction(

View file

@ -494,6 +494,7 @@ class AdaptivePopupMenuButton<T> {
required void Function(int index, AdaptivePopupMenuItem<T> entry)
onSelected,
Color? tint,
Color? iconColor,
double size = 44,
double? iconSize,
bool enabled = true,
@ -505,8 +506,8 @@ class AdaptivePopupMenuButton<T> {
key: key,
buttonIcon: icon is String
? iconSize == null
? CNSymbol(icon)
: CNSymbol(icon, size: iconSize)
? CNSymbol(icon, color: iconColor)
: CNSymbol(icon, size: iconSize, color: iconColor)
: null,
buttonCustomIcon: icon is IconData ? icon : null,
items: _nativeItems<T>(items),

View file

@ -4,6 +4,7 @@ import 'package:cupertino_native_better/cupertino_native_better.dart';
import 'package:cupertino_ui/cupertino_ui.dart';
import 'package:material_ui/material_ui.dart';
import '../../../theme/theme_extensions.dart';
import 'platform_ui_capabilities.dart';
enum AdaptiveSnackBarType { info, success, warning, error }
@ -42,22 +43,29 @@ class AdaptiveSnackBar {
}
if (!PlatformUiCapabilities.isIOS) {
final status = _statusColors(context, type);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
content: Text(
message,
style: status == null ? null : TextStyle(color: status.onBase),
),
duration: duration,
backgroundColor: switch (type) {
AdaptiveSnackBarType.info => Theme.of(
context,
).snackBarTheme.backgroundColor,
AdaptiveSnackBarType.success => Colors.green.shade700,
AdaptiveSnackBarType.warning => Colors.orange.shade700,
AdaptiveSnackBarType.error => Colors.red.shade700,
},
backgroundColor:
status?.base ??
switch (type) {
AdaptiveSnackBarType.info => Theme.of(
context,
).snackBarTheme.backgroundColor,
AdaptiveSnackBarType.success => Colors.green.shade700,
AdaptiveSnackBarType.warning => Colors.orange.shade700,
AdaptiveSnackBarType.error => Colors.red.shade700,
},
action: action == null
? null
: SnackBarAction(
label: action,
textColor: status?.onBase,
onPressed: onActionPressed ?? () {},
),
),
@ -87,6 +95,23 @@ class AdaptiveSnackBar {
overlay.insert(entry);
}
/// Palette-driven status colors, or null outside a Conduit theme.
static StatusColors? _statusColors(
BuildContext context,
AdaptiveSnackBarType type,
) {
final palette = Theme.of(
context,
).extension<ConduitThemeExtension>()?.statusPalette;
if (palette == null) return null;
return switch (type) {
AdaptiveSnackBarType.info => palette.info,
AdaptiveSnackBarType.success => palette.success,
AdaptiveSnackBarType.warning => palette.warning,
AdaptiveSnackBarType.error => palette.destructive,
};
}
static CNToastDuration? _nativeDuration(Duration value) {
if (value == const Duration(seconds: 2)) return CNToastDuration.short;
if (value == const Duration(milliseconds: 3500)) {
@ -135,12 +160,15 @@ class _IOSBannerState extends State<_IOSBanner> {
@override
Widget build(BuildContext context) {
final tint = switch (widget.type) {
AdaptiveSnackBarType.info => CupertinoColors.systemBlue,
AdaptiveSnackBarType.success => CupertinoColors.systemGreen,
AdaptiveSnackBarType.warning => CupertinoColors.systemOrange,
AdaptiveSnackBarType.error => CupertinoColors.systemRed,
};
final conduitTheme = Theme.of(context).extension<ConduitThemeExtension>();
final tint =
AdaptiveSnackBar._statusColors(context, widget.type)?.base ??
switch (widget.type) {
AdaptiveSnackBarType.info => CupertinoColors.systemBlue,
AdaptiveSnackBarType.success => CupertinoColors.systemGreen,
AdaptiveSnackBarType.warning => CupertinoColors.systemOrange,
AdaptiveSnackBarType.error => CupertinoColors.systemRed,
};
final icon = switch (widget.type) {
AdaptiveSnackBarType.info => CupertinoIcons.info_circle_fill,
AdaptiveSnackBarType.success => CupertinoIcons.check_mark_circled_solid,
@ -158,9 +186,11 @@ class _IOSBannerState extends State<_IOSBanner> {
liveRegion: true,
child: DecoratedBox(
decoration: BoxDecoration(
color: CupertinoColors.secondarySystemBackground.resolveFrom(
context,
),
color:
conduitTheme?.surfaceContainerHighest ??
CupertinoColors.secondarySystemBackground.resolveFrom(
context,
),
borderRadius: BorderRadius.circular(14),
boxShadow: const [
BoxShadow(
@ -180,7 +210,9 @@ class _IOSBannerState extends State<_IOSBanner> {
child: Text(
widget.message,
style: TextStyle(
color: CupertinoColors.label.resolveFrom(context),
color:
conduitTheme?.textPrimary ??
CupertinoColors.label.resolveFrom(context),
),
),
),

View file

@ -51,7 +51,7 @@ class SidebarIos26Scaffold extends StatelessWidget {
_ => null,
};
final hasBottomNavigation = renderedBottomNavigation != null;
final textColor = CupertinoColors.label.resolveFrom(context);
final textColor = context.conduitTheme.textPrimary;
final hasNavigationBar = leading != null || actions?.isNotEmpty == true;
final toolbarActions = actions ?? const <AdaptiveAppBarAction>[];
final toolbarActionsWidth = toolbarActions.isEmpty
@ -112,7 +112,14 @@ class _NativeToolbarActions extends StatelessWidget {
@override
Widget build(BuildContext context) {
// The native group reads brightness only when it is created, so rebuild
// it when the appearance or action tints change; otherwise a light/dark
// switch leaves a light glass pill behind dark-mode icons.
return CNGlassButtonGroup.fromWidgets(
key: ValueKey<Object>((
CupertinoTheme.brightnessOf(context),
Object.hashAll(actions.map((action) => action.tintColor)),
)),
spacing: 8,
spacingForGlass: 36,
buttonWidgets: [
@ -136,7 +143,7 @@ class _NativeToolbarActions extends StatelessWidget {
? null
: CNSymbol(
action.iosSymbol!,
size: kCupertinoNativeControlSymbolExtent,
size: kConduitNativeSingleActionSymbolExtent,
),
customIcon: action.iosSymbol == null ? action.icon : null,
onPressed: action.onPressed,

View file

@ -1,4 +1,3 @@
import 'package:cupertino_ui/cupertino_ui.dart';
import 'package:material_ui/material_ui.dart';
import '../../theme/theme_extensions.dart';
@ -75,19 +74,13 @@ class InsetGroupedSection extends StatelessWidget {
clipBehavior: Clip.antiAlias,
padding: padding,
decoration: BoxDecoration(
color: nativeSurface
? CupertinoColors.secondarySystemGroupedBackground
.resolveFrom(context)
: theme.surfaceContainer.withValues(alpha: 0.68),
// Palette-derived grouped card on both platforms: the lighter
// palette surface on the darker grouped page, so sections lift
// without borders (iOS inset-grouped, Material 3 filled cards).
color: theme.groupedSurface,
borderRadius: BorderRadius.circular(
nativeSurface ? AppBorderRadius.md : AppBorderRadius.card,
),
border: nativeSurface
? null
: Border.all(
color: theme.cardBorder,
width: BorderWidth.thin,
),
),
child: Material(type: MaterialType.transparency, child: child),
),

View file

@ -246,10 +246,7 @@ class _UtilityPageScaffoldState extends State<UtilityPageScaffold> {
return AdaptiveRouteShell(
backgroundColor:
widget.backgroundColor ??
(context.usesCupertinoChrome
? CupertinoColors.systemGroupedBackground.resolveFrom(context)
: context.conduitTheme.surfaceBackground),
widget.backgroundColor ?? context.conduitTheme.groupedBackground,
appBar: appBar,
body: Stack(
children: [

View file

@ -6,6 +6,11 @@
// Edit a palette here and both front-ends move together.
//
// Values are ARGB, matching Flutter's `Color(0x...)` layout.
//
// Presets other than Conduit mirror tweakcn's (utils/theme-presets.ts)
// verbatim; success/warning/info are Conduit additions. Adapt colors for app
// use in the Flutter token layer (e.g. `legibleDestructive`), not here, so the
// palettes stay comparable with tweakcn.
import 'palette.dart';
@ -255,8 +260,8 @@ const ThemeVariant _catppuccinLight = ThemeVariant(
sidebarForeground: 0xFF4C4F69,
sidebarPrimary: 0xFF8839EF,
sidebarPrimaryForeground: 0xFFFFFFFF,
sidebarAccent: 0xFFDCE0E8,
sidebarAccentForeground: 0xFF4C4F69,
sidebarAccent: 0xFF04A5E5,
sidebarAccentForeground: 0xFFFFFFFF,
sidebarBorder: 0xFFBCC0CC,
sidebarRing: 0xFF8839EF,
success: 0xFF40A02B,
@ -265,7 +270,7 @@ const ThemeVariant _catppuccinLight = ThemeVariant(
warningForeground: 0xFF4C4F69,
info: 0xFF04A5E5,
infoForeground: 0xFFFFFFFF,
radius: 6.0,
radius: 5.6,
);
const ThemeVariant _catppuccinDark = ThemeVariant(
@ -292,8 +297,8 @@ const ThemeVariant _catppuccinDark = ThemeVariant(
sidebarForeground: 0xFFCDD6F4,
sidebarPrimary: 0xFFCBA6F7,
sidebarPrimaryForeground: 0xFF1E1E2E,
sidebarAccent: 0xFF292C3C,
sidebarAccentForeground: 0xFFCDD6F4,
sidebarAccent: 0xFF89DCEB,
sidebarAccentForeground: 0xFF1E1E2E,
sidebarBorder: 0xFF45475A,
sidebarRing: 0xFFCBA6F7,
success: 0xFFA6E3A1,
@ -302,7 +307,7 @@ const ThemeVariant _catppuccinDark = ThemeVariant(
warningForeground: 0xFF181825,
info: 0xFF89DCEB,
infoForeground: 0xFF181825,
radius: 6.0,
radius: 5.6,
);
const ThemeVariant _tangerineLight = ThemeVariant(

View file

@ -3,6 +3,7 @@ import 'dart:async';
import 'package:checks/checks.dart';
import 'package:conduit/platform/conduit_platform_apis.g.dart';
import 'package:conduit/core/services/native_sheet_bridge.dart';
import 'package:conduit/shared/theme/theme_extensions.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
@ -761,5 +762,13 @@ void main() {
check(resolveGroupedSheetColors(page: tinted, card: white))
.equals((tinted, white));
});
test('lifts cards onto the popover when page and card match', () {
// T3 Chat light: page and card share one tinted color.
const tinted = Color(0xFFFAF5FA);
check(
resolveGroupedSheetColors(page: tinted, card: tinted, popover: white),
).equals((tinted, white));
});
});
}

View file

@ -1,3 +1,5 @@
import 'dart:math' as math;
import 'package:conduit/shared/theme/app_theme.dart';
import 'package:conduit/shared/theme/color_tokens.dart';
import 'package:conduit/shared/theme/theme_extensions.dart';
@ -27,6 +29,71 @@ void main() {
check(iosSelection).equals(androidSelection);
});
test('Material container roles come from the palette, not the seed', () {
// fromSeed hue-shifts these roles (pink containers on the monochrome
// Conduit palette), and Material controls read them directly.
for (final definition in TweakcnThemes.all) {
for (final brightness in Brightness.values) {
final variant = definition.variantFor(brightness);
final theme = brightness == Brightness.dark
? AppTheme.dark(definition)
: AppTheme.light(definition);
final scheme = theme.colorScheme;
final surfaces = theme.extension<SurfaceThemeExtension>()!;
final label = '${definition.id} $brightness';
check(
because: label,
scheme.secondaryContainer,
).equals(variant.secondary);
check(
because: label,
scheme.onSecondaryContainer,
).equals(variant.secondaryForeground);
check(because: label, scheme.tertiaryContainer).equals(variant.muted);
check(
because: label,
scheme.surfaceContainer,
).equals(surfaces.container);
check(
because: label,
scheme.inverseSurface,
).equals(variant.foreground);
}
}
});
test('error color stays a legible red on every palette', () {
// Palettes keep tweakcn's destructive verbatim (a button fill), but the
// app also draws the error color as text and icons on the page.
for (final definition in TweakcnThemes.all) {
for (final brightness in Brightness.values) {
final variant = definition.variantFor(brightness);
final error = brightness == Brightness.dark
? AppColorTokens.dark(theme: definition).statusError60
: AppColorTokens.light(theme: definition).statusError60;
final label = '${definition.id} $brightness';
final lighter = math.max(
error.computeLuminance(),
variant.background.computeLuminance(),
);
final darker = math.min(
error.computeLuminance(),
variant.background.computeLuminance(),
);
check(
because: label,
(lighter + 0.05) / (darker + 0.05),
).isGreaterOrEqual(3);
check(because: label, error.r > error.g && error.r > error.b).isTrue();
}
}
// Legible presets pass through untouched.
final catppuccin = TweakcnThemes.catppuccin.variantFor(Brightness.light);
check(
AppColorTokens.light(theme: TweakcnThemes.catppuccin).statusError60,
).equals(catppuccin.destructive);
});
test('product typography uses one ramp on Android and iOS', () {
addTearDown(() => debugDefaultTargetPlatformOverride = null);

View file

@ -1,8 +1,39 @@
import 'package:conduit/shared/theme/theme_extensions.dart';
import 'package:conduit/shared/theme/tweakcn_themes.dart';
import 'package:checks/checks.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('sidebar tint keeps legible accents and softens saturated ones', () {
for (final definition in TweakcnThemes.all) {
for (final brightness in Brightness.values) {
final sidebar = SidebarThemeExtension.fromVariant(
definition.variantFor(brightness),
);
final label = '${definition.id} $brightness';
final underText = Color.alphaBlend(
sidebar.tint.withValues(alpha: 0.5),
sidebar.background,
);
final a = sidebar.foreground.computeLuminance();
final b = underText.computeLuminance();
final contrast =
(a > b ? a + 0.05 : b + 0.05) / (a > b ? b + 0.05 : a + 0.05);
check(because: label, contrast).isGreaterOrEqual(4.5);
}
}
// Tweakcn's Catppuccin sidebar accent is saturated sky blue.
final catppuccin = SidebarThemeExtension.fromVariant(
TweakcnThemes.catppuccin.variantFor(Brightness.light),
);
check(catppuccin.tint).not((it) => it.equals(catppuccin.accent));
final t3 = SidebarThemeExtension.fromVariant(
TweakcnThemes.t3Chat.variantFor(Brightness.light),
);
check(t3.tint).equals(t3.accent);
});
for (final platform in TargetPlatform.values) {
testWidgets('uses Cupertino chrome on ${platform.name}', (tester) async {
late bool usesCupertinoChrome;