mirror of
https://github.com/cogwheel0/conduit.git
synced 2026-10-19 09:43:36 +00:00
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:
parent
5814328427
commit
d51ba8dcb5
38 changed files with 488 additions and 158 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ class ConnectionWebAuthScaffold extends StatelessWidget {
|
|||
appBar: AdaptiveAppBar(
|
||||
title: title,
|
||||
leading: backButton,
|
||||
tintColor: context.conduitTheme.textPrimary,
|
||||
actions: [
|
||||
if (onRefresh != null)
|
||||
AdaptiveAppBarAction(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue