diff --git a/.gitignore b/.gitignore index 511c01eb..e6bc655f 100644 --- a/.gitignore +++ b/.gitignore @@ -145,4 +145,9 @@ env.g.dart # Non-CI golden files and failures **/test/**/goldens/**/*.* **/test/**/failures/**/*.* -!**/test/**/goldens/ci/*.* \ No newline at end of file +!**/test/**/goldens/ci/*.* + +# Transient Dart file written, analyzed and deleted by +# theme_code_generator_compiles_test.dart - only left behind if that test +# crashes mid-run. +apps/design_system_gallery/test/core/generated/ \ No newline at end of file diff --git a/apps/design_system_gallery/AGENTS.md b/apps/design_system_gallery/AGENTS.md index d36e5a62..9f2c30b2 100644 --- a/apps/design_system_gallery/AGENTS.md +++ b/apps/design_system_gallery/AGENTS.md @@ -56,15 +56,30 @@ apps/design_system_gallery/ │ │ ├── spacing.dart # StreamSpacing showcase │ │ └── colors.dart # StreamColors showcase │ ├── config/ +│ │ ├── theme_color_slot.dart # ThemeColorSlot/ThemeSeedSlot enums (source of truth for colors) +│ │ ├── theme_studio_sections.dart # Section/group layout shared by the panel, export page & codegen +│ │ ├── component_theme_descriptors.dart # Editable Color props per component theme + name lookup │ │ ├── theme_configuration.dart # Theme state (colors, brightness, etc.) +│ │ ├── theme_export_configuration.dart # Export page state: a light + dark config, plus link state │ │ └── preview_configuration.dart # Preview state (device, text scale) │ ├── core/ -│ │ └── preview_wrapper.dart # Wraps use cases with theme/device frame +│ │ ├── preview_wrapper.dart # Wraps use cases with theme/device frame +│ │ └── theme_code_generator.dart # Generates copy-pasteable Dart for the export page │ └── widgets/ │ ├── toolbar/ # Top toolbar widgets -│ └── theme_studio/ # Theme customization panel widgets +│ ├── theme_studio/ # Theme customization panel widgets +│ └── theme_export/ # Export page widgets (linked color rows, message preview) ``` +The gallery also has a `test/` directory (`melos run test:flutter` picks it up automatically). It currently covers: + +- **`theme_color_slot.dart` vs. `StreamColorScheme`** — pins the slot list so an added SDK color fails loudly instead of being silently missed. +- **Component themes** — that every `ComponentThemeDescriptor` matches the real `StreamTheme` API. +- **`theme_export_configuration.dart`** — seeding from the studio, link/unlink semantics, and that export never writes back. +- **`color_picker_tile.dart`** — the default/customized states, and that a tile keeps a constant height either way. +- **The export page** — the light/dark columns, link toggles, the responsive side-by-side/tabs split, and the component theme picker. +- **The code generator** — const naming (shared vs. `Light`/`Dark`-suffixed), chrome derivation, and a check that the generated snippet actually **type-checks against the real API** (`theme_code_generator_compiles_test.dart` writes it to a real `.dart` file and runs `dart analyze` over it). + ## Common Commands ```bash @@ -369,7 +384,7 @@ Use `context.read()` for calling methods (no rebuild on chan ```dart // For calling setters/methods - use read -context.read().setAccentPrimary(color); +context.read().setOverride(ThemeColorSlot.accentPrimary, color); context.read().resetToDefaults(); ``` @@ -377,11 +392,29 @@ Use `context.watch()` only when you need to rebuild on chang ### Adding New Theme Properties -1. Add private field and getter in `theme_configuration.dart` -2. Add setter using `_update()` pattern -3. Include in `_rebuildTheme()` colorScheme.copyWith() -4. Add to `resetToDefaults()` -5. Add UI control in `theme_customization_panel.dart` +Every plain `Color?` parameter of `StreamColorScheme` is represented once, as a +`ThemeColorSlot` value — `ThemeConfiguration` stores overrides in a single +`Map` rather than one field/getter/setter/reset per +color, and the studio panel renders `themeStudioSections` instead of +hand-written tiles. This is what keeps the panel, the export page, and the +code generator from drifting apart (they used to — `textOnInverse`, +`borderOnInverse` and `borderDisabledOnSurface` existed on `StreamColorScheme` +but were missing from the studio for a while). + +To add a new SDK color: + +1. Add a `ThemeColorSlot` value in `theme_color_slot.dart` (parameter name + + a `_readXxx(StreamColorScheme s) => s.xxx;` top-level reader — enum-constant + arguments must be constant expressions, so the reader is a function + tear-off, not an inline closure). +2. Add the same parameter to the `_rebuildTheme()` call in + `theme_configuration.dart` (`xxx: _overrides[ThemeColorSlot.xxx]`). +3. Add the slot to a group in `theme_studio_sections.dart` — this alone adds + its UI control to the panel, the export page, and code generation. +4. Update `theme_color_slot_test.dart`'s pinned parameter-name list. + +`brand`/`chrome` (swatch-valued, see `ThemeSeedSlot`) and `avatarPalette` +(a list, not a color) are handled separately and don't go through this path. ### Best Practices diff --git a/apps/design_system_gallery/lib/app/gallery_app.dart b/apps/design_system_gallery/lib/app/gallery_app.dart index 48a5c37a..29b56303 100644 --- a/apps/design_system_gallery/lib/app/gallery_app.dart +++ b/apps/design_system_gallery/lib/app/gallery_app.dart @@ -5,6 +5,7 @@ import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; import '../config/preview_configuration.dart'; import '../config/theme_configuration.dart'; import 'gallery_shell.dart'; +import 'theme_export_page.dart'; /// Stream Design System Gallery /// @@ -30,6 +31,10 @@ class _StreamDesignSystemGalleryState extends State { super.dispose(); } + void _openThemeExport(BuildContext context) { + Navigator.of(context).push(MaterialPageRoute(builder: (_) => const ThemeExportPage())); + } + @override Widget build(BuildContext context) { return MultiProvider( @@ -49,9 +54,13 @@ class _StreamDesignSystemGalleryState extends State { theme: materialTheme, darkTheme: materialTheme, themeMode: isDark ? .dark : .light, - home: GalleryShell( - showThemePanel: _showThemePanel, - onToggleThemePanel: () => setState(() => _showThemePanel = !_showThemePanel), + home: Builder( + // Builder gives a context below the [MaterialApp]'s [Navigator]. + builder: (context) => GalleryShell( + showThemePanel: _showThemePanel, + onToggleThemePanel: () => setState(() => _showThemePanel = !_showThemePanel), + onExportTheme: () => _openThemeExport(context), + ), ), ); }, diff --git a/apps/design_system_gallery/lib/app/gallery_shell.dart b/apps/design_system_gallery/lib/app/gallery_shell.dart index 42ce4758..122b9863 100644 --- a/apps/design_system_gallery/lib/app/gallery_shell.dart +++ b/apps/design_system_gallery/lib/app/gallery_shell.dart @@ -23,10 +23,12 @@ class GalleryShell extends StatelessWidget { super.key, required this.showThemePanel, required this.onToggleThemePanel, + required this.onExportTheme, }); final bool showThemePanel; final VoidCallback onToggleThemePanel; + final VoidCallback onExportTheme; @override Widget build(BuildContext context) { @@ -55,6 +57,7 @@ class GalleryShell extends StatelessWidget { GalleryToolbar( showThemePanel: showThemePanel, onToggleThemePanel: onToggleThemePanel, + onExportTheme: onExportTheme, ), // Content area below toolbar Expanded( diff --git a/apps/design_system_gallery/lib/app/theme_export_page.dart b/apps/design_system_gallery/lib/app/theme_export_page.dart new file mode 100644 index 00000000..66ef3f2e --- /dev/null +++ b/apps/design_system_gallery/lib/app/theme_export_page.dart @@ -0,0 +1,343 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:stream_core_flutter/core.dart'; + +import '../config/component_theme_descriptors.dart'; +import '../config/theme_color_slot.dart'; +import '../config/theme_configuration.dart'; +import '../config/theme_export_configuration.dart'; +import '../config/theme_studio_sections.dart'; +import '../widgets/theme_export/theme_export_widgets.dart'; +import '../widgets/theme_studio/add_component_theme_button.dart'; +import '../widgets/theme_studio/color_picker_tile.dart'; + +/// Each settings column's width in the side-by-side layout — slightly wider +/// than the theme studio panel (`kThemePanelWidth` in gallery_shell.dart, +/// 340) since a column here also carries its own copy of the row content +/// the studio doesn't need to fit (e.g. wider component property names). +const _kSettingsColumnWidth = 360.0; + +const _kSettingsWidth = _kSettingsColumnWidth * 2 + kExportLinkColumnWidth; + +/// The code pane's floor in the side-by-side layout — it's handed +/// everything left over after the (fixed-width) settings columns, down to +/// this minimum, not a fixed width of its own. +const _kCodePaneMinWidth = 440.0; + +/// Below this width, the settings columns and a code pane wide enough to be +/// useful no longer fit side by side — collapse into two tabs instead. +const _kTabsBreakpoint = _kSettingsWidth + _kCodePaneMinWidth; + +/// Full-screen theme export page: light settings, dark settings, and a live, +/// copy-pasteable Dart snippet, pushed from the toolbar's export button. +/// +/// Seeds two independent [ThemeConfiguration]s (see +/// [ThemeExportConfiguration]) from the theme studio's current, +/// brightness-agnostic state. Edits here never write back to the studio — +/// export is one-way. +class ThemeExportPage extends StatefulWidget { + const ThemeExportPage({super.key}); + + @override + State createState() => _ThemeExportPageState(); +} + +class _ThemeExportPageState extends State { + late final ThemeExportConfiguration _export; + + @override + void initState() { + super.initState(); + _export = ThemeExportConfiguration(context.read()); + } + + @override + void dispose() { + _export.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final useTabs = MediaQuery.sizeOf(context).width < _kTabsBreakpoint; + + return ChangeNotifierProvider.value( + value: _export, + child: useTabs ? const _TabbedExportLayout() : const _SideBySideExportLayout(), + ); + } +} + +/// The settings columns at a fixed width, beside a code pane that absorbs +/// all remaining width (never less than [_kCodePaneMinWidth] — that's what +/// [_kTabsBreakpoint] guarantees by gating this layout in the first place). +class _SideBySideExportLayout extends StatelessWidget { + const _SideBySideExportLayout(); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Export Theme')), + body: const Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox(width: _kSettingsWidth, child: _SettingsWithPreview()), + Expanded(child: ThemeExportCodePane()), + ], + ), + ); + } +} + +/// Two tabs — Theme Settings / Dart Code — for windows too narrow to show +/// both at a useful width side by side. +class _TabbedExportLayout extends StatelessWidget { + const _TabbedExportLayout(); + + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + title: const Text('Export Theme'), + bottom: const TabBar( + tabs: [ + Tab(text: 'Theme Settings'), + Tab(text: 'Dart Code'), + ], + ), + ), + body: const TabBarView( + children: [ + _SettingsWithPreview(), + ThemeExportCodePane(), + ], + ), + ), + ); + } +} + +/// The scrollable settings columns with the message preview pinned below +/// them — full width, outside the scroll, on each column's own background. +/// Shared by both layouts. +class _SettingsWithPreview extends StatelessWidget { + const _SettingsWithPreview(); + + @override + Widget build(BuildContext context) { + return const Column( + children: [ + Expanded(child: _SettingsColumns()), + ThemeExportPreviewBar(), + ], + ); + } +} + +/// The two linked settings columns (brand/chrome seeds, then every +/// [ThemeColorSlot] grouped per [themeStudioSections]), lazily built via a +/// [SliverList] — with ~51 color rows this matters for scroll performance. +/// +/// Every row (color tiles, section headers, group headings, and the spacing +/// between them) is an [ExportColumnRow], so each column paints one +/// continuous `backgroundApp` from top to bottom with no gaps — nothing +/// shows the page's own background as a seam between rows. +class _SettingsColumns extends StatelessWidget { + const _SettingsColumns(); + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + // Measured once here, above ExportColumnRow's IntrinsicHeight (used to + // align light/dark tiles) - ColorPickerTile can't measure this itself + // via its own LayoutBuilder, since IntrinsicHeight can't compute + // intrinsic dimensions through one. See isColorPickerTileCompact. + return LayoutBuilder( + builder: (context, constraints) { + final columnWidth = (constraints.maxWidth - spacing.md * 2 - kExportLinkColumnWidth) / 2; + final compact = isColorPickerTileCompact(columnWidth, spacing); + final rows = _buildRows(context, spacing, compact); + + return CustomScrollView( + slivers: [ + SliverPadding( + padding: EdgeInsets.all(spacing.md), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) => rows[index], childCount: rows.length), + ), + ), + ], + ); + }, + ); + } + + List _buildRows(BuildContext context, StreamSpacing spacing, bool compact) { + final export = context.watch(); + Widget spacer(double height) => ExportColumnRow.spacer( + lightMaterialTheme: export.lightMaterialTheme, + darkMaterialTheme: export.darkMaterialTheme, + height: height, + ); + + final rows = [ + const _SectionHeader(title: 'Brand & Chrome', subtitle: 'brand, chrome'), + spacer(spacing.sm), + LinkedSeedRow(seed: ThemeSeedSlot.brand, label: 'brand', compact: compact), + LinkedSeedRow(seed: ThemeSeedSlot.chrome, label: 'chrome', compact: compact), + spacer(spacing.lg), + ]; + + for (final section in themeStudioSections) { + rows.add(_SectionHeader(title: section.title, subtitle: section.subtitle)); + rows.add(spacer(spacing.sm)); + for (final group in section.groups) { + if (group.heading case final heading?) { + rows.add(_GroupHeading(heading)); + } + rows.addAll(group.slots.map((slot) => LinkedColorRow(slot: slot, compact: compact))); + } + rows.add(spacer(spacing.lg)); + } + + for (final component in export.activeComponentThemes) { + final descriptor = componentThemeDescriptorOrNull(component); + if (descriptor == null) continue; + rows.add(_SectionHeader(title: descriptor.name, subtitle: descriptor.themeParameterName)); + rows.add(spacer(spacing.sm)); + rows.addAll( + descriptor.properties.map( + (property) => LinkedComponentColorRow(component: component, property: property, compact: compact), + ), + ); + rows.add(spacer(spacing.sm)); + rows.add( + Padding( + padding: EdgeInsets.symmetric(horizontal: spacing.md), + child: RemoveComponentThemeButton(onTap: () => export.removeComponentTheme(component)), + ), + ); + rows.add(spacer(spacing.lg)); + } + + final availableComponentThemes = componentThemeDescriptors + .where((d) => !export.activeComponentThemes.contains(d.name)) + .toList(); + rows.add( + Padding( + padding: EdgeInsets.symmetric(horizontal: spacing.md), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: AddComponentThemeButton(available: availableComponentThemes, onSelected: export.addComponentTheme), + ), + ), + ), + ); + + return rows; + } +} + +/// A section title + subtitle chip (e.g. "Accent Colors" / `accent*`), +/// rendered once per column so it picks up that column's own text colors — +/// a single full-width neutral header would show as a flat, wrong-colored +/// bar cutting across the dark column. +class _SectionHeader extends StatelessWidget { + const _SectionHeader({required this.title, required this.subtitle}); + + final String title; + final String subtitle; + + @override + Widget build(BuildContext context) { + final export = context.watch(); + + return ExportColumnRow( + lightMaterialTheme: export.lightMaterialTheme, + darkMaterialTheme: export.darkMaterialTheme, + lightBuilder: _content, + darkBuilder: _content, + ); + } + + Widget _content(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + final radius = context.streamRadius; + + return Padding( + padding: EdgeInsets.symmetric(horizontal: spacing.md, vertical: spacing.sm), + child: Row( + children: [ + // Expanded, not Flexible: two Flexible children split the free + // space evenly, which capped the title at ~half the row and + // ellipsized it while the chip beside it sat in unused space. + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.headingXs.copyWith(color: colorScheme.textPrimary), + ), + ), + SizedBox(width: spacing.xs + spacing.xxs), + // flex: 0 sizes the chip to its content instead of claiming a + // share of the row, leaving the rest to the title. + Flexible( + flex: 0, + child: Container( + padding: EdgeInsets.symmetric(horizontal: spacing.xs, vertical: 1), + decoration: BoxDecoration( + color: colorScheme.backgroundSurfaceSubtle, + borderRadius: BorderRadius.all(radius.xs), + ), + child: Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.metadataDefault.copyWith(color: colorScheme.textTertiary, fontFamily: 'monospace'), + ), + ), + ), + ], + ), + ); + } +} + +/// A sub-heading within a section (e.g. "Surface"/"Elevation" inside +/// Background Colors), rendered once per column for the same reason as +/// [_SectionHeader]. +class _GroupHeading extends StatelessWidget { + const _GroupHeading(this.heading); + + final String heading; + + @override + Widget build(BuildContext context) { + final export = context.watch(); + + return ExportColumnRow( + lightMaterialTheme: export.lightMaterialTheme, + darkMaterialTheme: export.darkMaterialTheme, + lightBuilder: _content, + darkBuilder: _content, + ); + } + + Widget _content(BuildContext context) { + final spacing = context.streamSpacing; + return Padding( + padding: EdgeInsets.symmetric(horizontal: spacing.md, vertical: spacing.xs), + child: Text( + heading, + style: context.streamTextTheme.metadataEmphasis.copyWith(color: context.streamColorScheme.textSecondary), + ), + ); + } +} diff --git a/apps/design_system_gallery/lib/config/component_theme_descriptors.dart b/apps/design_system_gallery/lib/config/component_theme_descriptors.dart new file mode 100644 index 00000000..b1b62655 --- /dev/null +++ b/apps/design_system_gallery/lib/config/component_theme_descriptors.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/core.dart'; + +/// A component theme this feature can edit: the plain `Color?` properties of +/// one of [StreamTheme]'s ~40 component theme data classes. +/// +/// Scoped to a handful of components with meaningful *direct* Color +/// properties — most component themes bury their colors inside nested style +/// objects (e.g. [StreamMessageBubbleStyle]) instead of exposing them +/// directly, which this simple property-list model can't reach. Extending +/// coverage to more components, or to nested styles, means adding another +/// [ComponentThemeDescriptor] below; it doesn't require touching anything +/// else in `theme_studio`. +class ComponentThemeDescriptor { + const ComponentThemeDescriptor({ + required this.name, + required this.themeParameterName, + required this.themeDataTypeName, + required this.properties, + required this.build, + }); + + /// Display name, e.g. `'Avatar'`. + final String name; + + /// The named parameter on `StreamTheme(...)` this plugs into, e.g. + /// `'avatarTheme'` for `StreamTheme(avatarTheme: ...)`. + final String themeParameterName; + + /// The Dart type this builds, e.g. `'StreamAvatarThemeData'` — used by the + /// code generator to emit `StreamAvatarThemeData(...)` calls. + final String themeDataTypeName; + + /// The editable `Color?` property names, in constructor order. + final List properties; + + /// Builds the component's theme-data object from a (possibly partial) map + /// of property name to color. + final Object Function(Map values) build; +} + +/// The component themes covered by the theme studio's "Add component theme" +/// picker, in the order they appear there. +/// +/// Deliberately excludes Avatar: its color story is the Avatar Palette +/// section (a set of rotating background/foreground pairs, consumed by +/// downstream packages like stream_chat_flutter for per-user color +/// selection), not a single fixed override like the component themes below. +final componentThemeDescriptors = [ + ComponentThemeDescriptor( + name: 'Badge Count', + themeParameterName: 'badgeCountTheme', + themeDataTypeName: 'StreamBadgeCountThemeData', + properties: const ['textColor', 'backgroundColor', 'borderColor'], + build: (Map v) => StreamBadgeCountThemeData( + textColor: v['textColor'], + backgroundColor: v['backgroundColor'], + borderColor: v['borderColor'], + ), + ), + ComponentThemeDescriptor( + name: 'Badge Notification', + themeParameterName: 'badgeNotificationTheme', + themeDataTypeName: 'StreamBadgeNotificationThemeData', + properties: const [ + 'primaryBackgroundColor', + 'errorBackgroundColor', + 'neutralBackgroundColor', + 'textColor', + 'borderColor', + ], + build: (Map v) => StreamBadgeNotificationThemeData( + primaryBackgroundColor: v['primaryBackgroundColor'], + errorBackgroundColor: v['errorBackgroundColor'], + neutralBackgroundColor: v['neutralBackgroundColor'], + textColor: v['textColor'], + borderColor: v['borderColor'], + ), + ), + ComponentThemeDescriptor( + name: 'Online Indicator', + themeParameterName: 'onlineIndicatorTheme', + themeDataTypeName: 'StreamOnlineIndicatorThemeData', + properties: const ['backgroundOnline', 'backgroundOffline', 'borderColor'], + build: (Map v) => StreamOnlineIndicatorThemeData( + backgroundOnline: v['backgroundOnline'], + backgroundOffline: v['backgroundOffline'], + borderColor: v['borderColor'], + ), + ), +]; + +/// [componentThemeDescriptors] keyed by name, so resolving one is a map +/// lookup rather than a linear rescan on every rebuild. +final _descriptorsByName = {for (final descriptor in componentThemeDescriptors) descriptor.name: descriptor}; + +/// The descriptor named [name], or `null` when no descriptor has that name. +/// +/// Active component themes are tracked by free-form [String] name +/// (`ThemeConfiguration.componentOverrides`, the studio panel, the export +/// page), so a name with no matching descriptor is representable — e.g. one +/// renamed or removed from [componentThemeDescriptors] while still active. +/// Returning `null` lets callers skip it; the `firstWhere` this replaces +/// threw a `StateError` from inside `build`. +ComponentThemeDescriptor? componentThemeDescriptorOrNull(String name) => _descriptorsByName[name]; diff --git a/apps/design_system_gallery/lib/config/theme_color_slot.dart b/apps/design_system_gallery/lib/config/theme_color_slot.dart new file mode 100644 index 00000000..59d1caac --- /dev/null +++ b/apps/design_system_gallery/lib/config/theme_color_slot.dart @@ -0,0 +1,175 @@ +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/core.dart'; + +/// A single, plain [Color]-valued parameter of [StreamColorScheme]. +/// +/// Every value here has a matching named parameter on both +/// [StreamColorScheme.light] and [StreamColorScheme.dark] with the exact same +/// [parameterName]. This enum exists so the theme studio panel, the export +/// page, and the code generator can all iterate the same list instead of +/// hand-writing it four times over — which is how the studio silently lost +/// track of [textOnInverse], [borderOnInverse] and [borderDisabledOnSurface] +/// in the first place. +/// +/// `brand` and `chrome` are intentionally excluded: they are +/// [StreamColorSwatch]-valued, not [Color]-valued, so they have a different +/// write path (see [ThemeSeedSlot]). `avatarPalette` is excluded too, since +/// it's a `List`, not a color. +/// +/// The order of values matches the parameter order of +/// [StreamColorScheme.light] so generated code and studio sections read like +/// the SDK API. +enum ThemeColorSlot { + // Accent + accentPrimary('accentPrimary', _readAccentPrimary), + accentSuccess('accentSuccess', _readAccentSuccess), + accentWarning('accentWarning', _readAccentWarning), + accentError('accentError', _readAccentError), + accentNeutral('accentNeutral', _readAccentNeutral), + + // Text + textPrimary('textPrimary', _readTextPrimary), + textSecondary('textSecondary', _readTextSecondary), + textTertiary('textTertiary', _readTextTertiary), + textDisabled('textDisabled', _readTextDisabled), + textLink('textLink', _readTextLink), + textOnAccent('textOnAccent', _readTextOnAccent), + textOnInverse('textOnInverse', _readTextOnInverse), + + // Background + backgroundApp('backgroundApp', _readBackgroundApp), + backgroundSurface('backgroundSurface', _readBackgroundSurface), + backgroundSurfaceSubtle('backgroundSurfaceSubtle', _readBackgroundSurfaceSubtle), + backgroundSurfaceStrong('backgroundSurfaceStrong', _readBackgroundSurfaceStrong), + backgroundSurfaceCard('backgroundSurfaceCard', _readBackgroundSurfaceCard), + backgroundOnAccent('backgroundOnAccent', _readBackgroundOnAccent), + backgroundHighlight('backgroundHighlight', _readBackgroundHighlight), + backgroundScrim('backgroundScrim', _readBackgroundScrim), + backgroundOverlayLight('backgroundOverlayLight', _readBackgroundOverlayLight), + backgroundOverlayDark('backgroundOverlayDark', _readBackgroundOverlayDark), + backgroundDisabled('backgroundDisabled', _readBackgroundDisabled), + backgroundInverse('backgroundInverse', _readBackgroundInverse), + + // Background - Elevation + backgroundElevation0('backgroundElevation0', _readBackgroundElevation0), + backgroundElevation1('backgroundElevation1', _readBackgroundElevation1), + backgroundElevation2('backgroundElevation2', _readBackgroundElevation2), + backgroundElevation3('backgroundElevation3', _readBackgroundElevation3), + + // Border - Core + borderDefault('borderDefault', _readBorderDefault), + borderSubtle('borderSubtle', _readBorderSubtle), + borderStrong('borderStrong', _readBorderStrong), + borderOnAccent('borderOnAccent', _readBorderOnAccent), + borderOnInverse('borderOnInverse', _readBorderOnInverse), + borderOnSurface('borderOnSurface', _readBorderOnSurface), + borderOpacitySubtle('borderOpacitySubtle', _readBorderOpacitySubtle), + borderOpacityStrong('borderOpacityStrong', _readBorderOpacityStrong), + + // Border - Utility + borderFocus('borderFocus', _readBorderFocus), + borderDisabled('borderDisabled', _readBorderDisabled), + borderDisabledOnSurface('borderDisabledOnSurface', _readBorderDisabledOnSurface), + borderHover('borderHover', _readBorderHover), + borderPressed('borderPressed', _readBorderPressed), + borderActive('borderActive', _readBorderActive), + borderError('borderError', _readBorderError), + borderWarning('borderWarning', _readBorderWarning), + borderSuccess('borderSuccess', _readBorderSuccess), + borderSelected('borderSelected', _readBorderSelected), + + // State + backgroundHover('backgroundHover', _readBackgroundHover), + backgroundPressed('backgroundPressed', _readBackgroundPressed), + backgroundSelected('backgroundSelected', _readBackgroundSelected), + + // System + systemText('systemText', _readSystemText), + systemScrollbar('systemScrollbar', _readSystemScrollbar); + + const ThemeColorSlot(this.parameterName, this.read); + + /// The exact named-parameter name on [StreamColorScheme.light]/`.dark`. + final String parameterName; + + /// Reads this slot's resolved value off a built [StreamColorScheme]. + final Color Function(StreamColorScheme scheme) read; +} + +// Enum-constant arguments must be constant expressions, which rules out +// closures — so each reader is a top-level function tear-off (tear-offs of +// top-level functions are compile-time constants) instead of an inline +// `(s) => s.foo` lambda. +Color _readAccentPrimary(StreamColorScheme s) => s.accentPrimary; +Color _readAccentSuccess(StreamColorScheme s) => s.accentSuccess; +Color _readAccentWarning(StreamColorScheme s) => s.accentWarning; +Color _readAccentError(StreamColorScheme s) => s.accentError; +Color _readAccentNeutral(StreamColorScheme s) => s.accentNeutral; + +Color _readTextPrimary(StreamColorScheme s) => s.textPrimary; +Color _readTextSecondary(StreamColorScheme s) => s.textSecondary; +Color _readTextTertiary(StreamColorScheme s) => s.textTertiary; +Color _readTextDisabled(StreamColorScheme s) => s.textDisabled; +Color _readTextLink(StreamColorScheme s) => s.textLink; +Color _readTextOnAccent(StreamColorScheme s) => s.textOnAccent; +Color _readTextOnInverse(StreamColorScheme s) => s.textOnInverse; + +Color _readBackgroundApp(StreamColorScheme s) => s.backgroundApp; +Color _readBackgroundSurface(StreamColorScheme s) => s.backgroundSurface; +Color _readBackgroundSurfaceSubtle(StreamColorScheme s) => s.backgroundSurfaceSubtle; +Color _readBackgroundSurfaceStrong(StreamColorScheme s) => s.backgroundSurfaceStrong; +Color _readBackgroundSurfaceCard(StreamColorScheme s) => s.backgroundSurfaceCard; +Color _readBackgroundOnAccent(StreamColorScheme s) => s.backgroundOnAccent; +Color _readBackgroundHighlight(StreamColorScheme s) => s.backgroundHighlight; +Color _readBackgroundScrim(StreamColorScheme s) => s.backgroundScrim; +Color _readBackgroundOverlayLight(StreamColorScheme s) => s.backgroundOverlayLight; +Color _readBackgroundOverlayDark(StreamColorScheme s) => s.backgroundOverlayDark; +Color _readBackgroundDisabled(StreamColorScheme s) => s.backgroundDisabled; +Color _readBackgroundInverse(StreamColorScheme s) => s.backgroundInverse; + +Color _readBackgroundElevation0(StreamColorScheme s) => s.backgroundElevation0; +Color _readBackgroundElevation1(StreamColorScheme s) => s.backgroundElevation1; +Color _readBackgroundElevation2(StreamColorScheme s) => s.backgroundElevation2; +Color _readBackgroundElevation3(StreamColorScheme s) => s.backgroundElevation3; + +Color _readBorderDefault(StreamColorScheme s) => s.borderDefault; +Color _readBorderSubtle(StreamColorScheme s) => s.borderSubtle; +Color _readBorderStrong(StreamColorScheme s) => s.borderStrong; +Color _readBorderOnAccent(StreamColorScheme s) => s.borderOnAccent; +Color _readBorderOnInverse(StreamColorScheme s) => s.borderOnInverse; +Color _readBorderOnSurface(StreamColorScheme s) => s.borderOnSurface; +Color _readBorderOpacitySubtle(StreamColorScheme s) => s.borderOpacitySubtle; +Color _readBorderOpacityStrong(StreamColorScheme s) => s.borderOpacityStrong; + +Color _readBorderFocus(StreamColorScheme s) => s.borderFocus; +Color _readBorderDisabled(StreamColorScheme s) => s.borderDisabled; +Color _readBorderDisabledOnSurface(StreamColorScheme s) => s.borderDisabledOnSurface; +Color _readBorderHover(StreamColorScheme s) => s.borderHover; +Color _readBorderPressed(StreamColorScheme s) => s.borderPressed; +Color _readBorderActive(StreamColorScheme s) => s.borderActive; +Color _readBorderError(StreamColorScheme s) => s.borderError; +Color _readBorderWarning(StreamColorScheme s) => s.borderWarning; +Color _readBorderSuccess(StreamColorScheme s) => s.borderSuccess; +Color _readBorderSelected(StreamColorScheme s) => s.borderSelected; + +Color _readBackgroundHover(StreamColorScheme s) => s.backgroundHover; +Color _readBackgroundPressed(StreamColorScheme s) => s.backgroundPressed; +Color _readBackgroundSelected(StreamColorScheme s) => s.backgroundSelected; + +Color _readSystemText(StreamColorScheme s) => s.systemText; +Color _readSystemScrollbar(StreamColorScheme s) => s.systemScrollbar; + +/// The two [StreamColorSwatch]-valued seed parameters of [StreamColorScheme]. +/// +/// Unlike [ThemeColorSlot], a seed is written by wrapping a plain [Color] in +/// [StreamColorSwatch.fromColor] and is exported the same way — see +/// `theme_code_generator.dart`. +enum ThemeSeedSlot { + brand('brand'), + chrome('chrome'); + + const ThemeSeedSlot(this.parameterName); + + /// The exact named-parameter name on [StreamColorScheme.light]/`.dark`. + final String parameterName; +} diff --git a/apps/design_system_gallery/lib/config/theme_configuration.dart b/apps/design_system_gallery/lib/config/theme_configuration.dart index 733359e7..7dd1bd35 100644 --- a/apps/design_system_gallery/lib/config/theme_configuration.dart +++ b/apps/design_system_gallery/lib/config/theme_configuration.dart @@ -1,10 +1,15 @@ import 'package:flutter/material.dart'; import 'package:stream_core_flutter/core.dart'; +import 'component_theme_descriptors.dart'; +import 'theme_color_slot.dart'; + /// A notifier that manages the theme configuration for the design system gallery. /// /// Supports full customization of the Stream design system theme using the -/// exact naming conventions from [StreamColorScheme]. +/// exact naming conventions from [StreamColorScheme]. Overrides for the 51 +/// plain-color parameters are keyed by [ThemeColorSlot] rather than +/// hand-written per-color fields — see `theme_color_slot.dart` for why. class ThemeConfiguration extends ChangeNotifier { ThemeConfiguration({ Brightness brightness = Brightness.light, @@ -15,6 +20,21 @@ class ThemeConfiguration extends ChangeNotifier { factory ThemeConfiguration.light() => ThemeConfiguration(); factory ThemeConfiguration.dark() => ThemeConfiguration(brightness: Brightness.dark); + /// Creates a [ThemeConfiguration] seeded with [source]'s current overrides. + /// + /// Used by the export page to derive independent light/dark configurations + /// from the (brightness-agnostic) theme studio state, without either side + /// writing back to [source]. + factory ThemeConfiguration.seededFrom(ThemeConfiguration source, {required Brightness brightness}) { + return ThemeConfiguration(brightness: brightness)..applyOverrides( + source._overrides, + brandSeed: source._brandSeed, + chromeSeed: source._chromeSeed, + avatarPalette: source._avatarPalette, + componentOverrides: source._componentOverrides, + ); + } + // ========================================================================= // Core State // ========================================================================= @@ -24,179 +44,140 @@ class ThemeConfiguration extends ChangeNotifier { Brightness _brightness; Brightness get brightness => _brightness; - // ========================================================================= - // Accent Colors - // ========================================================================= - Color? _accentPrimary; - Color? _accentSuccess; - Color? _accentWarning; - Color? _accentError; - Color? _accentNeutral; + // Overrides for every plain Color? parameter of StreamColorScheme, keyed by + // slot. Absence of a key means "use the SDK default". + final Map _overrides = {}; - // ========================================================================= - // Text Colors - // ========================================================================= - Color? _textPrimary; - Color? _textSecondary; - Color? _textTertiary; - Color? _textDisabled; - Color? _textLink; - Color? _textOnAccent; + // Brand & chrome are StreamColorSwatch-valued, so they're seeded from a + // single Color and normalized via StreamColorSwatch.fromColor, not stored + // directly as overrides. + Color? _brandSeed; + Color? _chromeSeed; - // ========================================================================= - // Background Colors - // ========================================================================= - Color? _backgroundApp; - Color? _backgroundSurface; - Color? _backgroundSurfaceSubtle; - Color? _backgroundSurfaceStrong; - Color? _backgroundSurfaceCard; - Color? _backgroundOnAccent; - Color? _backgroundHighlight; - Color? _backgroundScrim; - Color? _backgroundOverlayLight; - Color? _backgroundOverlayDark; - Color? _backgroundDisabled; - Color? _backgroundHover; - Color? _backgroundPressed; - Color? _backgroundSelected; - Color? _backgroundInverse; - Color? _backgroundElevation0; - Color? _backgroundElevation1; - Color? _backgroundElevation2; - Color? _backgroundElevation3; + List? _avatarPalette; - // ========================================================================= - // Border Colors - Core - // ========================================================================= - Color? _borderDefault; - Color? _borderSubtle; - Color? _borderStrong; - Color? _borderOnAccent; - Color? _borderOnSurface; - Color? _borderOpacitySubtle; - Color? _borderOpacityStrong; + // Component theme overrides, keyed by ComponentThemeDescriptor.name, then + // by property name (e.g. _componentOverrides['Avatar']['backgroundColor']). + // A component appears here (possibly with an empty inner map) once added + // via addComponentTheme, so the studio panel keeps showing its section + // even before any of its colors are customized. + final Map> _componentOverrides = {}; + + // ========================================================================= + // Slot-based access + // ========================================================================= + + /// Resolves [slot] to its current value: the override if one is set, + /// otherwise the SDK default derived by [StreamColorScheme]. + /// + /// Reads the raw override first rather than reading back through + /// [themeData] — necessary for [brandPrimaryColor]/[chromePrimaryColor] + /// (see below) but kept consistent here too. + Color resolve(ThemeColorSlot slot) => _overrides[slot] ?? slot.read(_themeData.colorScheme); + + /// Whether [slot] has been overridden (vs. using the SDK default). + bool isCustom(ThemeColorSlot slot) => _overrides.containsKey(slot); + + /// A read-only snapshot of every currently-overridden slot. + Map get overrides => Map.unmodifiable(_overrides); + + void setOverride(ThemeColorSlot slot, Color color) => _update(() => _overrides[slot] = color); + + void resetOverride(ThemeColorSlot slot) => _update(() => _overrides.remove(slot)); + + /// Replaces all overrides, brand/chrome seeds, the avatar palette, and + /// component theme overrides in one rebuild+notify. Used to seed a new + /// [ThemeConfiguration] from another (see [ThemeConfiguration.seededFrom]). + void applyOverrides( + Map overrides, { + Color? brandSeed, + Color? chromeSeed, + List? avatarPalette, + Map>? componentOverrides, + }) { + _overrides + ..clear() + ..addAll(overrides); + _brandSeed = brandSeed; + _chromeSeed = chromeSeed; + _avatarPalette = avatarPalette; + _componentOverrides + ..clear() + ..addAll( + componentOverrides?.map((component, values) => MapEntry(component, Map.from(values))) ?? {}, + ); + _rebuildTheme(); + notifyListeners(); + } // ========================================================================= - // Border Colors - Utility + // Getters - Brand & Chrome // ========================================================================= - Color? _borderFocus; - Color? _borderDisabled; - Color? _borderHover; - Color? _borderPressed; - Color? _borderActive; - Color? _borderError; - Color? _borderWarning; - Color? _borderSuccess; - Color? _borderSelected; - // ========================================================================= - // System Colors - // ========================================================================= - Color? _systemText; - Color? _systemScrollbar; + // brand.shade500 is NOT the seed color the user picked - StreamColorSwatch + // normalizes the seed onto the HCT tone ladder - so the raw override must + // win here rather than reading back through the built scheme. + Color get brandPrimaryColor => _brandSeed ?? _themeData.colorScheme.brand.shade500; + Color get chromePrimaryColor => _chromeSeed ?? _themeData.colorScheme.chrome.shade500; - // ========================================================================= - // Avatar Palette - // ========================================================================= - List? _avatarPalette; + bool get brandIsCustom => _brandSeed != null; + bool get chromeIsCustom => _chromeSeed != null; // ========================================================================= - // Brand Color + // Getters - Avatar Palette // ========================================================================= - Color? _brandPrimaryColor; + List get avatarPalette => _avatarPalette ?? _themeData.colorScheme.avatarPalette; + bool get avatarPaletteIsCustom => _avatarPalette != null; // ========================================================================= - // Chrome Color + // Component theme overrides // ========================================================================= - Color? _chromePrimaryColor; - // ========================================================================= - // Getters - Accent - // ========================================================================= - Color get accentPrimary => _accentPrimary ?? _themeData.colorScheme.accentPrimary; - Color get accentSuccess => _accentSuccess ?? _themeData.colorScheme.accentSuccess; - Color get accentWarning => _accentWarning ?? _themeData.colorScheme.accentWarning; - Color get accentError => _accentError ?? _themeData.colorScheme.accentError; - Color get accentNeutral => _accentNeutral ?? _themeData.colorScheme.accentNeutral; + /// Component themes currently shown in the studio (added via + /// [addComponentTheme]) — present even before any of their colors are + /// customized, so the panel keeps rendering an empty section for them. + Set get activeComponentThemes => Set.unmodifiable(_componentOverrides.keys); - // ========================================================================= - // Getters - Text - // ========================================================================= - Color get textPrimary => _textPrimary ?? _themeData.colorScheme.textPrimary; - Color get textSecondary => _textSecondary ?? _themeData.colorScheme.textSecondary; - Color get textTertiary => _textTertiary ?? _themeData.colorScheme.textTertiary; - Color get textDisabled => _textDisabled ?? _themeData.colorScheme.textDisabled; - Color get textLink => _textLink ?? _themeData.colorScheme.textLink; - Color get textOnAccent => _textOnAccent ?? _themeData.colorScheme.textOnAccent; + /// A read-only deep snapshot of every active component's overrides. + Map> get componentOverrides => { + for (final entry in _componentOverrides.entries) entry.key: Map.unmodifiable(entry.value), + }; - // ========================================================================= - // Getters - Background - // ========================================================================= - Color get backgroundApp => _backgroundApp ?? _themeData.colorScheme.backgroundApp; - Color get backgroundSurface => _backgroundSurface ?? _themeData.colorScheme.backgroundSurface; - Color get backgroundSurfaceSubtle => _backgroundSurfaceSubtle ?? _themeData.colorScheme.backgroundSurfaceSubtle; - Color get backgroundSurfaceStrong => _backgroundSurfaceStrong ?? _themeData.colorScheme.backgroundSurfaceStrong; - Color get backgroundSurfaceCard => _backgroundSurfaceCard ?? _themeData.colorScheme.backgroundSurfaceCard; - Color get backgroundOnAccent => _backgroundOnAccent ?? _themeData.colorScheme.backgroundOnAccent; - Color get backgroundHighlight => _backgroundHighlight ?? _themeData.colorScheme.backgroundHighlight; - Color get backgroundScrim => _backgroundScrim ?? _themeData.colorScheme.backgroundScrim; - Color get backgroundOverlayLight => _backgroundOverlayLight ?? _themeData.colorScheme.backgroundOverlayLight; - Color get backgroundOverlayDark => _backgroundOverlayDark ?? _themeData.colorScheme.backgroundOverlayDark; - Color get backgroundDisabled => _backgroundDisabled ?? _themeData.colorScheme.backgroundDisabled; - Color get backgroundHover => _backgroundHover ?? _themeData.colorScheme.backgroundHover; - Color get backgroundPressed => _backgroundPressed ?? _themeData.colorScheme.backgroundPressed; - Color get backgroundSelected => _backgroundSelected ?? _themeData.colorScheme.backgroundSelected; - Color get backgroundInverse => _backgroundInverse ?? _themeData.colorScheme.backgroundInverse; - Color get backgroundElevation0 => _backgroundElevation0 ?? _themeData.colorScheme.backgroundElevation0; - Color get backgroundElevation1 => _backgroundElevation1 ?? _themeData.colorScheme.backgroundElevation1; - Color get backgroundElevation2 => _backgroundElevation2 ?? _themeData.colorScheme.backgroundElevation2; - Color get backgroundElevation3 => _backgroundElevation3 ?? _themeData.colorScheme.backgroundElevation3; + Color? resolveComponentColor(String component, String property) => _componentOverrides[component]?[property]; - // ========================================================================= - // Getters - Border Core - // ========================================================================= - Color get borderDefault => _borderDefault ?? _themeData.colorScheme.borderDefault; - Color get borderSubtle => _borderSubtle ?? _themeData.colorScheme.borderSubtle; - Color get borderStrong => _borderStrong ?? _themeData.colorScheme.borderStrong; - Color get borderOnAccent => _borderOnAccent ?? _themeData.colorScheme.borderOnAccent; - Color get borderOnSurface => _borderOnSurface ?? _themeData.colorScheme.borderOnSurface; - Color get borderOpacitySubtle => _borderOpacitySubtle ?? _themeData.colorScheme.borderOpacitySubtle; - Color get borderOpacityStrong => _borderOpacityStrong ?? _themeData.colorScheme.borderOpacityStrong; + bool isComponentColorCustom(String component, String property) => + _componentOverrides[component]?.containsKey(property) ?? false; - // ========================================================================= - // Getters - Border Utility - // ========================================================================= - Color get borderFocus => _borderFocus ?? _themeData.colorScheme.borderFocus; - Color get borderDisabled => _borderDisabled ?? _themeData.colorScheme.borderDisabled; - Color get borderHover => _borderHover ?? _themeData.colorScheme.borderHover; - Color get borderPressed => _borderPressed ?? _themeData.colorScheme.borderPressed; - Color get borderActive => _borderActive ?? _themeData.colorScheme.borderActive; - Color get borderError => _borderError ?? _themeData.colorScheme.borderError; - Color get borderWarning => _borderWarning ?? _themeData.colorScheme.borderWarning; - Color get borderSuccess => _borderSuccess ?? _themeData.colorScheme.borderSuccess; - Color get borderSelected => _borderSelected ?? _themeData.colorScheme.borderSelected; + void addComponentTheme(String component) => _update(() => _componentOverrides.putIfAbsent(component, () => {})); - // ========================================================================= - // Getters - System - // ========================================================================= - Color get systemText => _systemText ?? _themeData.colorScheme.systemText; - Color get systemScrollbar => _systemScrollbar ?? _themeData.colorScheme.systemScrollbar; + void removeComponentTheme(String component) => _update(() => _componentOverrides.remove(component)); - // ========================================================================= - // Getters - Avatar Palette - // ========================================================================= - List get avatarPalette => _avatarPalette ?? _themeData.colorScheme.avatarPalette; + void setComponentColor(String component, String property, Color color) => + _update(() => _componentOverrides.putIfAbsent(component, () => {})[property] = color); - // ========================================================================= - // Getters - Brand - // ========================================================================= - Color get brandPrimaryColor => _brandPrimaryColor ?? _themeData.colorScheme.brand.shade500; + void resetComponentColor(String component, String property) => + _update(() => _componentOverrides[component]?.remove(property)); // ========================================================================= - // Getters - Chrome + // Named getters used by buildMaterialTheme() (see AGENTS.md - "Use class + // getters directly"). Everything else is read via resolve(slot). // ========================================================================= - Color get chromePrimaryColor => _chromePrimaryColor ?? _themeData.colorScheme.chrome.shade500; + Color get accentPrimary => resolve(ThemeColorSlot.accentPrimary); + Color get accentNeutral => resolve(ThemeColorSlot.accentNeutral); + Color get accentError => resolve(ThemeColorSlot.accentError); + Color get textPrimary => resolve(ThemeColorSlot.textPrimary); + Color get textSecondary => resolve(ThemeColorSlot.textSecondary); + Color get textTertiary => resolve(ThemeColorSlot.textTertiary); + Color get textDisabled => resolve(ThemeColorSlot.textDisabled); + Color get textOnAccent => resolve(ThemeColorSlot.textOnAccent); + Color get backgroundApp => resolve(ThemeColorSlot.backgroundApp); + Color get backgroundSurface => resolve(ThemeColorSlot.backgroundSurface); + Color get backgroundSurfaceSubtle => resolve(ThemeColorSlot.backgroundSurfaceSubtle); + Color get backgroundSurfaceStrong => resolve(ThemeColorSlot.backgroundSurfaceStrong); + Color get backgroundDisabled => resolve(ThemeColorSlot.backgroundDisabled); + Color get borderDefault => resolve(ThemeColorSlot.borderDefault); + Color get borderSubtle => resolve(ThemeColorSlot.borderSubtle); + Color get systemScrollbar => resolve(ThemeColorSlot.systemScrollbar); // ========================================================================= // Setters @@ -209,74 +190,14 @@ class ThemeConfiguration extends ChangeNotifier { notifyListeners(); } - // Accent - void setAccentPrimary(Color color) => _update(() => _accentPrimary = color); - void setAccentSuccess(Color color) => _update(() => _accentSuccess = color); - void setAccentWarning(Color color) => _update(() => _accentWarning = color); - void setAccentError(Color color) => _update(() => _accentError = color); - void setAccentNeutral(Color color) => _update(() => _accentNeutral = color); - - // Text - void setTextPrimary(Color color) => _update(() => _textPrimary = color); - void setTextSecondary(Color color) => _update(() => _textSecondary = color); - void setTextTertiary(Color color) => _update(() => _textTertiary = color); - void setTextDisabled(Color color) => _update(() => _textDisabled = color); - void setTextLink(Color color) => _update(() => _textLink = color); - void setTextOnAccent(Color color) => _update(() => _textOnAccent = color); - - // Background - void setBackgroundApp(Color color) => _update(() => _backgroundApp = color); - void setBackgroundSurface(Color color) => _update(() => _backgroundSurface = color); - void setBackgroundSurfaceSubtle(Color color) => _update(() => _backgroundSurfaceSubtle = color); - void setBackgroundSurfaceStrong(Color color) => _update(() => _backgroundSurfaceStrong = color); - void setBackgroundSurfaceCard(Color color) => _update(() => _backgroundSurfaceCard = color); - void setBackgroundOnAccent(Color color) => _update(() => _backgroundOnAccent = color); - void setBackgroundHighlight(Color color) => _update(() => _backgroundHighlight = color); - void setBackgroundScrim(Color color) => _update(() => _backgroundScrim = color); - void setBackgroundOverlayLight(Color color) => _update(() => _backgroundOverlayLight = color); - void setBackgroundOverlayDark(Color color) => _update(() => _backgroundOverlayDark = color); - void setBackgroundDisabled(Color color) => _update(() => _backgroundDisabled = color); - void setBackgroundHover(Color color) => _update(() => _backgroundHover = color); - void setBackgroundPressed(Color color) => _update(() => _backgroundPressed = color); - void setBackgroundSelected(Color color) => _update(() => _backgroundSelected = color); - void setBackgroundInverse(Color color) => _update(() => _backgroundInverse = color); - void setBackgroundElevation0(Color color) => _update(() => _backgroundElevation0 = color); - void setBackgroundElevation1(Color color) => _update(() => _backgroundElevation1 = color); - void setBackgroundElevation2(Color color) => _update(() => _backgroundElevation2 = color); - void setBackgroundElevation3(Color color) => _update(() => _backgroundElevation3 = color); - - // Border Core - void setBorderDefault(Color color) => _update(() => _borderDefault = color); - void setBorderSubtle(Color color) => _update(() => _borderSubtle = color); - void setBorderStrong(Color color) => _update(() => _borderStrong = color); - void setBorderOnAccent(Color color) => _update(() => _borderOnAccent = color); - void setBorderOnSurface(Color color) => _update(() => _borderOnSurface = color); - void setBorderOpacitySubtle(Color color) => _update(() => _borderOpacitySubtle = color); - void setBorderOpacityStrong(Color color) => _update(() => _borderOpacityStrong = color); - - // Border Utility - void setBorderFocus(Color color) => _update(() => _borderFocus = color); - void setBorderDisabled(Color color) => _update(() => _borderDisabled = color); - void setBorderHover(Color color) => _update(() => _borderHover = color); - void setBorderPressed(Color color) => _update(() => _borderPressed = color); - void setBorderActive(Color color) => _update(() => _borderActive = color); - void setBorderError(Color color) => _update(() => _borderError = color); - void setBorderWarning(Color color) => _update(() => _borderWarning = color); - void setBorderSuccess(Color color) => _update(() => _borderSuccess = color); - void setBorderSelected(Color color) => _update(() => _borderSelected = color); - - // System - void setSystemText(Color color) => _update(() => _systemText = color); - void setSystemScrollbar(Color color) => _update(() => _systemScrollbar = color); - - // Avatar Palette - void setAvatarPalette(List palette) => _update(() => _avatarPalette = palette); + void setBrandPrimaryColor(Color color) => _update(() => _brandSeed = color); + void setChromePrimaryColor(Color color) => _update(() => _chromeSeed = color); - // Brand - void setBrandPrimaryColor(Color color) => _update(() => _brandPrimaryColor = color); + void resetBrand() => _update(() => _brandSeed = null); + void resetChrome() => _update(() => _chromeSeed = null); - // Chrome - void setChromePrimaryColor(Color color) => _update(() => _chromePrimaryColor = color); + void setAvatarPalette(List palette) => _update(() => _avatarPalette = palette); + void resetAvatarPalette() => _update(() => _avatarPalette = null); void updateAvatarPaletteAt(int index, StreamAvatarColorPair pair) { final current = List.from(avatarPalette); @@ -300,149 +221,6 @@ class ThemeConfiguration extends ChangeNotifier { } } - // ========================================================================= - // Is Customized - whether a color has been overridden (vs. using the - // SDK default derived by [StreamColorScheme]). - // ========================================================================= - - // Brand & Chrome - bool get brandIsCustom => _brandPrimaryColor != null; - bool get chromeIsCustom => _chromePrimaryColor != null; - - // Accent - bool get accentPrimaryIsCustom => _accentPrimary != null; - bool get accentSuccessIsCustom => _accentSuccess != null; - bool get accentWarningIsCustom => _accentWarning != null; - bool get accentErrorIsCustom => _accentError != null; - bool get accentNeutralIsCustom => _accentNeutral != null; - - // Text - bool get textPrimaryIsCustom => _textPrimary != null; - bool get textSecondaryIsCustom => _textSecondary != null; - bool get textTertiaryIsCustom => _textTertiary != null; - bool get textDisabledIsCustom => _textDisabled != null; - bool get textLinkIsCustom => _textLink != null; - bool get textOnAccentIsCustom => _textOnAccent != null; - - // Background - bool get backgroundAppIsCustom => _backgroundApp != null; - bool get backgroundSurfaceIsCustom => _backgroundSurface != null; - bool get backgroundSurfaceSubtleIsCustom => _backgroundSurfaceSubtle != null; - bool get backgroundSurfaceStrongIsCustom => _backgroundSurfaceStrong != null; - bool get backgroundSurfaceCardIsCustom => _backgroundSurfaceCard != null; - bool get backgroundOnAccentIsCustom => _backgroundOnAccent != null; - bool get backgroundHighlightIsCustom => _backgroundHighlight != null; - bool get backgroundScrimIsCustom => _backgroundScrim != null; - bool get backgroundOverlayLightIsCustom => _backgroundOverlayLight != null; - bool get backgroundOverlayDarkIsCustom => _backgroundOverlayDark != null; - bool get backgroundDisabledIsCustom => _backgroundDisabled != null; - bool get backgroundHoverIsCustom => _backgroundHover != null; - bool get backgroundPressedIsCustom => _backgroundPressed != null; - bool get backgroundSelectedIsCustom => _backgroundSelected != null; - bool get backgroundInverseIsCustom => _backgroundInverse != null; - bool get backgroundElevation0IsCustom => _backgroundElevation0 != null; - bool get backgroundElevation1IsCustom => _backgroundElevation1 != null; - bool get backgroundElevation2IsCustom => _backgroundElevation2 != null; - bool get backgroundElevation3IsCustom => _backgroundElevation3 != null; - - // Border Core - bool get borderDefaultIsCustom => _borderDefault != null; - bool get borderSubtleIsCustom => _borderSubtle != null; - bool get borderStrongIsCustom => _borderStrong != null; - bool get borderOnAccentIsCustom => _borderOnAccent != null; - bool get borderOnSurfaceIsCustom => _borderOnSurface != null; - bool get borderOpacitySubtleIsCustom => _borderOpacitySubtle != null; - bool get borderOpacityStrongIsCustom => _borderOpacityStrong != null; - - // Border Utility - bool get borderFocusIsCustom => _borderFocus != null; - bool get borderDisabledIsCustom => _borderDisabled != null; - bool get borderHoverIsCustom => _borderHover != null; - bool get borderPressedIsCustom => _borderPressed != null; - bool get borderActiveIsCustom => _borderActive != null; - bool get borderErrorIsCustom => _borderError != null; - bool get borderWarningIsCustom => _borderWarning != null; - bool get borderSuccessIsCustom => _borderSuccess != null; - bool get borderSelectedIsCustom => _borderSelected != null; - - // System - bool get systemTextIsCustom => _systemText != null; - bool get systemScrollbarIsCustom => _systemScrollbar != null; - - // Avatar Palette - bool get avatarPaletteIsCustom => _avatarPalette != null; - - // ========================================================================= - // Per-field Reset - reverts a single color back to the SDK default. - // ========================================================================= - - // Brand & Chrome - void resetBrand() => _update(() => _brandPrimaryColor = null); - void resetChrome() => _update(() => _chromePrimaryColor = null); - - // Accent - void resetAccentPrimary() => _update(() => _accentPrimary = null); - void resetAccentSuccess() => _update(() => _accentSuccess = null); - void resetAccentWarning() => _update(() => _accentWarning = null); - void resetAccentError() => _update(() => _accentError = null); - void resetAccentNeutral() => _update(() => _accentNeutral = null); - - // Text - void resetTextPrimary() => _update(() => _textPrimary = null); - void resetTextSecondary() => _update(() => _textSecondary = null); - void resetTextTertiary() => _update(() => _textTertiary = null); - void resetTextDisabled() => _update(() => _textDisabled = null); - void resetTextLink() => _update(() => _textLink = null); - void resetTextOnAccent() => _update(() => _textOnAccent = null); - - // Background - void resetBackgroundApp() => _update(() => _backgroundApp = null); - void resetBackgroundSurface() => _update(() => _backgroundSurface = null); - void resetBackgroundSurfaceSubtle() => _update(() => _backgroundSurfaceSubtle = null); - void resetBackgroundSurfaceStrong() => _update(() => _backgroundSurfaceStrong = null); - void resetBackgroundSurfaceCard() => _update(() => _backgroundSurfaceCard = null); - void resetBackgroundOnAccent() => _update(() => _backgroundOnAccent = null); - void resetBackgroundHighlight() => _update(() => _backgroundHighlight = null); - void resetBackgroundScrim() => _update(() => _backgroundScrim = null); - void resetBackgroundOverlayLight() => _update(() => _backgroundOverlayLight = null); - void resetBackgroundOverlayDark() => _update(() => _backgroundOverlayDark = null); - void resetBackgroundDisabled() => _update(() => _backgroundDisabled = null); - void resetBackgroundHover() => _update(() => _backgroundHover = null); - void resetBackgroundPressed() => _update(() => _backgroundPressed = null); - void resetBackgroundSelected() => _update(() => _backgroundSelected = null); - void resetBackgroundInverse() => _update(() => _backgroundInverse = null); - void resetBackgroundElevation0() => _update(() => _backgroundElevation0 = null); - void resetBackgroundElevation1() => _update(() => _backgroundElevation1 = null); - void resetBackgroundElevation2() => _update(() => _backgroundElevation2 = null); - void resetBackgroundElevation3() => _update(() => _backgroundElevation3 = null); - - // Border Core - void resetBorderDefault() => _update(() => _borderDefault = null); - void resetBorderSubtle() => _update(() => _borderSubtle = null); - void resetBorderStrong() => _update(() => _borderStrong = null); - void resetBorderOnAccent() => _update(() => _borderOnAccent = null); - void resetBorderOnSurface() => _update(() => _borderOnSurface = null); - void resetBorderOpacitySubtle() => _update(() => _borderOpacitySubtle = null); - void resetBorderOpacityStrong() => _update(() => _borderOpacityStrong = null); - - // Border Utility - void resetBorderFocus() => _update(() => _borderFocus = null); - void resetBorderDisabled() => _update(() => _borderDisabled = null); - void resetBorderHover() => _update(() => _borderHover = null); - void resetBorderPressed() => _update(() => _borderPressed = null); - void resetBorderActive() => _update(() => _borderActive = null); - void resetBorderError() => _update(() => _borderError = null); - void resetBorderWarning() => _update(() => _borderWarning = null); - void resetBorderSuccess() => _update(() => _borderSuccess = null); - void resetBorderSelected() => _update(() => _borderSelected = null); - - // System - void resetSystemText() => _update(() => _systemText = null); - void resetSystemScrollbar() => _update(() => _systemScrollbar = null); - - // Avatar Palette - void resetAvatarPalette() => _update(() => _avatarPalette = null); - void _update(VoidCallback setter) { setter(); _rebuildTheme(); @@ -450,86 +228,42 @@ class ThemeConfiguration extends ChangeNotifier { } void resetToDefaults() { - // Brand - _brandPrimaryColor = null; - // Chrome - _chromePrimaryColor = null; - - // Accent - _accentPrimary = null; - _accentSuccess = null; - _accentWarning = null; - _accentError = null; - _accentNeutral = null; - // Text - _textPrimary = null; - _textSecondary = null; - _textTertiary = null; - _textDisabled = null; - _textLink = null; - _textOnAccent = null; - // Background - _backgroundApp = null; - _backgroundSurface = null; - _backgroundSurfaceSubtle = null; - _backgroundSurfaceStrong = null; - _backgroundSurfaceCard = null; - _backgroundOnAccent = null; - _backgroundHighlight = null; - _backgroundScrim = null; - _backgroundOverlayLight = null; - _backgroundOverlayDark = null; - _backgroundDisabled = null; - _backgroundHover = null; - _backgroundPressed = null; - _backgroundSelected = null; - _backgroundInverse = null; - _backgroundElevation0 = null; - _backgroundElevation1 = null; - _backgroundElevation2 = null; - _backgroundElevation3 = null; - // Border Core - _borderDefault = null; - _borderSubtle = null; - _borderStrong = null; - _borderOnAccent = null; - _borderOnSurface = null; - _borderOpacitySubtle = null; - _borderOpacityStrong = null; - // Border Utility - _borderFocus = null; - _borderDisabled = null; - _borderHover = null; - _borderPressed = null; - _borderActive = null; - _borderError = null; - _borderWarning = null; - _borderSuccess = null; - _borderSelected = null; - // System - _systemText = null; - _systemScrollbar = null; - // Avatar + _overrides.clear(); + _brandSeed = null; + _chromeSeed = null; _avatarPalette = null; + _componentOverrides.clear(); _rebuildTheme(); notifyListeners(); } + /// Builds [name]'s component theme-data object from its current overrides, + /// or `null` if it hasn't been added or has no colors set yet (in which + /// case [StreamTheme] falls back to that component's own SDK default). + Object? _buildComponentTheme(String name) { + final values = _componentOverrides[name]; + if (values == null || values.isEmpty) return null; + final descriptor = componentThemeDescriptorOrNull(name); + assert(descriptor != null, 'No ComponentThemeDescriptor named "$name"'); + if (descriptor == null) return null; + return descriptor.build(values); + } + void _rebuildTheme() { // Brand swatch, if the brand ("primary") color is customized. - final effectiveBrand = _brandPrimaryColor != null - ? StreamColorSwatch.fromColor(_brandPrimaryColor!, brightness: _brightness) + final effectiveBrand = _brandSeed != null + ? StreamColorSwatch.fromColor(_brandSeed!, brightness: _brightness) : null; // Chrome swatch. Mirrors StreamColorScheme.fromSeed: an explicit chrome color wins; // otherwise, when a brand color is set but chrome isn't, derive chrome from brand at // neutral chroma so chrome-dependent colors still pick up the brand's hue. - final effectiveChrome = _chromePrimaryColor != null - ? StreamColorSwatch.fromColor(_chromePrimaryColor!, brightness: _brightness) - : _brandPrimaryColor != null + final effectiveChrome = _chromeSeed != null + ? StreamColorSwatch.fromColor(_chromeSeed!, brightness: _brightness) + : _brandSeed != null ? StreamColorSwatch.fromColor( - _brandPrimaryColor!, + _brandSeed!, brightness: _brightness, chroma: StreamColorScheme.neutralChroma, ) @@ -544,59 +278,63 @@ class ThemeConfiguration extends ChangeNotifier { // Chrome chrome: effectiveChrome, // Accent - accentPrimary: _accentPrimary, - accentSuccess: _accentSuccess, - accentWarning: _accentWarning, - accentError: _accentError, - accentNeutral: _accentNeutral, + accentPrimary: _overrides[ThemeColorSlot.accentPrimary], + accentSuccess: _overrides[ThemeColorSlot.accentSuccess], + accentWarning: _overrides[ThemeColorSlot.accentWarning], + accentError: _overrides[ThemeColorSlot.accentError], + accentNeutral: _overrides[ThemeColorSlot.accentNeutral], // Text - textPrimary: _textPrimary, - textSecondary: _textSecondary, - textTertiary: _textTertiary, - textDisabled: _textDisabled, - textLink: _textLink, - textOnAccent: _textOnAccent, + textPrimary: _overrides[ThemeColorSlot.textPrimary], + textSecondary: _overrides[ThemeColorSlot.textSecondary], + textTertiary: _overrides[ThemeColorSlot.textTertiary], + textDisabled: _overrides[ThemeColorSlot.textDisabled], + textLink: _overrides[ThemeColorSlot.textLink], + textOnAccent: _overrides[ThemeColorSlot.textOnAccent], + textOnInverse: _overrides[ThemeColorSlot.textOnInverse], // Background - backgroundApp: _backgroundApp, - backgroundSurface: _backgroundSurface, - backgroundSurfaceSubtle: _backgroundSurfaceSubtle, - backgroundSurfaceStrong: _backgroundSurfaceStrong, - backgroundSurfaceCard: _backgroundSurfaceCard, - backgroundOnAccent: _backgroundOnAccent, - backgroundHighlight: _backgroundHighlight, - backgroundScrim: _backgroundScrim, - backgroundOverlayLight: _backgroundOverlayLight, - backgroundOverlayDark: _backgroundOverlayDark, - backgroundDisabled: _backgroundDisabled, - backgroundHover: _backgroundHover, - backgroundPressed: _backgroundPressed, - backgroundSelected: _backgroundSelected, - backgroundInverse: _backgroundInverse, - backgroundElevation0: _backgroundElevation0, - backgroundElevation1: _backgroundElevation1, - backgroundElevation2: _backgroundElevation2, - backgroundElevation3: _backgroundElevation3, - // Border Core - borderDefault: _borderDefault, - borderSubtle: _borderSubtle, - borderStrong: _borderStrong, - borderOnAccent: _borderOnAccent, - borderOnSurface: _borderOnSurface, - borderOpacitySubtle: _borderOpacitySubtle, - borderOpacityStrong: _borderOpacityStrong, - // Border Utility - borderFocus: _borderFocus, - borderDisabled: _borderDisabled, - borderHover: _borderHover, - borderPressed: _borderPressed, - borderActive: _borderActive, - borderError: _borderError, - borderWarning: _borderWarning, - borderSuccess: _borderSuccess, - borderSelected: _borderSelected, + backgroundApp: _overrides[ThemeColorSlot.backgroundApp], + backgroundSurface: _overrides[ThemeColorSlot.backgroundSurface], + backgroundSurfaceSubtle: _overrides[ThemeColorSlot.backgroundSurfaceSubtle], + backgroundSurfaceStrong: _overrides[ThemeColorSlot.backgroundSurfaceStrong], + backgroundSurfaceCard: _overrides[ThemeColorSlot.backgroundSurfaceCard], + backgroundOnAccent: _overrides[ThemeColorSlot.backgroundOnAccent], + backgroundHighlight: _overrides[ThemeColorSlot.backgroundHighlight], + backgroundScrim: _overrides[ThemeColorSlot.backgroundScrim], + backgroundOverlayLight: _overrides[ThemeColorSlot.backgroundOverlayLight], + backgroundOverlayDark: _overrides[ThemeColorSlot.backgroundOverlayDark], + backgroundDisabled: _overrides[ThemeColorSlot.backgroundDisabled], + backgroundInverse: _overrides[ThemeColorSlot.backgroundInverse], + backgroundElevation0: _overrides[ThemeColorSlot.backgroundElevation0], + backgroundElevation1: _overrides[ThemeColorSlot.backgroundElevation1], + backgroundElevation2: _overrides[ThemeColorSlot.backgroundElevation2], + backgroundElevation3: _overrides[ThemeColorSlot.backgroundElevation3], + // Border - Core + borderDefault: _overrides[ThemeColorSlot.borderDefault], + borderSubtle: _overrides[ThemeColorSlot.borderSubtle], + borderStrong: _overrides[ThemeColorSlot.borderStrong], + borderOnAccent: _overrides[ThemeColorSlot.borderOnAccent], + borderOnInverse: _overrides[ThemeColorSlot.borderOnInverse], + borderOnSurface: _overrides[ThemeColorSlot.borderOnSurface], + borderOpacitySubtle: _overrides[ThemeColorSlot.borderOpacitySubtle], + borderOpacityStrong: _overrides[ThemeColorSlot.borderOpacityStrong], + // Border - Utility + borderFocus: _overrides[ThemeColorSlot.borderFocus], + borderDisabled: _overrides[ThemeColorSlot.borderDisabled], + borderDisabledOnSurface: _overrides[ThemeColorSlot.borderDisabledOnSurface], + borderHover: _overrides[ThemeColorSlot.borderHover], + borderPressed: _overrides[ThemeColorSlot.borderPressed], + borderActive: _overrides[ThemeColorSlot.borderActive], + borderError: _overrides[ThemeColorSlot.borderError], + borderWarning: _overrides[ThemeColorSlot.borderWarning], + borderSuccess: _overrides[ThemeColorSlot.borderSuccess], + borderSelected: _overrides[ThemeColorSlot.borderSelected], + // State + backgroundHover: _overrides[ThemeColorSlot.backgroundHover], + backgroundPressed: _overrides[ThemeColorSlot.backgroundPressed], + backgroundSelected: _overrides[ThemeColorSlot.backgroundSelected], // System - systemText: _systemText, - systemScrollbar: _systemScrollbar, + systemText: _overrides[ThemeColorSlot.systemText], + systemScrollbar: _overrides[ThemeColorSlot.systemScrollbar], // Avatar avatarPalette: _avatarPalette, ); @@ -604,6 +342,10 @@ class ThemeConfiguration extends ChangeNotifier { _themeData = StreamTheme( brightness: _brightness, colorScheme: colorScheme, + avatarTheme: _buildComponentTheme('Avatar') as StreamAvatarThemeData?, + badgeCountTheme: _buildComponentTheme('Badge Count') as StreamBadgeCountThemeData?, + badgeNotificationTheme: _buildComponentTheme('Badge Notification') as StreamBadgeNotificationThemeData?, + onlineIndicatorTheme: _buildComponentTheme('Online Indicator') as StreamOnlineIndicatorThemeData?, ); } diff --git a/apps/design_system_gallery/lib/config/theme_export_configuration.dart b/apps/design_system_gallery/lib/config/theme_export_configuration.dart new file mode 100644 index 00000000..6f973f5f --- /dev/null +++ b/apps/design_system_gallery/lib/config/theme_export_configuration.dart @@ -0,0 +1,196 @@ +import 'package:flutter/material.dart'; + +import '../core/theme_code_generator.dart'; +import 'theme_color_slot.dart'; +import 'theme_configuration.dart'; + +/// Drives the export page: two independent [ThemeConfiguration]s (one per +/// brightness) seeded from the theme studio's current, brightness-agnostic +/// state, plus which [ThemeColorSlot]/[ThemeSeedSlot]s/component colors are +/// currently "linked" (edited together) vs. unlinked (edited independently). +/// brand, chrome and accent* colors default to linked; everything else +/// defaults to unlinked (see the comment on `_unlinkedSlots` below). +/// +/// Never writes back to the studio [ThemeConfiguration] it was seeded from — +/// export is one-way. +class ThemeExportConfiguration extends ChangeNotifier { + ThemeExportConfiguration(ThemeConfiguration studio) + : light = ThemeConfiguration.seededFrom(studio, brightness: Brightness.light), + dark = ThemeConfiguration.seededFrom(studio, brightness: Brightness.dark) { + light.addListener(_handleChildChanged); + dark.addListener(_handleChildChanged); + } + + final ThemeConfiguration light; + final ThemeConfiguration dark; + + ThemeConfiguration _side(Brightness brightness) => brightness == Brightness.light ? light : dark; + + // brand, chrome and accent* colors are usually shared between light and + // dark themes, so they start linked. Nearly everything else (text*, + // background*, border*, state, system*) is typically *inverted* between + // brightnesses - e.g. textPrimary is dark-on-light in light mode and + // light-on-dark in dark mode - so linking those by default would mean the + // very first edit overwrites one side with a value that's wrong for it. + // They start unlinked instead. + final Set _unlinkedSlots = { + for (final slot in ThemeColorSlot.values) + if (!slot.parameterName.startsWith('accent')) slot, + }; + final Set _unlinkedSeeds = {}; + + bool isSlotLinked(ThemeColorSlot slot) => !_unlinkedSlots.contains(slot); + bool isSeedLinked(ThemeSeedSlot seed) => !_unlinkedSeeds.contains(seed); + + // Toggling link state doesn't force light/dark back in sync - it only + // changes where the *next* edit goes. Auto-syncing on re-link would + // silently discard whichever side's value came from being unlinked. + void toggleSlotLinked(ThemeColorSlot slot) { + if (!_unlinkedSlots.remove(slot)) _unlinkedSlots.add(slot); + notifyListeners(); + } + + void toggleSeedLinked(ThemeSeedSlot seed) { + if (!_unlinkedSeeds.remove(seed)) _unlinkedSeeds.add(seed); + notifyListeners(); + } + + /// Sets [slot] to [color], starting from an edit made on [from]'s column. + /// When linked, both sides get [color]; when unlinked, only [from] does. + void setColor(ThemeColorSlot slot, Color color, {required Brightness from}) { + if (isSlotLinked(slot)) { + light.setOverride(slot, color); + dark.setOverride(slot, color); + } else { + _side(from).setOverride(slot, color); + } + } + + void resetColor(ThemeColorSlot slot, {required Brightness from}) { + if (isSlotLinked(slot)) { + light.resetOverride(slot); + dark.resetOverride(slot); + } else { + _side(from).resetOverride(slot); + } + } + + void setSeed(ThemeSeedSlot seed, Color color, {required Brightness from}) { + void apply(ThemeConfiguration config) => + seed == ThemeSeedSlot.brand ? config.setBrandPrimaryColor(color) : config.setChromePrimaryColor(color); + + if (isSeedLinked(seed)) { + apply(light); + apply(dark); + } else { + apply(_side(from)); + } + } + + void resetSeed(ThemeSeedSlot seed, {required Brightness from}) { + void apply(ThemeConfiguration config) => seed == ThemeSeedSlot.brand ? config.resetBrand() : config.resetChrome(); + + if (isSeedLinked(seed)) { + apply(light); + apply(dark); + } else { + apply(_side(from)); + } + } + + // Component theme colors, keyed by "component::property". Add/remove of a + // whole component always applies to both sides together (there's no + // per-brightness set of active components). Individual property colors + // start unlinked, same reasoning as everything but brand/chrome/accent* + // above - a badge or online-indicator background is just as likely to + // need different light/dark values as a background/border color is. + final Set _linkedComponentColors = {}; + + String _componentKey(String component, String property) => '$component::$property'; + + /// The component themes currently active (added via [addComponentTheme]), + /// present even before any of their colors are customized. Both sides + /// always agree since add/remove is applied to both together. + Set get activeComponentThemes => light.activeComponentThemes; + + void addComponentTheme(String component) { + light.addComponentTheme(component); + dark.addComponentTheme(component); + } + + void removeComponentTheme(String component) { + light.removeComponentTheme(component); + dark.removeComponentTheme(component); + _linkedComponentColors.removeWhere((key) => key.startsWith('$component::')); + } + + bool isComponentColorLinked(String component, String property) => + _linkedComponentColors.contains(_componentKey(component, property)); + + void toggleComponentColorLinked(String component, String property) { + final key = _componentKey(component, property); + if (!_linkedComponentColors.remove(key)) _linkedComponentColors.add(key); + notifyListeners(); + } + + void setComponentColor(String component, String property, Color color, {required Brightness from}) { + if (isComponentColorLinked(component, property)) { + light.setComponentColor(component, property, color); + dark.setComponentColor(component, property, color); + } else { + _side(from).setComponentColor(component, property, color); + } + } + + void resetComponentColor(String component, String property, {required Brightness from}) { + if (isComponentColorLinked(component, property)) { + light.resetComponentColor(component, property); + dark.resetComponentColor(component, property); + } else { + _side(from).resetComponentColor(component, property); + } + } + + // ThemeData is expensive to compare (it and StreamTheme's ~45 component + // sub-themes are compared field-by-field by InheritedTheme), so it's built + // once per change and handed out by reference rather than rebuilt on every + // row's Theme(...) wrapper. + ThemeData? _lightMaterialTheme; + ThemeData? _darkMaterialTheme; + + ThemeData get lightMaterialTheme => _lightMaterialTheme ??= light.buildMaterialTheme(); + ThemeData get darkMaterialTheme => _darkMaterialTheme ??= dark.buildMaterialTheme(); + + void _handleChildChanged() { + _lightMaterialTheme = null; + _darkMaterialTheme = null; + notifyListeners(); + } + + /// Generates the copy-pasteable Dart snippet for the current state. + String generateCode() => generateThemeCode( + light: ThemeExportSide( + overrides: light.overrides, + brandSeed: light.brandIsCustom ? light.brandPrimaryColor : null, + chromeSeed: light.chromeIsCustom ? light.chromePrimaryColor : null, + avatarPalette: light.avatarPaletteIsCustom ? light.avatarPalette : null, + componentOverrides: light.componentOverrides, + ), + dark: ThemeExportSide( + overrides: dark.overrides, + brandSeed: dark.brandIsCustom ? dark.brandPrimaryColor : null, + chromeSeed: dark.chromeIsCustom ? dark.chromePrimaryColor : null, + avatarPalette: dark.avatarPaletteIsCustom ? dark.avatarPalette : null, + componentOverrides: dark.componentOverrides, + ), + ); + + @override + void dispose() { + light.removeListener(_handleChildChanged); + dark.removeListener(_handleChildChanged); + light.dispose(); + dark.dispose(); + super.dispose(); + } +} diff --git a/apps/design_system_gallery/lib/config/theme_studio_sections.dart b/apps/design_system_gallery/lib/config/theme_studio_sections.dart new file mode 100644 index 00000000..8b5df382 --- /dev/null +++ b/apps/design_system_gallery/lib/config/theme_studio_sections.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart'; + +import 'theme_color_slot.dart'; + +/// A named run of [ThemeColorSlot]s within a [ThemeStudioSection]. +/// +/// Most sections have a single, unnamed group. `Background Colors` splits +/// into three ("main", "Surface", "Elevation") to match the sub-headings the +/// theme studio panel has always shown. +class ThemeStudioSlotGroup { + const ThemeStudioSlotGroup({this.heading, required this.slots}); + + /// Optional sub-heading rendered above [slots] (e.g. `'Surface'`). + final String? heading; + + final List slots; +} + +/// A group of [ThemeColorSlot]s shown together, mirroring one +/// [StreamColorScheme] concern (accent, text, background, ...). +/// +/// This is the single source of truth consumed by the theme studio panel, +/// the export page, and the code generator's ordering — add a new color here +/// once and all three pick it up. +class ThemeStudioSection { + const ThemeStudioSection({ + required this.title, + required this.subtitle, + required this.icon, + required this.groups, + }); + + final String title; + final String subtitle; + final IconData icon; + final List groups; + + /// All slots in this section, flattened across [groups]. + Iterable get slots => groups.expand((g) => g.slots); +} + +/// The ordered list of color sections, matching [StreamColorScheme.light]'s +/// parameter order. +/// +/// `Appearance`, `Brand Color`, `Chrome Color`, and `Avatar Palette` are not +/// represented here: brightness is a single value (not a slot), brand/chrome +/// are [ThemeSeedSlot]s with a different write path, and the avatar palette +/// is a `List`, not a color. +const themeStudioSections = [ + ThemeStudioSection( + title: 'Accent Colors', + subtitle: 'accent*', + icon: Icons.color_lens, + groups: [ + ThemeStudioSlotGroup( + slots: [ + ThemeColorSlot.accentPrimary, + ThemeColorSlot.accentSuccess, + ThemeColorSlot.accentWarning, + ThemeColorSlot.accentError, + ThemeColorSlot.accentNeutral, + ], + ), + ], + ), + ThemeStudioSection( + title: 'Text Colors', + subtitle: 'text*', + icon: Icons.format_color_text, + groups: [ + ThemeStudioSlotGroup( + slots: [ + ThemeColorSlot.textPrimary, + ThemeColorSlot.textSecondary, + ThemeColorSlot.textTertiary, + ThemeColorSlot.textDisabled, + ThemeColorSlot.textLink, + ThemeColorSlot.textOnAccent, + ThemeColorSlot.textOnInverse, + ], + ), + ], + ), + ThemeStudioSection( + title: 'Background Colors', + subtitle: 'background*', + icon: Icons.format_paint, + groups: [ + ThemeStudioSlotGroup( + slots: [ + ThemeColorSlot.backgroundApp, + ThemeColorSlot.backgroundInverse, + ThemeColorSlot.backgroundOnAccent, + ThemeColorSlot.backgroundHighlight, + ThemeColorSlot.backgroundScrim, + ThemeColorSlot.backgroundOverlayLight, + ThemeColorSlot.backgroundOverlayDark, + ThemeColorSlot.backgroundDisabled, + ThemeColorSlot.backgroundHover, + ThemeColorSlot.backgroundPressed, + ThemeColorSlot.backgroundSelected, + ], + ), + ThemeStudioSlotGroup( + heading: 'Surface', + slots: [ + ThemeColorSlot.backgroundSurface, + ThemeColorSlot.backgroundSurfaceSubtle, + ThemeColorSlot.backgroundSurfaceStrong, + ThemeColorSlot.backgroundSurfaceCard, + ], + ), + ThemeStudioSlotGroup( + heading: 'Elevation', + slots: [ + ThemeColorSlot.backgroundElevation0, + ThemeColorSlot.backgroundElevation1, + ThemeColorSlot.backgroundElevation2, + ThemeColorSlot.backgroundElevation3, + ], + ), + ], + ), + ThemeStudioSection( + title: 'Border Colors - Core', + subtitle: 'border*', + icon: Icons.border_all, + groups: [ + ThemeStudioSlotGroup( + slots: [ + ThemeColorSlot.borderDefault, + ThemeColorSlot.borderSubtle, + ThemeColorSlot.borderStrong, + ThemeColorSlot.borderOnAccent, + ThemeColorSlot.borderOnInverse, + ThemeColorSlot.borderOnSurface, + ThemeColorSlot.borderOpacitySubtle, + ThemeColorSlot.borderOpacityStrong, + ], + ), + ], + ), + ThemeStudioSection( + title: 'Border Colors - Utility', + subtitle: 'border*', + icon: Icons.border_style, + groups: [ + ThemeStudioSlotGroup( + slots: [ + ThemeColorSlot.borderFocus, + ThemeColorSlot.borderActive, + ThemeColorSlot.borderHover, + ThemeColorSlot.borderPressed, + ThemeColorSlot.borderDisabled, + ThemeColorSlot.borderDisabledOnSurface, + ThemeColorSlot.borderError, + ThemeColorSlot.borderWarning, + ThemeColorSlot.borderSuccess, + ThemeColorSlot.borderSelected, + ], + ), + ], + ), + ThemeStudioSection( + title: 'System Colors', + subtitle: 'system*', + icon: Icons.settings_system_daydream, + groups: [ + ThemeStudioSlotGroup( + slots: [ + ThemeColorSlot.systemText, + ThemeColorSlot.systemScrollbar, + ], + ), + ], + ), +]; diff --git a/apps/design_system_gallery/lib/core/theme_code_generator.dart b/apps/design_system_gallery/lib/core/theme_code_generator.dart new file mode 100644 index 00000000..61539b01 --- /dev/null +++ b/apps/design_system_gallery/lib/core/theme_code_generator.dart @@ -0,0 +1,360 @@ +import 'package:dart_style/dart_style.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/core.dart'; + +import '../config/component_theme_descriptors.dart'; +import '../config/theme_color_slot.dart'; + +/// One brightness's worth of input to [generateThemeCode]: the slots that +/// have been overridden, the raw brand/chrome seeds (if customized), and an +/// avatar palette override (if customized). +/// +/// Mirrors a single [ThemeConfiguration]'s [ThemeConfiguration.overrides], +/// brand/chrome seed, and avatar palette override. +class ThemeExportSide { + const ThemeExportSide({ + this.overrides = const {}, + this.brandSeed, + this.chromeSeed, + this.avatarPalette, + this.componentOverrides = const {}, + }); + + final Map overrides; + final Color? brandSeed; + final Color? chromeSeed; + final List? avatarPalette; + + /// Component theme overrides, keyed by [ComponentThemeDescriptor.name], + /// then by property name. Mirrors [ThemeConfiguration.componentOverrides]. + final Map> componentOverrides; +} + +/// Generates a copy-pasteable Dart snippet reproducing [light] and [dark] as +/// a [StreamTheme]-based `MaterialApp` `theme`/`darkTheme` pair. +/// +/// Only customized values are emitted — [StreamColorScheme]'s own defaults +/// fill in everything else. This deliberately does **not** value-diff +/// against `StreamColorScheme.light()`/`.dark()`: several colors (e.g. +/// `textLink`, `borderActive`) are themselves *derived* from `accentPrimary`/ +/// `brand` inside those factories, and asymmetrically so between light and +/// dark. Diffing values would emit those derived colors as frozen overrides, +/// breaking the very derivation this snippet is meant to preserve for its +/// consumer. Only the [ThemeColorSlot]s actually present in +/// [ThemeExportSide.overrides] are emitted. +/// +/// A slot set to the same color on both sides becomes a single `const`. Set +/// to different colors (or set on only one side) it becomes two consts, +/// suffixed `Light`/`Dark`. The same rule applies to the `brand`/`chrome` +/// seeds and to the avatar palette. When `brand` is customized but `chrome` +/// is not (on a given side), `chrome` is emitted as derived from `brand` at +/// [StreamColorScheme.neutralChroma] — mirroring +/// `ThemeConfiguration._rebuildTheme()` and `StreamColorScheme.fromSeed`. +String generateThemeCode({required ThemeExportSide light, required ThemeExportSide dark}) { + final constLines = []; + final usedNames = {}; + + final brandPlan = _planValue('brand', light.brandSeed, dark.brandSeed, _colorLiteral, constLines, usedNames); + final chromePlan = _planValue('chrome', light.chromeSeed, dark.chromeSeed, _colorLiteral, constLines, usedNames); + + final slotPlans = { + for (final slot in ThemeColorSlot.values) + if (light.overrides.containsKey(slot) || dark.overrides.containsKey(slot)) + slot: _planValue( + slot.parameterName, + light.overrides[slot], + dark.overrides[slot], + _colorLiteral, + constLines, + usedNames, + ), + }; + + final avatarPalettePlan = _planValue( + 'avatarPalette', + light.avatarPalette, + dark.avatarPalette, + _avatarPaletteLiteral, + constLines, + usedNames, + areEqual: _avatarPaletteEquals, + ); + + // Component property colors are edited per brightness on the export page + // and start out unlinked, so light and dark routinely diverge here - they + // go through the same shared/split const planning as every other value. + final componentPlans = >{ + for (final descriptor in componentThemeDescriptors) + if (light.componentOverrides.containsKey(descriptor.name) || dark.componentOverrides.containsKey(descriptor.name)) + descriptor.name: { + for (final property in descriptor.properties) + property: _planValue( + _componentConstBaseName(descriptor.name, property), + light.componentOverrides[descriptor.name]?[property], + dark.componentOverrides[descriptor.name]?[property], + _colorLiteral, + constLines, + usedNames, + ), + }, + }; + + final lightScheme = _colorSchemeCall( + brightness: Brightness.light, + brandRef: brandPlan.refFor(Brightness.light), + chromeRef: chromePlan.refFor(Brightness.light), + slotPlans: slotPlans, + avatarPaletteRef: avatarPalettePlan.refFor(Brightness.light), + ); + final darkScheme = _colorSchemeCall( + brightness: Brightness.dark, + brandRef: brandPlan.refFor(Brightness.dark), + chromeRef: chromePlan.refFor(Brightness.dark), + slotPlans: slotPlans, + avatarPaletteRef: avatarPalettePlan.refFor(Brightness.dark), + ); + final lightComponentArgs = _componentThemeArgs(componentPlans, Brightness.light); + final darkComponentArgs = _componentThemeArgs(componentPlans, Brightness.dark); + + final source = + ''' +void _f() { +${constLines.join('\n')} +${constLines.isNotEmpty ? '\n' : ''}final lightStreamTheme = StreamTheme( + colorScheme: $lightScheme, + $lightComponentArgs +); +final darkStreamTheme = StreamTheme( + colorScheme: $darkScheme, + $darkComponentArgs +); + +MaterialApp( + theme: ThemeData( + brightness: Brightness.light, + extensions: [lightStreamTheme], + ), + darkTheme: ThemeData( + brightness: Brightness.dark, + extensions: [darkStreamTheme], + ), +); +} +'''; + + final formatted = _dedent(DartFormatter(languageVersion: DartFormatter.latestLanguageVersion).format(source)); + return _forceMultilineStreamThemeAssignments(formatted); +} + +/// Builds `StreamColorScheme.light(...)`/`.dark(...)` for one brightness: +/// `brand`/`chrome` (with chrome derived from brand when only brand was +/// customized), then every overridden [ThemeColorSlot] in declaration order. +String _colorSchemeCall({ + required Brightness brightness, + required String? brandRef, + required String? chromeRef, + required Map slotPlans, + required String? avatarPaletteRef, +}) { + final brightnessExpr = brightness == Brightness.light ? 'Brightness.light' : 'Brightness.dark'; + final factoryName = brightness == Brightness.light ? 'StreamColorScheme.light' : 'StreamColorScheme.dark'; + + final args = []; + if (brandRef != null) { + args.add('brand: StreamColorSwatch.fromColor($brandRef, brightness: $brightnessExpr)'); + } + if (chromeRef != null) { + args.add('chrome: StreamColorSwatch.fromColor($chromeRef, brightness: $brightnessExpr)'); + } else if (brandRef != null) { + // Chrome wasn't customized on this side, but brand was: derive it from + // brand at neutral chroma, matching ThemeConfiguration._rebuildTheme() + // and StreamColorScheme.fromSeed. + args.add( + 'chrome: StreamColorSwatch.fromColor($brandRef, brightness: $brightnessExpr, ' + 'chroma: StreamColorScheme.neutralChroma)', + ); + } + for (final entry in slotPlans.entries) { + final ref = entry.value.refFor(brightness); + if (ref != null) args.add('${entry.key.parameterName}: $ref'); + } + if (avatarPaletteRef != null) { + args.add('avatarPalette: $avatarPaletteRef'); + } + + return '$factoryName(${args.join(', ')})'; +} + +/// A plan for emitting one or two `const` declarations for a value that may +/// differ between light and dark, plus how to reference it on each side. +class _ConstPlan { + const _ConstPlan({this.sharedName, this.lightName, this.darkName}); + + final String? sharedName; + final String? lightName; + final String? darkName; + + String? refFor(Brightness brightness) => sharedName ?? (brightness == Brightness.light ? lightName : darkName); +} + +/// Plans const emission for a single named value across [light]/[dark]: +/// shared when both are set and equal, otherwise `Light`/`Dark`-suffixed and +/// emitted only for the sides that actually have a value. Appends the +/// resulting `const` declaration(s) to [constLines], guarding name +/// collisions via [usedNames]. +_ConstPlan _planValue( + String baseName, + T? light, + T? dark, + String Function(T value) literal, + List constLines, + Set usedNames, { + bool Function(T a, T b)? areEqual, +}) { + if (light == null && dark == null) return const _ConstPlan(); + + final equal = light != null && dark != null && (areEqual?.call(light, dark) ?? light == dark); + if (equal) { + final name = _uniqueName(baseName, usedNames); + constLines.add('const $name = ${literal(light)};'); + return _ConstPlan(sharedName: name); + } + + String? lightName; + String? darkName; + if (light != null) { + lightName = _uniqueName('${baseName}Light', usedNames); + constLines.add('const $lightName = ${literal(light)};'); + } + if (dark != null) { + darkName = _uniqueName('${baseName}Dark', usedNames); + constLines.add('const $darkName = ${literal(dark)};'); + } + return _ConstPlan(lightName: lightName, darkName: darkName); +} + +String _uniqueName(String base, Set usedNames) { + var name = base; + var suffix = 2; + while (!usedNames.add(name)) { + name = '$base$suffix'; + suffix++; + } + return name; +} + +String _colorLiteral(Color color) { + final argb = color.toARGB32(); + final a = (argb >> 24) & 0xFF; + final r = (argb >> 16) & 0xFF; + final g = (argb >> 8) & 0xFF; + final b = argb & 0xFF; + return 'Color.fromARGB($a, $r, $g, $b)'; +} + +String _avatarPaletteLiteral(List palette) { + final entries = palette + .map( + (pair) => + 'StreamAvatarColorPair(' + 'backgroundColor: ${_colorLiteral(pair.backgroundColor)}, ' + 'foregroundColor: ${_colorLiteral(pair.foregroundColor)})', + ) + .join(', '); + return '[$entries]'; +} + +bool _avatarPaletteEquals(List a, List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i].backgroundColor.toARGB32() != b[i].backgroundColor.toARGB32()) return false; + if (a[i].foregroundColor.toARGB32() != b[i].foregroundColor.toARGB32()) return false; + } + return true; +} + +/// A stable, readable const base name for one component property, e.g. +/// `('Badge Count', 'textColor')` -> `'badgeCountTextColor'`. +String _componentConstBaseName(String componentName, String property) { + final pascalComponent = componentName.split(' ').join(); + final camelComponent = pascalComponent[0].toLowerCase() + pascalComponent.substring(1); + final pascalProperty = property[0].toUpperCase() + property.substring(1); + return '$camelComponent$pascalProperty'; +} + +/// Builds the `avatarTheme: StreamAvatarThemeData(...)`-style named +/// arguments (one per component with at least one overridden property on +/// [brightness]) to splice into a `StreamTheme(...)` call. +String _componentThemeArgs(Map> componentPlans, Brightness brightness) { + final args = []; + for (final descriptor in componentThemeDescriptors) { + final propertyPlans = componentPlans[descriptor.name]; + if (propertyPlans == null) continue; + + final ctorArgs = []; + for (final property in descriptor.properties) { + final ref = propertyPlans[property]?.refFor(brightness); + if (ref != null) ctorArgs.add('$property: $ref'); + } + if (ctorArgs.isEmpty) continue; + + args.add('${descriptor.themeParameterName}: ${descriptor.themeDataTypeName}(${ctorArgs.join(', ')}),'); + } + return args.join('\n'); +} + +/// Rewrites a `final x = StreamTheme(...)` assignment that [DartFormatter] +/// collapsed onto a single line (because its arguments happened to be short +/// enough to fit) into the same one-argument-per-line shape used whenever +/// there's enough content to force wrapping. Without this, "nothing +/// customized" exports as a compact one-liner that reads as visually +/// inconsistent with — and, in a narrow code pane, wraps far worse than — +/// the multi-line shape every non-trivial export gets for free. +/// +/// `dart_style`'s tall-style formatter (Dart 3.7+) decides line-splitting +/// purely from content width; it doesn't treat a trailing comma in the +/// input as a "keep this expanded" hint the way the old formatter did, so +/// there's no formatter option to lean on for this — hence the targeted +/// rewrite instead. +String _forceMultilineStreamThemeAssignments(String formatted) { + final pattern = RegExp(r'^final (\w+) = StreamTheme\((.*)\);$', multiLine: true); + return formatted.replaceAllMapped(pattern, (match) { + final varName = match.group(1); + final args = _splitTopLevelArgs(match.group(2)!); + final argLines = args.map((arg) => ' $arg,').join('\n'); + return 'final $varName = StreamTheme(\n$argLines\n);'; + }); +} + +/// Splits a comma-separated argument list on commas at paren/bracket depth +/// zero only, so a nested call's own commas (e.g. inside +/// `StreamAvatarThemeData(backgroundColor: x, foregroundColor: y)`) aren't +/// mistaken for top-level argument separators. +List _splitTopLevelArgs(String argsList) { + final args = []; + var depth = 0; + var start = 0; + for (var i = 0; i < argsList.length; i++) { + final char = argsList[i]; + if (char == '(' || char == '[' || char == '{') depth++; + if (char == ')' || char == ']' || char == '}') depth--; + if (char == ',' && depth == 0) { + args.add(argsList.substring(start, i).trim()); + start = i + 1; + } + } + final last = argsList.substring(start).trim(); + if (last.isNotEmpty) args.add(last); + return args; +} + +/// Strips the synthetic `void _f() { ... }` wrapper used to get valid, +/// [DartFormatter]-formatted output for a snippet that isn't itself a +/// compilation unit, and removes one level of indentation. +String _dedent(String formatted) { + final lines = formatted.split('\n'); + // Drop the wrapper's opening `void _f() {` and closing `}` (plus the + // trailing blank line DartFormatter leaves after the final `}`). + final body = lines.sublist(1, lines.length - 2); + return body.map((line) => line.startsWith(' ') ? line.substring(2) : line).join('\n'); +} diff --git a/apps/design_system_gallery/lib/widgets/theme_export/export_column_row.dart b/apps/design_system_gallery/lib/widgets/theme_export/export_column_row.dart new file mode 100644 index 00000000..85068f32 --- /dev/null +++ b/apps/design_system_gallery/lib/widgets/theme_export/export_column_row.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/core.dart'; + +/// Width of the strip between the light and dark halves — the link toggle +/// column. +/// +/// Shared with the export page, which subtracts it to work out each settings +/// column's width: a second literal there could silently drift from this one +/// and skew that calculation. +const kExportLinkColumnWidth = 40.0; + +/// The shared building block for every row on the export page's settings +/// columns: a light half and a dark half, each themed and painted with that +/// side's own `backgroundApp` — so tile rows, section headers, group +/// headings, and inter-row spacing all paint the same continuous background +/// per column, instead of the page's own background showing through as a +/// seam between them. +class ExportColumnRow extends StatelessWidget { + const ExportColumnRow({ + super.key, + required this.lightMaterialTheme, + required this.darkMaterialTheme, + required this.lightBuilder, + required this.darkBuilder, + this.middle, + }) : height = null; + + /// A blank spacer of [height] with the same continuous background — use + /// this instead of a plain transparent `SizedBox` between rows/sections. + const ExportColumnRow.spacer({ + super.key, + required this.lightMaterialTheme, + required this.darkMaterialTheme, + required double this.height, + }) : lightBuilder = _empty, + darkBuilder = _empty, + middle = null; + + final ThemeData lightMaterialTheme; + final ThemeData darkMaterialTheme; + final WidgetBuilder lightBuilder; + final WidgetBuilder darkBuilder; + + /// Content for the narrow strip between the two halves (e.g. a link + /// toggle). Left blank (and thus unthemed) for headers/spacers. + final Widget? middle; + + final double? height; + + static Widget _empty(BuildContext context) => const SizedBox.shrink(); + + @override + Widget build(BuildContext context) { + final row = IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: _side(lightMaterialTheme, lightBuilder)), + SizedBox(width: kExportLinkColumnWidth, child: middle ?? const SizedBox.shrink()), + Expanded(child: _side(darkMaterialTheme, darkBuilder)), + ], + ), + ); + return height != null ? SizedBox(height: height, child: row) : row; + } + + Widget _side(ThemeData theme, WidgetBuilder builder) { + // A Builder gets a BuildContext under this side's Theme, so both the + // content's own context.streamColorScheme reads AND any dialog it pushes + // (showDialog captures inherited themes from the caller's context) pick + // up this side's colors rather than the page's ambient theme. + return Theme( + data: theme, + child: Builder( + builder: (context) => Material(color: context.streamColorScheme.backgroundApp, child: builder(context)), + ), + ); + } +} diff --git a/apps/design_system_gallery/lib/widgets/theme_export/link_toggle_button.dart b/apps/design_system_gallery/lib/widgets/theme_export/link_toggle_button.dart new file mode 100644 index 00000000..d216e636 --- /dev/null +++ b/apps/design_system_gallery/lib/widgets/theme_export/link_toggle_button.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/core.dart'; + +/// The link/unlink control sitting between a light and a dark color tile. +/// +/// Linked (default): editing either side's color edits both. Unlinked: each +/// side is edited independently. Styled off the *page's* ambient theme +/// (whatever brightness the studio itself currently is in) rather than +/// either the light or dark column, since it sits in the seam between them. +class LinkToggleButton extends StatelessWidget { + const LinkToggleButton({super.key, required this.linked, required this.onTap}); + + final bool linked; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + + return Center( + child: Tooltip( + message: linked ? 'Linked — editing either side edits both. Tap to unlink.' : 'Unlinked. Tap to link.', + child: Material( + color: linked ? colorScheme.backgroundSurfaceStrong : colorScheme.backgroundSurfaceSubtle, + shape: const CircleBorder(), + child: InkWell( + onTap: onTap, + customBorder: const CircleBorder(), + child: Padding( + padding: EdgeInsets.all(context.streamSpacing.xs), + child: Icon( + linked ? Icons.link : Icons.link_off, + size: 16, + color: linked ? colorScheme.accentPrimary : colorScheme.textTertiary, + ), + ), + ), + ), + ), + ); + } +} diff --git a/apps/design_system_gallery/lib/widgets/theme_export/linked_color_rows.dart b/apps/design_system_gallery/lib/widgets/theme_export/linked_color_rows.dart new file mode 100644 index 00000000..98a69249 --- /dev/null +++ b/apps/design_system_gallery/lib/widgets/theme_export/linked_color_rows.dart @@ -0,0 +1,188 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../config/component_theme_descriptors.dart'; +import '../../config/theme_color_slot.dart'; +import '../../config/theme_configuration.dart'; +import '../../config/theme_export_configuration.dart'; +import '../theme_studio/color_picker_tile.dart'; +import 'export_column_row.dart'; +import 'link_toggle_button.dart'; + +/// One [ThemeColorSlot], rendered as a light tile, a link toggle, and a dark +/// tile. Each tile is themed with the export config's cached light/dark +/// [ThemeData] so it renders — and opens its color picker — in that side's +/// actual colors, independent of the other column. +class LinkedColorRow extends StatelessWidget { + const LinkedColorRow({super.key, required this.slot, this.compact = false}); + + final ThemeColorSlot slot; + + /// Forwarded to both [ColorPickerTile]s — see [isColorPickerTileCompact]. + final bool compact; + + @override + Widget build(BuildContext context) { + final export = context.watch(); + + return _LinkedTileRow( + lightMaterialTheme: export.lightMaterialTheme, + darkMaterialTheme: export.darkMaterialTheme, + linked: export.isSlotLinked(slot), + onToggleLink: () => export.toggleSlotLinked(slot), + label: slot.parameterName, + compact: compact, + lightColor: export.light.resolve(slot), + lightIsDefault: !export.light.isCustom(slot), + onLightChanged: (color) => export.setColor(slot, color, from: Brightness.light), + onLightReset: () => export.resetColor(slot, from: Brightness.light), + darkColor: export.dark.resolve(slot), + darkIsDefault: !export.dark.isCustom(slot), + onDarkChanged: (color) => export.setColor(slot, color, from: Brightness.dark), + onDarkReset: () => export.resetColor(slot, from: Brightness.dark), + ); + } +} + +/// The brand or chrome seed color, rendered the same way as [LinkedColorRow] +/// but backed by [ThemeExportConfiguration.setSeed]/`resetSeed` since brand +/// and chrome are [ThemeSeedSlot]s, not [ThemeColorSlot]s. +class LinkedSeedRow extends StatelessWidget { + const LinkedSeedRow({super.key, required this.seed, required this.label, this.compact = false}); + + final ThemeSeedSlot seed; + final String label; + + /// Forwarded to both [ColorPickerTile]s — see [isColorPickerTileCompact]. + final bool compact; + + @override + Widget build(BuildContext context) { + final export = context.watch(); + + Color seedColorOf(ThemeConfiguration config) => + seed == ThemeSeedSlot.brand ? config.brandPrimaryColor : config.chromePrimaryColor; + bool isCustomOf(ThemeConfiguration config) => + seed == ThemeSeedSlot.brand ? config.brandIsCustom : config.chromeIsCustom; + + return _LinkedTileRow( + lightMaterialTheme: export.lightMaterialTheme, + darkMaterialTheme: export.darkMaterialTheme, + linked: export.isSeedLinked(seed), + onToggleLink: () => export.toggleSeedLinked(seed), + label: label, + compact: compact, + lightColor: seedColorOf(export.light), + lightIsDefault: !isCustomOf(export.light), + onLightChanged: (color) => export.setSeed(seed, color, from: Brightness.light), + onLightReset: () => export.resetSeed(seed, from: Brightness.light), + darkColor: seedColorOf(export.dark), + darkIsDefault: !isCustomOf(export.dark), + onDarkChanged: (color) => export.setSeed(seed, color, from: Brightness.dark), + onDarkReset: () => export.resetSeed(seed, from: Brightness.dark), + ); + } +} + +/// One `(component, property)` pair from an active component theme (see +/// [ComponentThemeDescriptor]), rendered the same way as [LinkedColorRow]. +/// +/// The resolved color is nullable here, unlike [ThemeColorSlot]s: a +/// component theme property has no SDK-computed default this page can +/// resolve (the real fallback lives inside the component's own widget), so +/// [ColorPickerTile] renders `null` as "default" rather than a fabricated +/// color. +class LinkedComponentColorRow extends StatelessWidget { + const LinkedComponentColorRow({super.key, required this.component, required this.property, this.compact = false}); + + final String component; + final String property; + + /// Forwarded to both [ColorPickerTile]s — see [isColorPickerTileCompact]. + final bool compact; + + @override + Widget build(BuildContext context) { + final export = context.watch(); + + return _LinkedTileRow( + lightMaterialTheme: export.lightMaterialTheme, + darkMaterialTheme: export.darkMaterialTheme, + linked: export.isComponentColorLinked(component, property), + onToggleLink: () => export.toggleComponentColorLinked(component, property), + label: property, + compact: compact, + lightColor: export.light.resolveComponentColor(component, property), + lightIsDefault: !export.light.isComponentColorCustom(component, property), + onLightChanged: (color) => export.setComponentColor(component, property, color, from: Brightness.light), + onLightReset: () => export.resetComponentColor(component, property, from: Brightness.light), + darkColor: export.dark.resolveComponentColor(component, property), + darkIsDefault: !export.dark.isComponentColorCustom(component, property), + onDarkChanged: (color) => export.setComponentColor(component, property, color, from: Brightness.dark), + onDarkReset: () => export.resetComponentColor(component, property, from: Brightness.dark), + ); + } +} + +/// Shared layout for [LinkedColorRow], [LinkedSeedRow] and +/// [LinkedComponentColorRow]: light tile, link toggle, dark tile — light and +/// dark align by construction since both tiles are the same [ColorPickerTile] +/// with a fixed layout. +class _LinkedTileRow extends StatelessWidget { + const _LinkedTileRow({ + required this.lightMaterialTheme, + required this.darkMaterialTheme, + required this.linked, + required this.onToggleLink, + required this.label, + required this.compact, + required this.lightColor, + required this.lightIsDefault, + required this.onLightChanged, + required this.onLightReset, + required this.darkColor, + required this.darkIsDefault, + required this.onDarkChanged, + required this.onDarkReset, + }); + + final ThemeData lightMaterialTheme; + final ThemeData darkMaterialTheme; + final bool linked; + final VoidCallback onToggleLink; + final String label; + final bool compact; + final Color? lightColor; + final bool lightIsDefault; + final ValueChanged onLightChanged; + final VoidCallback onLightReset; + final Color? darkColor; + final bool darkIsDefault; + final ValueChanged onDarkChanged; + final VoidCallback onDarkReset; + + @override + Widget build(BuildContext context) { + return ExportColumnRow( + lightMaterialTheme: lightMaterialTheme, + darkMaterialTheme: darkMaterialTheme, + middle: LinkToggleButton(linked: linked, onTap: onToggleLink), + lightBuilder: (context) => ColorPickerTile( + label: label, + color: lightColor, + isDefault: lightIsDefault, + onColorChanged: onLightChanged, + onReset: onLightReset, + compact: compact, + ), + darkBuilder: (context) => ColorPickerTile( + label: label, + color: darkColor, + isDefault: darkIsDefault, + onColorChanged: onDarkChanged, + onReset: onDarkReset, + compact: compact, + ), + ); + } +} diff --git a/apps/design_system_gallery/lib/widgets/theme_export/message_bubble_preview.dart b/apps/design_system_gallery/lib/widgets/theme_export/message_bubble_preview.dart new file mode 100644 index 00000000..2984c62a --- /dev/null +++ b/apps/design_system_gallery/lib/widgets/theme_export/message_bubble_preview.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/chat.dart'; + +/// A minimal incoming/outgoing message pair. +/// +/// Shows how the color scheme being edited affects [StreamMessageBubble] in +/// practice. Renders under whatever [Theme] wraps it — the export page wraps +/// one instance per brightness. +class MessageBubblePreview extends StatelessWidget { + const MessageBubblePreview({super.key}); + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Align( + alignment: AlignmentDirectional.centerStart, + child: StreamMessageBubble( + child: StreamMessageText('Has anyone tried the new Flutter update?'), + ), + ), + SizedBox(height: spacing.sm), + Align( + alignment: AlignmentDirectional.centerEnd, + child: StreamMessageLayout( + data: const StreamMessageLayoutData(alignment: StreamMessageAlignment.end), + child: StreamMessageBubble( + child: StreamMessageText('Sure, I can help with that!'), + ), + ), + ), + ], + ); + } +} diff --git a/apps/design_system_gallery/lib/widgets/theme_export/theme_export_code_pane.dart b/apps/design_system_gallery/lib/widgets/theme_export/theme_export_code_pane.dart new file mode 100644 index 00000000..b79eb7fd --- /dev/null +++ b/apps/design_system_gallery/lib/widgets/theme_export/theme_export_code_pane.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:stream_core_flutter/core.dart'; + +import '../../config/theme_export_configuration.dart'; + +/// The right-hand pane of the export page: the generated Dart snippet with a +/// copy button. +/// +/// Themed dark throughout via [ThemeExportConfiguration.darkMaterialTheme] +/// and Stream tokens (not hardcoded colors). +class ThemeExportCodePane extends StatefulWidget { + const ThemeExportCodePane({super.key}); + + @override + State createState() => _ThemeExportCodePaneState(); +} + +class _ThemeExportCodePaneState extends State { + // Scrollbar needs an explicit controller shared with the scroll view it + // decorates - without one it falls back to PrimaryScrollController, which + // this nested scroll view isn't registered as, and throws on scroll. + final _scrollController = ScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final export = context.watch(); + + return Theme( + data: export.darkMaterialTheme, + child: Builder( + builder: (context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + return Material( + color: colorScheme.backgroundElevation1, + child: Padding( + padding: EdgeInsets.all(spacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Dart code', + style: textTheme.headingXs.copyWith(color: colorScheme.textPrimary), + ), + ), + IconButton( + tooltip: 'Copy to clipboard', + icon: const Icon(Icons.copy, size: 18), + onPressed: () => _copyCode(context, export.generateCode()), + ), + ], + ), + SizedBox(height: spacing.sm), + Expanded( + child: Container( + width: double.infinity, + padding: EdgeInsets.all(spacing.sm), + decoration: BoxDecoration( + color: colorScheme.backgroundElevation0, + borderRadius: BorderRadius.all(context.streamRadius.sm), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.all(context.streamRadius.sm), + border: Border.all(color: colorScheme.borderSubtle), + ), + child: Scrollbar( + controller: _scrollController, + child: SingleChildScrollView( + controller: _scrollController, + child: SelectableText( + export.generateCode(), + style: textTheme.captionDefault.copyWith( + color: colorScheme.textPrimary, + fontFamily: 'monospace', + ), + ), + ), + ), + ), + ), + ], + ), + ), + ); + }, + ), + ); + } + + Future _copyCode(BuildContext context, String code) async { + await Clipboard.setData(ClipboardData(text: code)); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Copied theme code to clipboard'))); + } + } +} diff --git a/apps/design_system_gallery/lib/widgets/theme_export/theme_export_preview_bar.dart b/apps/design_system_gallery/lib/widgets/theme_export/theme_export_preview_bar.dart new file mode 100644 index 00000000..6f879f84 --- /dev/null +++ b/apps/design_system_gallery/lib/widgets/theme_export/theme_export_preview_bar.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:stream_core_flutter/core.dart'; + +import '../../config/theme_export_configuration.dart'; +import 'export_column_row.dart'; +import 'message_bubble_preview.dart'; + +/// A full-width message preview shown below the scrollable settings columns +/// (not inside them), split light/dark like every other row so it sits +/// directly on each column's own background rather than in a separate card. +class ThemeExportPreviewBar extends StatelessWidget { + const ThemeExportPreviewBar({super.key}); + + @override + Widget build(BuildContext context) { + final export = context.watch(); + final spacing = context.streamSpacing; + + return ExportColumnRow( + lightMaterialTheme: export.lightMaterialTheme, + darkMaterialTheme: export.darkMaterialTheme, + lightBuilder: (context) => Padding( + padding: EdgeInsets.all(spacing.md), + child: const MessageBubblePreview(), + ), + darkBuilder: (context) => Padding( + padding: EdgeInsets.all(spacing.md), + child: const MessageBubblePreview(), + ), + ); + } +} diff --git a/apps/design_system_gallery/lib/widgets/theme_export/theme_export_widgets.dart b/apps/design_system_gallery/lib/widgets/theme_export/theme_export_widgets.dart new file mode 100644 index 00000000..2f3f8172 --- /dev/null +++ b/apps/design_system_gallery/lib/widgets/theme_export/theme_export_widgets.dart @@ -0,0 +1,9 @@ +/// Widgets used by the theme export page. +library; + +export 'export_column_row.dart'; +export 'link_toggle_button.dart'; +export 'linked_color_rows.dart'; +export 'message_bubble_preview.dart'; +export 'theme_export_code_pane.dart'; +export 'theme_export_preview_bar.dart'; diff --git a/apps/design_system_gallery/lib/widgets/theme_studio/add_component_theme_button.dart b/apps/design_system_gallery/lib/widgets/theme_studio/add_component_theme_button.dart new file mode 100644 index 00000000..b598541e --- /dev/null +++ b/apps/design_system_gallery/lib/widgets/theme_studio/add_component_theme_button.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/core.dart'; + +import '../../config/component_theme_descriptors.dart'; + +/// A button that opens a picker over [available] component themes and calls +/// [onSelected] with the chosen one's name. Renders nothing when [available] +/// is empty (every component theme is already active). +/// +/// Shared by the theme studio panel and the export page so "add a component +/// theme" looks and behaves identically in both places. +class AddComponentThemeButton extends StatelessWidget { + const AddComponentThemeButton({super.key, required this.available, required this.onSelected}); + + final List available; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + if (available.isEmpty) return const SizedBox.shrink(); + + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + final radius = context.streamRadius; + + return Material( + color: colorScheme.backgroundSurface, + borderRadius: BorderRadius.all(radius.md), + child: InkWell( + onTap: () => _showAddComponentThemeDialog(context), + borderRadius: BorderRadius.all(radius.md), + child: Container( + padding: EdgeInsets.all(spacing.sm), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.all(radius.md), + border: Border.all(color: colorScheme.borderDefault), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.add, color: colorScheme.accentPrimary, size: 16), + SizedBox(width: spacing.xs), + Text( + 'Add component theme', + style: textTheme.captionEmphasis.copyWith(color: colorScheme.accentPrimary), + ), + ], + ), + ), + ), + ); + } + + Future _showAddComponentThemeDialog(BuildContext context) async { + final textTheme = context.streamTextTheme; + + final selected = await showDialog( + context: context, + builder: (context) => SimpleDialog( + title: Text('Add component theme', style: textTheme.headingSm), + children: [ + for (final descriptor in available) + SimpleDialogOption( + onPressed: () => Navigator.of(context).pop(descriptor), + child: Text('${descriptor.name} (${descriptor.properties.length} colors)'), + ), + ], + ), + ); + + if (selected != null) onSelected(selected.name); + } +} + +/// The small "Remove component theme" row shown below an added component's +/// properties. Shared for the same reason as [AddComponentThemeButton]. +class RemoveComponentThemeButton extends StatelessWidget { + const RemoveComponentThemeButton({super.key, required this.onTap}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.all(context.streamRadius.sm), + child: Padding( + padding: EdgeInsets.symmetric(vertical: spacing.xs), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.delete_outline, color: colorScheme.textTertiary, size: 14), + SizedBox(width: spacing.xs), + Text( + 'Remove component theme', + style: textTheme.captionDefault.copyWith(color: colorScheme.textTertiary), + ), + ], + ), + ), + ); + } +} diff --git a/apps/design_system_gallery/lib/widgets/theme_studio/color_picker_tile.dart b/apps/design_system_gallery/lib/widgets/theme_studio/color_picker_tile.dart index 711952be..95b0bd63 100644 --- a/apps/design_system_gallery/lib/widgets/theme_studio/color_picker_tile.dart +++ b/apps/design_system_gallery/lib/widgets/theme_studio/color_picker_tile.dart @@ -2,6 +2,32 @@ import 'package:flutter/material.dart'; import 'package:flutter_colorpicker/flutter_colorpicker.dart'; import 'package:stream_core_flutter/core.dart'; +/// Below this width, the hex value and edit/reset icons don't fit +/// comfortably beside the label/default column — they move to their own +/// row underneath instead of squeezing both onto one line. Chosen to give +/// the label column at least ~100px even after the swatch, gaps, hex text +/// and icons are accounted for; narrower than this and a longer label (or +/// hex) starts wrapping character-by-character instead of just truncating. +const _kStackedLayoutThreshold = 260.0; + +/// Whether a [ColorPickerTile] given [outerWidth] of horizontal space (its +/// own render width, before its internal padding) would need the stacked +/// (compact) layout. +/// +/// [ColorPickerTile] can't decide this itself via an internal `LayoutBuilder` +/// the way a typical responsive widget would: the export page's linked rows +/// wrap light/dark tiles in `IntrinsicHeight` to align them, and +/// `IntrinsicHeight` can't compute intrinsic dimensions through a +/// `LayoutBuilder` anywhere in its subtree (Flutter throws on it). So a +/// caller that lays out several tiles at a shared, known width — like the +/// export page's settings columns — measures once via its own `LayoutBuilder` +/// placed *above* `IntrinsicHeight`, and passes the result down as +/// [ColorPickerTile.compact]. +bool isColorPickerTileCompact(double outerWidth, StreamSpacing spacing) { + final innerWidth = outerWidth - 2 * (spacing.sm + spacing.xxs); + return innerWidth < _kStackedLayoutThreshold; +} + /// A tile that displays a color and opens a color picker when tapped. class ColorPickerTile extends StatelessWidget { const ColorPickerTile({ @@ -11,15 +37,22 @@ class ColorPickerTile extends StatelessWidget { required this.onColorChanged, this.isDefault = false, this.onReset, + this.compact = false, }); final String label; - final Color color; + + /// The current color, or `null` when [isDefault] is true and there's no + /// concrete default to show (e.g. a component theme property that falls + /// back to a value this tile can't resolve). `null` renders a neutral + /// placeholder swatch and the literal text `default` in place of a hex + /// code, rather than a fabricated color that would look like a real value. + final Color? color; final ValueChanged onColorChanged; /// Whether [color] is still the SDK default (i.e. not overridden). /// - /// When true, a "default" subtitle is shown below [label] and no reset + /// When true, a "default" caption is shown below [label] and no reset /// control is displayed. final bool isDefault; @@ -28,11 +61,15 @@ class ColorPickerTile extends StatelessWidget { /// Ignored (no reset control shown) when [isDefault] is true. final VoidCallback? onReset; + /// Use the stacked (label / default / hex+icons) layout instead of the + /// inline one — see [isColorPickerTileCompact]. Defaults to false: the + /// theme studio panel is always wide enough for the inline layout, so it + /// doesn't need to compute this at all. + final bool compact; + @override Widget build(BuildContext context) { final colorScheme = context.streamColorScheme; - final textTheme = context.streamTextTheme; - final boxShadow = context.streamBoxShadow; final radius = context.streamRadius; final spacing = context.streamSpacing; @@ -52,71 +89,155 @@ class ColorPickerTile extends StatelessWidget { borderRadius: BorderRadius.all(radius.sm), border: Border.all(color: colorScheme.borderDefault), ), - child: Row( + child: compact ? _buildStacked(context) : _buildInline(context), + ), + ), + ); + } + + /// Swatch, then label/default beside the hex value and icons — everything + /// on one visual row. Used when there's enough width for the hex and + /// icons to share a line with the label column without crowding it. + Widget _buildInline(BuildContext context) { + final spacing = context.streamSpacing; + + return Row( + children: [ + _buildSwatch(context), + SizedBox(width: spacing.sm + spacing.xxs), + Expanded(child: _buildLabelColumn(context)), + SizedBox(width: spacing.xs + spacing.xxs), + _buildValueRow(context, expanded: false), + ], + ); + } + + /// Swatch beside label/default, with the hex value and icons moved to + /// their own row underneath. Used when the tile is too narrow for the hex + /// and icons to share a line with the label without crowding it — see + /// [_kStackedLayoutThreshold]. + Widget _buildStacked(BuildContext context) { + final spacing = context.streamSpacing; + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSwatch(context), + SizedBox(width: spacing.sm + spacing.xxs), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ - Container( - width: 24, - height: 24, - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.all(radius.xs), - boxShadow: boxShadow.elevation1, - ), - foregroundDecoration: BoxDecoration( - borderRadius: BorderRadius.all(radius.xs), - border: Border.all( - color: colorScheme.borderDefault.withValues(alpha: 0.3), - ), - ), - ), - SizedBox(width: spacing.sm + spacing.xxs), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - label, - style: textTheme.metadataDefault.copyWith( - color: colorScheme.textPrimary, - fontFamily: 'monospace', - ), - ), - if (isDefault) - Text( - 'default', - style: textTheme.metadataDefault.copyWith( - color: colorScheme.textTertiary, - ), - ), - ], - ), - ), - Text( - _colorToHex(color), - style: textTheme.metadataDefault.copyWith( - color: colorScheme.textTertiary, - fontFamily: 'monospace', - ), - ), - if (!isDefault && onReset != null) ...[ - SizedBox(width: spacing.xs + spacing.xxs), - Tooltip( - message: 'Reset to default', - child: InkWell( - onTap: onReset, - borderRadius: BorderRadius.all(radius.xs), - child: Icon(Icons.restart_alt, color: colorScheme.textSecondary, size: 14), - ), - ), - ], - SizedBox(width: spacing.xs + spacing.xxs), - Icon(Icons.edit, color: colorScheme.textSecondary, size: 12), + _buildLabelColumn(context), + _buildValueRow(context, expanded: true), ], ), ), + ], + ); + } + + Widget _buildSwatch(BuildContext context) { + final colorScheme = context.streamColorScheme; + final boxShadow = context.streamBoxShadow; + final radius = context.streamRadius; + + return Container( + width: 24, + height: 24, + clipBehavior: Clip.antiAlias, + alignment: Alignment.center, + decoration: BoxDecoration( + color: color ?? colorScheme.backgroundSurfaceStrong, + borderRadius: BorderRadius.all(radius.xs), + boxShadow: color != null ? boxShadow.elevation1 : null, + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.all(radius.xs), + border: Border.all(color: colorScheme.borderDefault.withValues(alpha: color != null ? 0.3 : 1)), ), + child: color == null ? Icon(Icons.help_outline, size: 14, color: colorScheme.textTertiary) : null, + ); + } + + Widget _buildLabelColumn(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.metadataDefault.copyWith(color: colorScheme.textPrimary, fontFamily: 'monospace'), + ), + // Always laid out (transparent when not default) so a default tile + // and a customized tile for the same slot are the same height - + // otherwise a light/dark pair in the export page's linked rows + // visibly misaligns whenever only one side is customized. + // + // Dropped from the semantics tree when it isn't the real state, + // though: transparent text is still read out, so a customized tile + // would otherwise announce "default". + ExcludeSemantics( + excluding: !isDefault, + child: Text( + 'default', + style: textTheme.metadataDefault.copyWith( + color: isDefault ? colorScheme.textTertiary : StreamColors.transparent, + ), + ), + ), + ], + ); + } + + /// The hex value plus reset/edit icons. Inline (`expanded: false`) packs + /// them tight, appended straight after the label column. Stacked + /// (`expanded: true`) is its own full-width row, with a [Spacer] pushing + /// the icons to the trailing edge since there's no label column to butt + /// up against on that row. + Widget _buildValueRow(BuildContext context, {required bool expanded}) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final radius = context.streamRadius; + final spacing = context.streamSpacing; + + return Row( + mainAxisSize: expanded ? MainAxisSize.max : MainAxisSize.min, + children: [ + if (color != null) + // Flexible + ellipsis rather than a bare Text: at extreme widths + // (e.g. a component color with a reset icon, on an already-narrow + // tile) the hex text plus icons can still exceed even this row's + // own width - shrink the hex first rather than overflow. + Flexible( + child: Text( + _colorToHex(color!), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.metadataDefault.copyWith(color: colorScheme.textTertiary, fontFamily: 'monospace'), + ), + ), + if (expanded) const Spacer(), + if (!isDefault && onReset != null) ...[ + SizedBox(width: spacing.xs + spacing.xxs), + Tooltip( + message: 'Reset to default', + child: InkWell( + onTap: onReset, + borderRadius: BorderRadius.all(radius.xs), + child: Icon(Icons.restart_alt, color: colorScheme.textSecondary, size: 14), + ), + ), + ], + SizedBox(width: spacing.xs + spacing.xxs), + Icon(Icons.edit, color: colorScheme.textSecondary, size: 12), + ], ); } @@ -126,7 +247,7 @@ class ColorPickerTile extends StatelessWidget { } Future _showColorPicker(BuildContext context) async { - var pickerColor = color; + var pickerColor = color ?? context.streamColorScheme.backgroundSurfaceStrong; final textTheme = context.streamTextTheme; await showDialog( diff --git a/apps/design_system_gallery/lib/widgets/theme_studio/theme_customization_panel.dart b/apps/design_system_gallery/lib/widgets/theme_studio/theme_customization_panel.dart index 3ce90aa8..f0ba6bc9 100644 --- a/apps/design_system_gallery/lib/widgets/theme_studio/theme_customization_panel.dart +++ b/apps/design_system_gallery/lib/widgets/theme_studio/theme_customization_panel.dart @@ -4,7 +4,10 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:stream_core_flutter/core.dart'; +import '../../config/component_theme_descriptors.dart'; import '../../config/theme_configuration.dart'; +import '../../config/theme_studio_sections.dart'; +import 'add_component_theme_button.dart'; import 'avatar_palette_section.dart'; import 'color_picker_tile.dart'; import 'mode_button.dart'; @@ -35,7 +38,12 @@ StreamAvatarColorPair _generateRandomAvatarPair({required bool isDark}) { /// A panel widget for customizing the Stream theme. /// -/// Organized into sections matching [StreamColorScheme] structure. +/// Color sections (accent/text/background/border/system) are rendered from +/// [themeStudioSections] so this panel, the export page, and the code +/// generator all stay in sync off a single list. Appearance, brand, chrome, +/// and the avatar palette aren't slot-driven (brightness is a single value, +/// brand/chrome are swatch seeds, and the palette is a list, not a color) so +/// they keep bespoke sections below. class ThemeCustomizationPanel extends StatefulWidget { const ThemeCustomizationPanel({super.key}); @@ -85,20 +93,18 @@ class _ThemeCustomizationPanelState extends State { SizedBox(height: spacing.md), _buildChromeSection(context), SizedBox(height: spacing.md), - _buildAccentColorsSection(context), - SizedBox(height: spacing.md), - _buildTextColorsSection(context), - SizedBox(height: spacing.md), - _buildBackgroundColorsSection(context), - SizedBox(height: spacing.md), - _buildBorderCoreSection(context), - SizedBox(height: spacing.md), - _buildBorderUtilitySection(context), - SizedBox(height: spacing.md), - _buildSystemColorsSection(context), - SizedBox(height: spacing.md), + for (final section in themeStudioSections) ...[ + _buildSlotSection(context, section), + SizedBox(height: spacing.md), + ], _buildAvatarPaletteSection(context), SizedBox(height: spacing.md), + for (final component in context.watch().activeComponentThemes) + if (componentThemeDescriptorOrNull(component) case final descriptor?) ...[ + _buildComponentThemeSection(context, descriptor), + SizedBox(height: spacing.md), + ], + _buildAddComponentThemeButton(context), ], ), ), @@ -242,433 +248,39 @@ class _ThemeCustomizationPanelState extends State { ); } - Widget _buildAccentColorsSection(BuildContext context) { - final config = context.watch(); - return SectionCard( - title: 'Accent Colors', - subtitle: 'accent*', - icon: Icons.color_lens, - child: Column( - children: [ - ColorPickerTile( - label: 'accentPrimary', - color: config.accentPrimary, - isDefault: !config.accentPrimaryIsCustom, - onColorChanged: config.setAccentPrimary, - onReset: config.resetAccentPrimary, - ), - ColorPickerTile( - label: 'accentSuccess', - color: config.accentSuccess, - isDefault: !config.accentSuccessIsCustom, - onColorChanged: config.setAccentSuccess, - onReset: config.resetAccentSuccess, - ), - ColorPickerTile( - label: 'accentWarning', - color: config.accentWarning, - isDefault: !config.accentWarningIsCustom, - onColorChanged: config.setAccentWarning, - onReset: config.resetAccentWarning, - ), - ColorPickerTile( - label: 'accentError', - color: config.accentError, - isDefault: !config.accentErrorIsCustom, - onColorChanged: config.setAccentError, - onReset: config.resetAccentError, - ), - ColorPickerTile( - label: 'accentNeutral', - color: config.accentNeutral, - isDefault: !config.accentNeutralIsCustom, - onColorChanged: config.setAccentNeutral, - onReset: config.resetAccentNeutral, - ), - ], - ), - ); - } - - Widget _buildTextColorsSection(BuildContext context) { - final config = context.watch(); - return SectionCard( - title: 'Text Colors', - subtitle: 'text*', - icon: Icons.format_color_text, - child: Column( - children: [ - ColorPickerTile( - label: 'textPrimary', - color: config.textPrimary, - isDefault: !config.textPrimaryIsCustom, - onColorChanged: config.setTextPrimary, - onReset: config.resetTextPrimary, - ), - ColorPickerTile( - label: 'textSecondary', - color: config.textSecondary, - isDefault: !config.textSecondaryIsCustom, - onColorChanged: config.setTextSecondary, - onReset: config.resetTextSecondary, - ), - ColorPickerTile( - label: 'textTertiary', - color: config.textTertiary, - isDefault: !config.textTertiaryIsCustom, - onColorChanged: config.setTextTertiary, - onReset: config.resetTextTertiary, - ), - ColorPickerTile( - label: 'textDisabled', - color: config.textDisabled, - isDefault: !config.textDisabledIsCustom, - onColorChanged: config.setTextDisabled, - onReset: config.resetTextDisabled, - ), - ColorPickerTile( - label: 'textLink', - color: config.textLink, - isDefault: !config.textLinkIsCustom, - onColorChanged: config.setTextLink, - onReset: config.resetTextLink, - ), - ColorPickerTile( - label: 'textOnAccent', - color: config.textOnAccent, - isDefault: !config.textOnAccentIsCustom, - onColorChanged: config.setTextOnAccent, - onReset: config.resetTextOnAccent, - ), - ], - ), - ); - } - - Widget _buildBackgroundColorsSection(BuildContext context) { + /// Renders one [ThemeStudioSection] as a [SectionCard] of [ColorPickerTile]s, + /// grouped and sub-headed per [ThemeStudioSlotGroup]. + Widget _buildSlotSection(BuildContext context, ThemeStudioSection section) { final config = context.watch(); + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; final spacing = context.streamSpacing; - return SectionCard( - title: 'Background Colors', - subtitle: 'background*', - icon: Icons.format_paint, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ColorPickerTile( - label: 'backgroundApp', - color: config.backgroundApp, - isDefault: !config.backgroundAppIsCustom, - onColorChanged: config.setBackgroundApp, - onReset: config.resetBackgroundApp, - ), - ColorPickerTile( - label: 'backgroundInverse', - color: config.backgroundInverse, - isDefault: !config.backgroundInverseIsCustom, - onColorChanged: config.setBackgroundInverse, - onReset: config.resetBackgroundInverse, - ), - ColorPickerTile( - label: 'backgroundOnAccent', - color: config.backgroundOnAccent, - isDefault: !config.backgroundOnAccentIsCustom, - onColorChanged: config.setBackgroundOnAccent, - onReset: config.resetBackgroundOnAccent, - ), - ColorPickerTile( - label: 'backgroundHighlight', - color: config.backgroundHighlight, - isDefault: !config.backgroundHighlightIsCustom, - onColorChanged: config.setBackgroundHighlight, - onReset: config.resetBackgroundHighlight, - ), - ColorPickerTile( - label: 'backgroundScrim', - color: config.backgroundScrim, - isDefault: !config.backgroundScrimIsCustom, - onColorChanged: config.setBackgroundScrim, - onReset: config.resetBackgroundScrim, - ), - ColorPickerTile( - label: 'backgroundOverlayLight', - color: config.backgroundOverlayLight, - isDefault: !config.backgroundOverlayLightIsCustom, - onColorChanged: config.setBackgroundOverlayLight, - onReset: config.resetBackgroundOverlayLight, - ), - ColorPickerTile( - label: 'backgroundOverlayDark', - color: config.backgroundOverlayDark, - isDefault: !config.backgroundOverlayDarkIsCustom, - onColorChanged: config.setBackgroundOverlayDark, - onReset: config.resetBackgroundOverlayDark, - ), - ColorPickerTile( - label: 'backgroundDisabled', - color: config.backgroundDisabled, - isDefault: !config.backgroundDisabledIsCustom, - onColorChanged: config.setBackgroundDisabled, - onReset: config.resetBackgroundDisabled, - ), - ColorPickerTile( - label: 'backgroundHover', - color: config.backgroundHover, - isDefault: !config.backgroundHoverIsCustom, - onColorChanged: config.setBackgroundHover, - onReset: config.resetBackgroundHover, - ), - ColorPickerTile( - label: 'backgroundPressed', - color: config.backgroundPressed, - isDefault: !config.backgroundPressedIsCustom, - onColorChanged: config.setBackgroundPressed, - onReset: config.resetBackgroundPressed, - ), - ColorPickerTile( - label: 'backgroundSelected', - color: config.backgroundSelected, - isDefault: !config.backgroundSelectedIsCustom, - onColorChanged: config.setBackgroundSelected, - onReset: config.resetBackgroundSelected, - ), - SizedBox(height: spacing.xs), - Text( - 'Surface', - style: context.streamTextTheme.metadataEmphasis.copyWith( - color: context.streamColorScheme.textSecondary, - ), - ), - SizedBox(height: spacing.xs), - ColorPickerTile( - label: 'backgroundSurface', - color: config.backgroundSurface, - isDefault: !config.backgroundSurfaceIsCustom, - onColorChanged: config.setBackgroundSurface, - onReset: config.resetBackgroundSurface, - ), - ColorPickerTile( - label: 'backgroundSurfaceSubtle', - color: config.backgroundSurfaceSubtle, - isDefault: !config.backgroundSurfaceSubtleIsCustom, - onColorChanged: config.setBackgroundSurfaceSubtle, - onReset: config.resetBackgroundSurfaceSubtle, - ), - ColorPickerTile( - label: 'backgroundSurfaceStrong', - color: config.backgroundSurfaceStrong, - isDefault: !config.backgroundSurfaceStrongIsCustom, - onColorChanged: config.setBackgroundSurfaceStrong, - onReset: config.resetBackgroundSurfaceStrong, - ), - ColorPickerTile( - label: 'backgroundSurfaceCard', - color: config.backgroundSurfaceCard, - isDefault: !config.backgroundSurfaceCardIsCustom, - onColorChanged: config.setBackgroundSurfaceCard, - onReset: config.resetBackgroundSurfaceCard, - ), - SizedBox(height: spacing.xs), - Text( - 'Elevation', - style: context.streamTextTheme.metadataEmphasis.copyWith( - color: context.streamColorScheme.textSecondary, - ), - ), - SizedBox(height: spacing.xs), - ColorPickerTile( - label: 'backgroundElevation0', - color: config.backgroundElevation0, - isDefault: !config.backgroundElevation0IsCustom, - onColorChanged: config.setBackgroundElevation0, - onReset: config.resetBackgroundElevation0, - ), - ColorPickerTile( - label: 'backgroundElevation1', - color: config.backgroundElevation1, - isDefault: !config.backgroundElevation1IsCustom, - onColorChanged: config.setBackgroundElevation1, - onReset: config.resetBackgroundElevation1, - ), - ColorPickerTile( - label: 'backgroundElevation2', - color: config.backgroundElevation2, - isDefault: !config.backgroundElevation2IsCustom, - onColorChanged: config.setBackgroundElevation2, - onReset: config.resetBackgroundElevation2, - ), - ColorPickerTile( - label: 'backgroundElevation3', - color: config.backgroundElevation3, - isDefault: !config.backgroundElevation3IsCustom, - onColorChanged: config.setBackgroundElevation3, - onReset: config.resetBackgroundElevation3, - ), - ], - ), - ); - } - - Widget _buildBorderCoreSection(BuildContext context) { - final config = context.watch(); - return SectionCard( - title: 'Border Colors - Core', - subtitle: 'border*', - icon: Icons.border_all, - child: Column( - children: [ - ColorPickerTile( - label: 'borderDefault', - color: config.borderDefault, - isDefault: !config.borderDefaultIsCustom, - onColorChanged: config.setBorderDefault, - onReset: config.resetBorderDefault, - ), - ColorPickerTile( - label: 'borderSubtle', - color: config.borderSubtle, - isDefault: !config.borderSubtleIsCustom, - onColorChanged: config.setBorderSubtle, - onReset: config.resetBorderSubtle, - ), - ColorPickerTile( - label: 'borderStrong', - color: config.borderStrong, - isDefault: !config.borderStrongIsCustom, - onColorChanged: config.setBorderStrong, - onReset: config.resetBorderStrong, - ), - ColorPickerTile( - label: 'borderOnAccent', - color: config.borderOnAccent, - isDefault: !config.borderOnAccentIsCustom, - onColorChanged: config.setBorderOnAccent, - onReset: config.resetBorderOnAccent, - ), - ColorPickerTile( - label: 'borderOnSurface', - color: config.borderOnSurface, - isDefault: !config.borderOnSurfaceIsCustom, - onColorChanged: config.setBorderOnSurface, - onReset: config.resetBorderOnSurface, - ), - ColorPickerTile( - label: 'borderOpacitySubtle', - color: config.borderOpacitySubtle, - isDefault: !config.borderOpacitySubtleIsCustom, - onColorChanged: config.setBorderOpacitySubtle, - onReset: config.resetBorderOpacitySubtle, - ), - ColorPickerTile( - label: 'borderOpacityStrong', - color: config.borderOpacityStrong, - isDefault: !config.borderOpacityStrongIsCustom, - onColorChanged: config.setBorderOpacityStrong, - onReset: config.resetBorderOpacityStrong, - ), - ], - ), - ); - } - - Widget _buildBorderUtilitySection(BuildContext context) { - final config = context.watch(); - return SectionCard( - title: 'Border Colors - Utility', - subtitle: 'border*', - icon: Icons.border_style, - child: Column( - children: [ - ColorPickerTile( - label: 'borderFocus', - color: config.borderFocus, - isDefault: !config.borderFocusIsCustom, - onColorChanged: config.setBorderFocus, - onReset: config.resetBorderFocus, - ), - ColorPickerTile( - label: 'borderActive', - color: config.borderActive, - isDefault: !config.borderActiveIsCustom, - onColorChanged: config.setBorderActive, - onReset: config.resetBorderActive, - ), - ColorPickerTile( - label: 'borderHover', - color: config.borderHover, - isDefault: !config.borderHoverIsCustom, - onColorChanged: config.setBorderHover, - onReset: config.resetBorderHover, - ), - ColorPickerTile( - label: 'borderPressed', - color: config.borderPressed, - isDefault: !config.borderPressedIsCustom, - onColorChanged: config.setBorderPressed, - onReset: config.resetBorderPressed, - ), - ColorPickerTile( - label: 'borderDisabled', - color: config.borderDisabled, - isDefault: !config.borderDisabledIsCustom, - onColorChanged: config.setBorderDisabled, - onReset: config.resetBorderDisabled, - ), - ColorPickerTile( - label: 'borderError', - color: config.borderError, - isDefault: !config.borderErrorIsCustom, - onColorChanged: config.setBorderError, - onReset: config.resetBorderError, - ), - ColorPickerTile( - label: 'borderWarning', - color: config.borderWarning, - isDefault: !config.borderWarningIsCustom, - onColorChanged: config.setBorderWarning, - onReset: config.resetBorderWarning, - ), - ColorPickerTile( - label: 'borderSuccess', - color: config.borderSuccess, - isDefault: !config.borderSuccessIsCustom, - onColorChanged: config.setBorderSuccess, - onReset: config.resetBorderSuccess, - ), - ColorPickerTile( - label: 'borderSelected', - color: config.borderSelected, - isDefault: !config.borderSelectedIsCustom, - onColorChanged: config.setBorderSelected, - onReset: config.resetBorderSelected, - ), - ], - ), - ); - } - Widget _buildSystemColorsSection(BuildContext context) { - final config = context.watch(); return SectionCard( - title: 'System Colors', - subtitle: 'system*', - icon: Icons.settings_system_daydream, + title: section.title, + subtitle: section.subtitle, + icon: section.icon, child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - ColorPickerTile( - label: 'systemText', - color: config.systemText, - isDefault: !config.systemTextIsCustom, - onColorChanged: config.setSystemText, - onReset: config.resetSystemText, - ), - ColorPickerTile( - label: 'systemScrollbar', - color: config.systemScrollbar, - isDefault: !config.systemScrollbarIsCustom, - onColorChanged: config.setSystemScrollbar, - onReset: config.resetSystemScrollbar, - ), + for (final group in section.groups) ...[ + if (group.heading case final heading?) ...[ + if (group != section.groups.first) SizedBox(height: spacing.xs), + Text( + heading, + style: textTheme.metadataEmphasis.copyWith(color: colorScheme.textSecondary), + ), + SizedBox(height: spacing.xs), + ], + for (final slot in group.slots) + ColorPickerTile( + label: slot.parameterName, + color: config.resolve(slot), + isDefault: !config.isCustom(slot), + onColorChanged: (color) => config.setOverride(slot, color), + onReset: () => config.resetOverride(slot), + ), + ], ], ), ); @@ -745,4 +357,44 @@ class _ThemeCustomizationPanelState extends State { ), ); } + + /// Renders one added component theme (see [ComponentThemeDescriptor]) as a + /// [SectionCard] of [ColorPickerTile]s, one per editable property, with a + /// control to remove the whole section. + Widget _buildComponentThemeSection(BuildContext context, ComponentThemeDescriptor descriptor) { + final config = context.watch(); + final spacing = context.streamSpacing; + + return SectionCard( + title: descriptor.name, + subtitle: descriptor.themeParameterName, + icon: Icons.widgets_outlined, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final property in descriptor.properties) + ColorPickerTile( + label: property, + // Component theme properties have no SDK-computed default value + // available here (unlike StreamColorScheme slots) - the real + // fallback lives inside each component's own widget. Passing + // null (rather than guessing a color) renders as "default" + // instead of a fabricated hex value. + color: config.resolveComponentColor(descriptor.name, property), + isDefault: !config.isComponentColorCustom(descriptor.name, property), + onColorChanged: (color) => config.setComponentColor(descriptor.name, property, color), + onReset: () => config.resetComponentColor(descriptor.name, property), + ), + SizedBox(height: spacing.sm), + RemoveComponentThemeButton(onTap: () => config.removeComponentTheme(descriptor.name)), + ], + ), + ); + } + + Widget _buildAddComponentThemeButton(BuildContext context) { + final config = context.watch(); + final available = componentThemeDescriptors.where((d) => !config.activeComponentThemes.contains(d.name)).toList(); + return AddComponentThemeButton(available: available, onSelected: config.addComponentTheme); + } } diff --git a/apps/design_system_gallery/lib/widgets/theme_studio/theme_studio_widgets.dart b/apps/design_system_gallery/lib/widgets/theme_studio/theme_studio_widgets.dart index 8adef0e5..5061ce98 100644 --- a/apps/design_system_gallery/lib/widgets/theme_studio/theme_studio_widgets.dart +++ b/apps/design_system_gallery/lib/widgets/theme_studio/theme_studio_widgets.dart @@ -3,6 +3,7 @@ /// This barrel file exports all theme customization-related widgets. library; +export 'add_component_theme_button.dart'; export 'avatar_palette_section.dart'; export 'color_picker_tile.dart'; export 'mode_button.dart'; diff --git a/apps/design_system_gallery/lib/widgets/toolbar/toolbar.dart b/apps/design_system_gallery/lib/widgets/toolbar/toolbar.dart index 6ecd5d93..419eefb2 100644 --- a/apps/design_system_gallery/lib/widgets/toolbar/toolbar.dart +++ b/apps/design_system_gallery/lib/widgets/toolbar/toolbar.dart @@ -25,10 +25,12 @@ class GalleryToolbar extends StatelessWidget { super.key, required this.showThemePanel, required this.onToggleThemePanel, + required this.onExportTheme, }); final bool showThemePanel; final VoidCallback onToggleThemePanel; + final VoidCallback onExportTheme; @override Widget build(BuildContext context) { @@ -128,6 +130,15 @@ class GalleryToolbar extends StatelessWidget { isActive: showThemePanel, onTap: onToggleThemePanel, ), + SizedBox(width: spacing.sm), + + // Export the current theme studio state as a Dart snippet. + ToolbarButton( + icon: Icons.ios_share, + tooltip: 'Export Theme', + isActive: false, + onTap: onExportTheme, + ), ], ), ); diff --git a/apps/design_system_gallery/macos/Runner.xcodeproj/project.pbxproj b/apps/design_system_gallery/macos/Runner.xcodeproj/project.pbxproj index e4842326..8f55ce34 100644 --- a/apps/design_system_gallery/macos/Runner.xcodeproj/project.pbxproj +++ b/apps/design_system_gallery/macos/Runner.xcodeproj/project.pbxproj @@ -28,6 +28,7 @@ 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; 46DD458174C7774D5517212F /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 59B1E00FA3B30213D4C20AB2 /* Pods_RunnerTests.framework */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 7E0E203C66EAF59B6A8F8BC4 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1E0ABC705860251A4E5A52A9 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ @@ -82,6 +83,7 @@ 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; 59B1E00FA3B30213D4C20AB2 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 710C45C47279046866A6B261 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; 7DE4CA34EE522ACFD8C08F0E /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 9426CBEF606781FF2C6BFDF7 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; @@ -103,6 +105,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, 7E0E203C66EAF59B6A8F8BC4 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -164,6 +167,7 @@ 33CEB47122A05771004F2AC0 /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, @@ -240,7 +244,6 @@ 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, - 12F31F9AA969464C3B9CAF4E /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -248,6 +251,9 @@ 33CC11202044C79F0003C045 /* PBXTargetDependency */, ); name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); productName = Runner; productReference = 33CC10ED2044A3C60003C045 /* design_system_gallery.app */; productType = "com.apple.product-type.application"; @@ -292,6 +298,9 @@ Base, ); mainGroup = 33CC10E42044A3C60003C045; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -323,23 +332,6 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 12F31F9AA969464C3B9CAF4E /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; 1EDB2D5DDC187E3C9DEBE26A /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -796,6 +788,20 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 33CC10E52044A3C60003C045 /* Project object */; } diff --git a/apps/design_system_gallery/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/apps/design_system_gallery/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index ff5c6086..427da882 100644 --- a/apps/design_system_gallery/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/apps/design_system_gallery/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + =3.38.1" dependencies: + dart_style: ^3.1.9 device_frame_plus: ^1.0.0 flutter: sdk: flutter diff --git a/apps/design_system_gallery/test/app/theme_export_page_test.dart b/apps/design_system_gallery/test/app/theme_export_page_test.dart new file mode 100644 index 00000000..2cf656f2 --- /dev/null +++ b/apps/design_system_gallery/test/app/theme_export_page_test.dart @@ -0,0 +1,238 @@ +import 'package:design_system_gallery/app/theme_export_page.dart'; +import 'package:design_system_gallery/config/theme_configuration.dart'; +import 'package:design_system_gallery/widgets/theme_export/message_bubble_preview.dart'; +import 'package:design_system_gallery/widgets/theme_studio/color_picker_tile.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +/// The two [ColorPickerTile]s (light, then dark) for the row labeled [label]. +List _tilesFor(WidgetTester tester, String label) => tester + .widgetList(find.byWidgetPredicate((w) => w is ColorPickerTile && w.label == label)) + .toList(); + +void main() { + Future pumpAt(WidgetTester tester, ThemeConfiguration studio, Size size) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: studio, + child: const MaterialApp(home: ThemeExportPage()), + ), + ); + await tester.pumpAndSettle(); + } + + // Side-by-side layout only shows above _kTabsBreakpoint (1200); force a + // wide surface so both settings columns and the code pane are present. + Future pumpWide(WidgetTester tester, ThemeConfiguration studio) => + pumpAt(tester, studio, const Size(1400, 900)); + + testWidgets('renders the light/dark settings columns and the code pane', (tester) async { + final studio = ThemeConfiguration.light(); + addTearDown(studio.dispose); + + await pumpWide(tester, studio); + + expect(find.text('Export Theme'), findsOneWidget); + expect(find.text('brand'), findsNWidgets(2)); // one per column + expect(find.text('chrome'), findsNWidgets(2)); + expect(find.text('Dart code'), findsOneWidget); + // The message preview sits below the settings columns (light + dark), + // not inside the code pane. + expect(find.byType(MessageBubblePreview), findsNWidgets(2)); + expect(find.byType(SelectableText), findsOneWidget); + }); + + testWidgets('section headers render once per column, not as a single full-width bar', (tester) async { + final studio = ThemeConfiguration.light(); + addTearDown(studio.dispose); + + await pumpWide(tester, studio); + + expect(find.text('Accent Colors'), findsNWidgets(2)); + expect(find.text('Text Colors'), findsNWidgets(2)); + }); + + testWidgets('scrolling the code block does not throw (Scrollbar needs its own controller)', (tester) async { + final studio = ThemeConfiguration.light(); + addTearDown(studio.dispose); + + await pumpWide(tester, studio); + + await tester.drag(find.byType(SelectableText), const Offset(0, -100)); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + }); + + testWidgets('seeds both columns from the studio brand color', (tester) async { + final studio = ThemeConfiguration.light(); + addTearDown(studio.dispose); + studio.setBrandPrimaryColor(const Color(0xFF01D151)); + + await pumpWide(tester, studio); + + // Both columns should show the customized brand color, not "default". + final brandTiles = _tilesFor(tester, 'brand'); + expect(brandTiles, hasLength(2)); + expect(brandTiles.every((t) => !t.isDefault), isTrue); + + // dart_style may wrap this call across lines, so match loosely. + final code = tester.widget(find.byType(SelectableText)).data!; + expect(code, matches(RegExp(r'StreamColorSwatch\.fromColor\(\s*brand,\s*brightness:\s*Brightness\.light,?\s*\)'))); + expect(code, matches(RegExp(r'StreamColorSwatch\.fromColor\(\s*brand,\s*brightness:\s*Brightness\.dark,?\s*\)'))); + }); + + testWidgets('tapping a link toggle unlinks that row, shown as a link_off icon', (tester) async { + final studio = ThemeConfiguration.light(); + addTearDown(studio.dispose); + + await pumpWide(tester, studio); + + // brand, chrome and accent* start linked; most other slots don't (see + // ThemeExportConfiguration) - so both icons are already present before + // any tap. Tap the first link icon (brand's, since it's first in the + // list and one of the slots that starts linked) and check the delta. + final linkedCountBefore = tester.widgetList(find.byIcon(Icons.link)).length; + final unlinkedCountBefore = tester.widgetList(find.byIcon(Icons.link_off)).length; + expect(linkedCountBefore, greaterThan(0)); + + await tester.tap(find.byIcon(Icons.link).first); + await tester.pumpAndSettle(); + + expect(tester.widgetList(find.byIcon(Icons.link)).length, linkedCountBefore - 1); + expect(tester.widgetList(find.byIcon(Icons.link_off)).length, unlinkedCountBefore + 1); + }); + + testWidgets('Add component theme is offered on the export page, and adding one shows a row per column', ( + tester, + ) async { + final studio = ThemeConfiguration.light(); + addTearDown(studio.dispose); + + await pumpWide(tester, studio); + + // "Add component theme" is the last row, well below the ~51 color rows + // above it in this lazily-built list - scroll it into view first. + await tester.dragUntilVisible( + find.text('Add component theme'), + find.byType(CustomScrollView), + const Offset(0, -500), + ); + await tester.tap(find.text('Add component theme')); + await tester.pumpAndSettle(); + + // Scoped to the dialog, and asserting it's actually open first: an + // unscoped findsNothing would pass vacuously if the picker never opened. + expect(find.byType(SimpleDialog), findsOneWidget); + // Avatar isn't offered - its color story is the Avatar Palette section. + expect( + find.descendant(of: find.byType(SimpleDialog), matching: find.textContaining('Avatar')), + findsNothing, + ); + expect(find.textContaining('Online Indicator'), findsOneWidget); + + await tester.tap(find.textContaining('Online Indicator')); + await tester.pumpAndSettle(); + + // The new section was inserted just above "Add component theme" - + // ensure it's actually scrolled into view before asserting on it. + await tester.dragUntilVisible( + find.text('Remove component theme'), + find.byType(CustomScrollView), + const Offset(0, -300), + ); + + expect(find.text('Online Indicator'), findsNWidgets(2)); // one section header per column + expect(_tilesFor(tester, 'backgroundOnline'), hasLength(2)); + expect(find.text('Remove component theme'), findsOneWidget); + + // Removing it drops the section and re-offers it in the picker. + await tester.tap(find.text('Remove component theme')); + await tester.pumpAndSettle(); + + expect(find.text('Online Indicator'), findsNothing); + expect(_tilesFor(tester, 'backgroundOnline'), isEmpty); + }); + + testWidgets("editing an unlinked slot's light side leaves the dark side untouched", (tester) async { + final studio = ThemeConfiguration.light(); + addTearDown(studio.dispose); + + await pumpWide(tester, studio); + + final beforeTiles = _tilesFor(tester, 'brand'); + expect(beforeTiles, hasLength(2)); + expect(beforeTiles.every((t) => t.isDefault), isTrue); + + // Unlink brand's row (the first link toggle in the list). + await tester.tap(find.byIcon(Icons.link).first); + await tester.pumpAndSettle(); + + // Open the light column's brand picker and apply. + await tester.tap(find.text('brand').first); + await tester.pumpAndSettle(); + expect(find.text('Apply'), findsOneWidget); + await tester.tap(find.text('Apply')); + await tester.pumpAndSettle(); + + final afterTiles = _tilesFor(tester, 'brand'); + expect(afterTiles[0].isDefault, isFalse, reason: 'light side was edited'); + expect(afterTiles[1].isDefault, isTrue, reason: 'dark side is untouched while unlinked'); + }); + + testWidgets('a section header gives the title the space its subtitle chip does not need', (tester) async { + final studio = ThemeConfiguration.light(); + addTearDown(studio.dispose); + + await pumpWide(tester, studio); + + // Both were Flexible, which split the row evenly and ellipsized the + // title at ~half the width while the chip sat in unused space. The + // title should now be far wider than the chip it sits next to. + final titleWidth = tester.getSize(find.text('Accent Colors').first).width; + final chipWidth = tester.getSize(find.text('accent*').first).width; + + expect(titleWidth, greaterThan(chipWidth)); + // Not truncated: the full title is laid out, not clipped to a share. + expect(tester.takeException(), isNull); + }); + + testWidgets('below the side-by-side breakpoint, settings and code collapse into two tabs', (tester) async { + final studio = ThemeConfiguration.light(); + addTearDown(studio.dispose); + + // Below _kTabsBreakpoint (1200) - too narrow for fixed-width settings + // columns plus a code pane that's still wide enough to be useful. + await pumpAt(tester, studio, const Size(900, 900)); + + expect(find.byType(TabBar), findsOneWidget); + expect(find.text('Theme Settings'), findsOneWidget); + expect(find.text('Dart Code'), findsOneWidget); + + // The Theme Settings tab is active first - settings render, code doesn't. + expect(find.text('brand'), findsNWidgets(2)); + expect(find.text('Dart code'), findsNothing); + + await tester.tap(find.text('Dart Code')); + await tester.pumpAndSettle(); + + expect(find.text('Dart code'), findsOneWidget); + expect(find.text('brand'), findsNothing); + }); + + testWidgets('at or above the breakpoint, settings and code render side by side with no tabs', (tester) async { + final studio = ThemeConfiguration.light(); + addTearDown(studio.dispose); + + await pumpAt(tester, studio, const Size(1200, 900)); + + expect(find.byType(TabBar), findsNothing); + expect(find.text('brand'), findsNWidgets(2)); + expect(find.text('Dart code'), findsOneWidget); + }); +} diff --git a/apps/design_system_gallery/test/config/component_theme_test.dart b/apps/design_system_gallery/test/config/component_theme_test.dart new file mode 100644 index 00000000..560db515 --- /dev/null +++ b/apps/design_system_gallery/test/config/component_theme_test.dart @@ -0,0 +1,112 @@ +import 'package:design_system_gallery/config/component_theme_descriptors.dart'; +import 'package:design_system_gallery/config/theme_configuration.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('Avatar is excluded from the addable component themes list', () { + // Avatar's color story is the Avatar Palette section (rotating + // background/foreground pairs consumed by downstream packages like + // stream_chat_flutter), not a single fixed override like the other + // component themes - offering it here would duplicate/conflict with that. + expect(componentThemeDescriptors.map((d) => d.name), isNot(contains('Avatar'))); + }); + + group('ThemeConfiguration component theme overrides', () { + late ThemeConfiguration config; + + setUp(() => config = ThemeConfiguration.light()); + tearDown(() => config.dispose()); + + test('a component is inactive until added', () { + expect(config.activeComponentThemes, isEmpty); + expect(config.resolveComponentColor('Online Indicator', 'backgroundOnline'), isNull); + expect(config.isComponentColorCustom('Online Indicator', 'backgroundOnline'), isFalse); + }); + + test('addComponentTheme makes the component active with no colors set yet', () { + config.addComponentTheme('Online Indicator'); + + expect(config.activeComponentThemes, {'Online Indicator'}); + expect(config.isComponentColorCustom('Online Indicator', 'backgroundOnline'), isFalse); + // themeData shouldn't carry an override just from being "added" with + // nothing set - that would be indistinguishable from the SDK default. + expect(config.themeData.onlineIndicatorTheme.backgroundOnline, isNull); + }); + + test('setComponentColor customizes a property and flows into themeData', () { + config.addComponentTheme('Online Indicator'); + config.setComponentColor('Online Indicator', 'backgroundOnline', const Color(0xFF112233)); + + expect(config.resolveComponentColor('Online Indicator', 'backgroundOnline'), const Color(0xFF112233)); + expect(config.isComponentColorCustom('Online Indicator', 'backgroundOnline'), isTrue); + expect(config.themeData.onlineIndicatorTheme.backgroundOnline, const Color(0xFF112233)); + // The other property on the same component stays untouched. + expect(config.isComponentColorCustom('Online Indicator', 'backgroundOffline'), isFalse); + }); + + test('setComponentColor implicitly activates the component', () { + // Setting a color directly (without an explicit addComponentTheme call + // first) should still work and register the component as active. + config.setComponentColor('Badge Count', 'textColor', const Color(0xFF000000)); + + expect(config.activeComponentThemes, contains('Badge Count')); + }); + + test('resetComponentColor clears just that property', () { + config.addComponentTheme('Online Indicator'); + config.setComponentColor('Online Indicator', 'backgroundOnline', const Color(0xFF112233)); + config.setComponentColor('Online Indicator', 'backgroundOffline', const Color(0xFF445566)); + + config.resetComponentColor('Online Indicator', 'backgroundOnline'); + + expect(config.isComponentColorCustom('Online Indicator', 'backgroundOnline'), isFalse); + expect(config.isComponentColorCustom('Online Indicator', 'backgroundOffline'), isTrue); + expect(config.activeComponentThemes, contains('Online Indicator')); + }); + + test('removeComponentTheme drops the whole section', () { + config.addComponentTheme('Online Indicator'); + config.setComponentColor('Online Indicator', 'backgroundOnline', const Color(0xFF112233)); + + config.removeComponentTheme('Online Indicator'); + + expect(config.activeComponentThemes, isEmpty); + expect(config.themeData.onlineIndicatorTheme.backgroundOnline, isNull); + }); + + test('resetToDefaults clears all component overrides', () { + config.setComponentColor('Online Indicator', 'backgroundOnline', const Color(0xFF112233)); + config.setComponentColor('Badge Count', 'textColor', const Color(0xFF000000)); + + config.resetToDefaults(); + + expect(config.activeComponentThemes, isEmpty); + }); + + test('componentOverrides is a read-only snapshot', () { + config.setComponentColor('Online Indicator', 'backgroundOnline', const Color(0xFF112233)); + + final snapshot = config.componentOverrides; + expect(snapshot['Online Indicator']!['backgroundOnline'], const Color(0xFF112233)); + expect( + () => snapshot['Online Indicator']!['backgroundOffline'] = const Color(0xFF000000), + throwsUnsupportedError, + ); + }); + + test('ThemeConfiguration.seededFrom carries component overrides to a fresh instance', () { + config.setComponentColor('Online Indicator', 'backgroundOnline', const Color(0xFF112233)); + + final seeded = ThemeConfiguration.seededFrom(config, brightness: Brightness.dark); + addTearDown(seeded.dispose); + + expect(seeded.resolveComponentColor('Online Indicator', 'backgroundOnline'), const Color(0xFF112233)); + + // And it's a copy, not shared state: further edits on either side + // don't leak to the other. + seeded.setComponentColor('Online Indicator', 'backgroundOffline', const Color(0xFF999999)); + expect(config.isComponentColorCustom('Online Indicator', 'backgroundOffline'), isFalse); + }); + }); +} diff --git a/apps/design_system_gallery/test/config/theme_color_slot_test.dart b/apps/design_system_gallery/test/config/theme_color_slot_test.dart new file mode 100644 index 00000000..845ef495 --- /dev/null +++ b/apps/design_system_gallery/test/config/theme_color_slot_test.dart @@ -0,0 +1,151 @@ +import 'package:design_system_gallery/config/theme_color_slot.dart'; +import 'package:design_system_gallery/config/theme_studio_sections.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_core_flutter/core.dart'; + +void main() { + group('ThemeColorSlot', () { + test('covers exactly the Color? parameters of StreamColorScheme.light/.dark, in order', () { + // Pinned against StreamColorScheme.light's parameter list + // (packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart). + // + // If this fails because a color was added to the SDK: add a matching + // ThemeColorSlot value (and extend this list). If it fails for any other + // reason, a slot was renamed or dropped by mistake. + const expectedParameterNames = [ + 'accentPrimary', + 'accentSuccess', + 'accentWarning', + 'accentError', + 'accentNeutral', + 'textPrimary', + 'textSecondary', + 'textTertiary', + 'textDisabled', + 'textLink', + 'textOnAccent', + 'textOnInverse', + 'backgroundApp', + 'backgroundSurface', + 'backgroundSurfaceSubtle', + 'backgroundSurfaceStrong', + 'backgroundSurfaceCard', + 'backgroundOnAccent', + 'backgroundHighlight', + 'backgroundScrim', + 'backgroundOverlayLight', + 'backgroundOverlayDark', + 'backgroundDisabled', + 'backgroundInverse', + 'backgroundElevation0', + 'backgroundElevation1', + 'backgroundElevation2', + 'backgroundElevation3', + 'borderDefault', + 'borderSubtle', + 'borderStrong', + 'borderOnAccent', + 'borderOnInverse', + 'borderOnSurface', + 'borderOpacitySubtle', + 'borderOpacityStrong', + 'borderFocus', + 'borderDisabled', + 'borderDisabledOnSurface', + 'borderHover', + 'borderPressed', + 'borderActive', + 'borderError', + 'borderWarning', + 'borderSuccess', + 'borderSelected', + 'backgroundHover', + 'backgroundPressed', + 'backgroundSelected', + 'systemText', + 'systemScrollbar', + ]; + + expect(ThemeColorSlot.values.map((s) => s.parameterName).toList(), expectedParameterNames); + }); + + test('every parameter name is unique', () { + final names = ThemeColorSlot.values.map((s) => s.parameterName); + expect(names.toSet(), hasLength(names.length)); + }); + + test('each slot.read reads back the value passed to its matching StreamColorScheme parameter', () { + // Every parameter gets a distinct color so a swapped or duplicated reader is caught. + final values = { + for (final (index, slot) in ThemeColorSlot.values.indexed) slot.parameterName: Color(0xFF000000 | index), + }; + + final scheme = StreamColorScheme.light( + accentPrimary: values['accentPrimary'], + accentSuccess: values['accentSuccess'], + accentWarning: values['accentWarning'], + accentError: values['accentError'], + accentNeutral: values['accentNeutral'], + textPrimary: values['textPrimary'], + textSecondary: values['textSecondary'], + textTertiary: values['textTertiary'], + textDisabled: values['textDisabled'], + textLink: values['textLink'], + textOnAccent: values['textOnAccent'], + textOnInverse: values['textOnInverse'], + backgroundApp: values['backgroundApp'], + backgroundSurface: values['backgroundSurface'], + backgroundSurfaceSubtle: values['backgroundSurfaceSubtle'], + backgroundSurfaceStrong: values['backgroundSurfaceStrong'], + backgroundSurfaceCard: values['backgroundSurfaceCard'], + backgroundOnAccent: values['backgroundOnAccent'], + backgroundHighlight: values['backgroundHighlight'], + backgroundScrim: values['backgroundScrim'], + backgroundOverlayLight: values['backgroundOverlayLight'], + backgroundOverlayDark: values['backgroundOverlayDark'], + backgroundDisabled: values['backgroundDisabled'], + backgroundInverse: values['backgroundInverse'], + backgroundElevation0: values['backgroundElevation0'], + backgroundElevation1: values['backgroundElevation1'], + backgroundElevation2: values['backgroundElevation2'], + backgroundElevation3: values['backgroundElevation3'], + borderDefault: values['borderDefault'], + borderSubtle: values['borderSubtle'], + borderStrong: values['borderStrong'], + borderOnAccent: values['borderOnAccent'], + borderOnInverse: values['borderOnInverse'], + borderOnSurface: values['borderOnSurface'], + borderOpacitySubtle: values['borderOpacitySubtle'], + borderOpacityStrong: values['borderOpacityStrong'], + borderFocus: values['borderFocus'], + borderDisabled: values['borderDisabled'], + borderDisabledOnSurface: values['borderDisabledOnSurface'], + borderHover: values['borderHover'], + borderPressed: values['borderPressed'], + borderActive: values['borderActive'], + borderError: values['borderError'], + borderWarning: values['borderWarning'], + borderSuccess: values['borderSuccess'], + borderSelected: values['borderSelected'], + backgroundHover: values['backgroundHover'], + backgroundPressed: values['backgroundPressed'], + backgroundSelected: values['backgroundSelected'], + systemText: values['systemText'], + systemScrollbar: values['systemScrollbar'], + ); + + for (final slot in ThemeColorSlot.values) { + expect(slot.read(scheme), values[slot.parameterName], reason: 'slot ${slot.name}'); + } + }); + }); + + group('themeStudioSections', () { + test('covers every ThemeColorSlot exactly once', () { + final slotsInSections = themeStudioSections.expand((section) => section.slots).toList(); + expect(slotsInSections.toSet(), ThemeColorSlot.values.toSet()); + expect(slotsInSections, hasLength(ThemeColorSlot.values.length)); + }); + }); +} diff --git a/apps/design_system_gallery/test/config/theme_export_configuration_test.dart b/apps/design_system_gallery/test/config/theme_export_configuration_test.dart new file mode 100644 index 00000000..2ea3115c --- /dev/null +++ b/apps/design_system_gallery/test/config/theme_export_configuration_test.dart @@ -0,0 +1,195 @@ +import 'package:design_system_gallery/config/theme_color_slot.dart'; +import 'package:design_system_gallery/config/theme_configuration.dart'; +import 'package:design_system_gallery/config/theme_export_configuration.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('ThemeExportConfiguration', () { + late ThemeConfiguration studio; + + setUp(() { + studio = ThemeConfiguration.light(); + }); + + tearDown(() { + studio.dispose(); + }); + + test('seeds light and dark from the studio overrides and brand seed', () { + studio.setOverride(ThemeColorSlot.accentError, const Color(0xFF112233)); + studio.setBrandPrimaryColor(const Color(0xFF01D151)); + + final export = ThemeExportConfiguration(studio); + addTearDown(export.dispose); + + expect(export.light.resolve(ThemeColorSlot.accentError), const Color(0xFF112233)); + expect(export.dark.resolve(ThemeColorSlot.accentError), const Color(0xFF112233)); + expect(export.light.brandPrimaryColor, const Color(0xFF01D151)); + expect(export.dark.brandPrimaryColor, const Color(0xFF01D151)); + expect(export.light.brightness, Brightness.light); + expect(export.dark.brightness, Brightness.dark); + }); + + test('never writes back to the studio configuration it was seeded from', () { + final export = ThemeExportConfiguration(studio); + addTearDown(export.dispose); + + export.setColor(ThemeColorSlot.accentError, const Color(0xFF445566), from: Brightness.light); + + expect(studio.isCustom(ThemeColorSlot.accentError), isFalse); + }); + + test('an accent* slot starts linked: editing one side edits both', () { + final export = ThemeExportConfiguration(studio); + addTearDown(export.dispose); + + expect(export.isSlotLinked(ThemeColorSlot.accentError), isTrue); + + export.setColor(ThemeColorSlot.accentError, const Color(0xFF445566), from: Brightness.light); + + expect(export.light.resolve(ThemeColorSlot.accentError), const Color(0xFF445566)); + expect(export.dark.resolve(ThemeColorSlot.accentError), const Color(0xFF445566)); + }); + + test('a non-accent slot starts unlinked: editing one side leaves the other untouched', () { + // textPrimary (like most text*/background*/border*/system* slots) is + // typically inverted between light and dark, so linking it by default + // would mean the very first edit overwrites the other side with a + // value that's wrong for it. + final export = ThemeExportConfiguration(studio); + addTearDown(export.dispose); + + expect(export.isSlotLinked(ThemeColorSlot.textPrimary), isFalse); + + export.setColor(ThemeColorSlot.textPrimary, const Color(0xFF445566), from: Brightness.light); + + expect(export.light.resolve(ThemeColorSlot.textPrimary), const Color(0xFF445566)); + expect(export.dark.isCustom(ThemeColorSlot.textPrimary), isFalse); + }); + + test('brand and chrome seeds start linked', () { + final export = ThemeExportConfiguration(studio); + addTearDown(export.dispose); + + expect(export.isSeedLinked(ThemeSeedSlot.brand), isTrue); + expect(export.isSeedLinked(ThemeSeedSlot.chrome), isTrue); + }); + + test('a component color starts unlinked: editing one side leaves the other untouched', () { + final export = ThemeExportConfiguration(studio); + addTearDown(export.dispose); + export.addComponentTheme('Online Indicator'); + + expect(export.isComponentColorLinked('Online Indicator', 'backgroundOnline'), isFalse); + + export.setComponentColor( + 'Online Indicator', + 'backgroundOnline', + const Color(0xFF445566), + from: Brightness.light, + ); + + expect(export.light.resolveComponentColor('Online Indicator', 'backgroundOnline'), const Color(0xFF445566)); + expect(export.dark.isComponentColorCustom('Online Indicator', 'backgroundOnline'), isFalse); + }); + + test('unlinking a slot makes edits independent', () { + final export = ThemeExportConfiguration(studio); + addTearDown(export.dispose); + + export.toggleSlotLinked(ThemeColorSlot.accentError); + expect(export.isSlotLinked(ThemeColorSlot.accentError), isFalse); + + export.setColor(ThemeColorSlot.accentError, const Color(0xFF445566), from: Brightness.light); + export.setColor(ThemeColorSlot.accentError, const Color(0xFF667788), from: Brightness.dark); + + expect(export.light.resolve(ThemeColorSlot.accentError), const Color(0xFF445566)); + expect(export.dark.resolve(ThemeColorSlot.accentError), const Color(0xFF667788)); + }); + + test('relinking does not force light/dark back in sync until the next edit', () { + final export = ThemeExportConfiguration(studio); + addTearDown(export.dispose); + + export.toggleSlotLinked(ThemeColorSlot.accentError); + export.setColor(ThemeColorSlot.accentError, const Color(0xFF445566), from: Brightness.light); + export.setColor(ThemeColorSlot.accentError, const Color(0xFF667788), from: Brightness.dark); + + export.toggleSlotLinked(ThemeColorSlot.accentError); + expect(export.isSlotLinked(ThemeColorSlot.accentError), isTrue); + // Still diverged immediately after relinking. + expect(export.light.resolve(ThemeColorSlot.accentError), const Color(0xFF445566)); + expect(export.dark.resolve(ThemeColorSlot.accentError), const Color(0xFF667788)); + + // The next edit, from either side, applies to both again. + export.setColor(ThemeColorSlot.accentError, const Color(0xFF999999), from: Brightness.dark); + expect(export.light.resolve(ThemeColorSlot.accentError), const Color(0xFF999999)); + expect(export.dark.resolve(ThemeColorSlot.accentError), const Color(0xFF999999)); + }); + + test('resetColor respects link state the same way as setColor', () { + final export = ThemeExportConfiguration(studio); + addTearDown(export.dispose); + + export.toggleSlotLinked(ThemeColorSlot.accentError); + export.setColor(ThemeColorSlot.accentError, const Color(0xFF445566), from: Brightness.light); + export.setColor(ThemeColorSlot.accentError, const Color(0xFF667788), from: Brightness.dark); + + export.resetColor(ThemeColorSlot.accentError, from: Brightness.light); + + expect(export.light.isCustom(ThemeColorSlot.accentError), isFalse); + expect(export.dark.isCustom(ThemeColorSlot.accentError), isTrue); + }); + + test('brand/chrome seeds follow the same link semantics via setSeed/resetSeed', () { + final export = ThemeExportConfiguration(studio); + addTearDown(export.dispose); + + export.toggleSeedLinked(ThemeSeedSlot.brand); + export.setSeed(ThemeSeedSlot.brand, const Color(0xFF01D151), from: Brightness.light); + + expect(export.light.brandIsCustom, isTrue); + expect(export.dark.brandIsCustom, isFalse); + + export.resetSeed(ThemeSeedSlot.brand, from: Brightness.light); + expect(export.light.brandIsCustom, isFalse); + }); + + test('cached Material themes are stable across reads and invalidated on change', () { + final export = ThemeExportConfiguration(studio); + addTearDown(export.dispose); + + final first = export.lightMaterialTheme; + expect(identical(export.lightMaterialTheme, first), isTrue); + + export.setColor(ThemeColorSlot.accentError, const Color(0xFF445566), from: Brightness.light); + + expect(identical(export.lightMaterialTheme, first), isFalse); + }); + + test('generateCode reflects the current, possibly-diverged light/dark overrides', () { + final export = ThemeExportConfiguration(studio); + addTearDown(export.dispose); + + export.toggleSlotLinked(ThemeColorSlot.accentError); + export.setColor(ThemeColorSlot.accentError, const Color(0xFFF98C26), from: Brightness.light); + export.setColor(ThemeColorSlot.accentError, const Color(0xFFF6AB64), from: Brightness.dark); + + final code = export.generateCode(); + + expect(code, contains('const accentErrorLight = Color.fromARGB(255, 249, 140, 38);')); + expect(code, contains('const accentErrorDark = Color.fromARGB(255, 246, 171, 100);')); + }); + + test('dispose cleans up both child configurations', () { + final export = ThemeExportConfiguration(studio); + + export.dispose(); + + // Both children should be disposed too - further use throws. + expect(() => export.light.addListener(() {}), throwsFlutterError); + expect(() => export.dark.addListener(() {}), throwsFlutterError); + }); + }); +} diff --git a/apps/design_system_gallery/test/core/theme_code_generator_compiles_test.dart b/apps/design_system_gallery/test/core/theme_code_generator_compiles_test.dart new file mode 100644 index 00000000..738e1c04 --- /dev/null +++ b/apps/design_system_gallery/test/core/theme_code_generator_compiles_test.dart @@ -0,0 +1,234 @@ +@Timeout(Duration(minutes: 5)) +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:design_system_gallery/config/component_theme_descriptors.dart'; +import 'package:design_system_gallery/config/theme_color_slot.dart'; +import 'package:design_system_gallery/core/theme_code_generator.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_core_flutter/core.dart'; + +/// One named scenario: a light/dark pair of inputs, and the function name its +/// generated snippet is wrapped in inside the assembled Dart file. +class _Scenario { + const _Scenario(this.name, this.light, this.dark); + + final String name; + final ThemeExportSide light; + final ThemeExportSide dark; +} + +/// A deterministic, distinct color per index - the actual values are +/// irrelevant here, only that each emitted const gets a real Color literal. +Color _color(int index) => Color(0xFF000000 | (index * 0x00050307) & 0x00FFFFFF); + +/// Every [ThemeColorSlot] overridden on both sides, with a *different* color +/// per side. +/// +/// This is the scenario that earns this test its keep: it forces the +/// generator to emit all ~51 `parameterName` strings as named arguments to +/// both `StreamColorScheme.light()` and `.dark()`. Those names live in the +/// enum as plain strings, so nothing but a real type-check proves they're +/// actual parameters of the real API. +Map _allSlots({required int seed}) => { + for (final (index, slot) in ThemeColorSlot.values.indexed) slot: _color(seed + index), +}; + +/// Every [ComponentThemeDescriptor] with every one of its properties set - +/// type-checks each descriptor's `themeParameterName`, `themeDataTypeName` +/// and property names, which are likewise only strings until compiled. +Map> _allComponents({required int seed}) { + var index = seed; + return { + for (final descriptor in componentThemeDescriptors) + descriptor.name: {for (final property in descriptor.properties) property: _color(index++)}, + }; +} + +const _palette = [ + StreamAvatarColorPair(backgroundColor: Color(0xFFD6E4FF), foregroundColor: Color(0xFF1A2B5C)), + StreamAvatarColorPair(backgroundColor: Color(0xFFCCFADE), foregroundColor: Color(0xFF0B3B24)), +]; + +final _scenarios = <_Scenario>[ + // Nothing customized - bare StreamColorScheme.light()/.dark(). + const _Scenario('nothingCustomized', ThemeExportSide(), ThemeExportSide()), + + // Brand only, shared: chrome is derived from brand at neutral chroma. + const _Scenario( + 'brandOnlyShared', + ThemeExportSide(brandSeed: Color(0xFF01D151)), + ThemeExportSide(brandSeed: Color(0xFF01D151)), + ), + + // The shape from the feature's own docs: shared brand, chrome customized + // on dark only, one slot split between the two sides. + const _Scenario( + 'sharedBrandSplitAccent', + ThemeExportSide(brandSeed: Color(0xFF01D151), overrides: {ThemeColorSlot.accentError: Color(0xFFF98C26)}), + ThemeExportSide( + brandSeed: Color(0xFF01D151), + chromeSeed: Color(0xFFCCFADE), + overrides: {ThemeColorSlot.accentError: Color(0xFFF6AB64)}, + ), + ), + + // A slot customized on one side only. + const _Scenario( + 'lightOnlySlot', + ThemeExportSide(overrides: {ThemeColorSlot.backgroundApp: Color(0xFFFAFAFA)}), + ThemeExportSide(), + ), + + // Avatar palette, shared and split. + const _Scenario( + 'avatarPaletteShared', + ThemeExportSide(avatarPalette: _palette), + ThemeExportSide(avatarPalette: _palette), + ), + const _Scenario('avatarPaletteLightOnly', ThemeExportSide(avatarPalette: _palette), ThemeExportSide()), + + // Every color slot, split light/dark. + _Scenario( + 'everyColorSlotSplit', + ThemeExportSide(brandSeed: const Color(0xFF01D151), overrides: _allSlots(seed: 1)), + ThemeExportSide( + brandSeed: const Color(0xFF01D151), + chromeSeed: const Color(0xFFCCFADE), + overrides: _allSlots(seed: 500), + ), + ), + + // Every color slot, shared between sides (exercises the single-const path + // for all of them, not just the suffixed one). + _Scenario( + 'everyColorSlotShared', + ThemeExportSide(overrides: _allSlots(seed: 1)), + ThemeExportSide(overrides: _allSlots(seed: 1)), + ), + + // Every component theme property, shared and split. + _Scenario( + 'everyComponentThemeShared', + ThemeExportSide(componentOverrides: _allComponents(seed: 1)), + ThemeExportSide(componentOverrides: _allComponents(seed: 1)), + ), + _Scenario( + 'everyComponentThemeSplit', + ThemeExportSide(componentOverrides: _allComponents(seed: 1)), + ThemeExportSide(componentOverrides: _allComponents(seed: 900)), + ), + + // Everything at once. + _Scenario( + 'everythingAtOnce', + ThemeExportSide( + brandSeed: const Color(0xFF01D151), + chromeSeed: const Color(0xFFCCFADE), + overrides: _allSlots(seed: 1), + avatarPalette: _palette, + componentOverrides: _allComponents(seed: 1), + ), + ThemeExportSide( + brandSeed: const Color(0xFF0A7F3C), + overrides: _allSlots(seed: 500), + avatarPalette: _palette, + componentOverrides: _allComponents(seed: 900), + ), + ), +]; + +/// Assembles every scenario's snippet into a single compilable library. +/// +/// Each snippet goes in its own function body, so the `const` declarations +/// the generator emits are scoped per scenario and can't collide across +/// them. +String _buildSource() { + final buffer = StringBuffer() + ..writeln('// GENERATED by theme_code_generator_compiles_test.dart.') + ..writeln('// Transient: written, analyzed and deleted by that test.') + ..writeln('//') + // The generated snippet ends in a bare `MaterialApp(...);` - it shows a + // consumer where the themes plug in, and is deliberately a statement + // rather than something assigned or returned. + ..writeln('// ignore_for_file: unnecessary_statements') + ..writeln() + ..writeln("import 'package:flutter/material.dart';") + ..writeln("import 'package:stream_core_flutter/core.dart';") + ..writeln(); + + for (final scenario in _scenarios) { + buffer + ..writeln('void ${scenario.name}() {') + ..writeln(generateThemeCode(light: scenario.light, dark: scenario.dark)) + ..writeln('}') + ..writeln(); + } + + return buffer.toString(); +} + +/// The source with 1-based line numbers, so an analyzer diagnostic's +/// `line:col` can be read straight off a failure message. +String _numbered(String source) { + final lines = const LineSplitter().convert(source); + final width = lines.length.toString().length; + return [ + for (final (index, line) in lines.indexed) '${(index + 1).toString().padLeft(width)} | $line', + ].join('\n'); +} + +void main() { + test('every generated snippet type-checks against the real stream_core_flutter API', () async { + final source = _buildSource(); + + // Must live inside this package so `package:` imports resolve through + // its own .dart_tool/package_config.json. + final file = File('test/core/generated/export_snippets.dart'); + file.parent.createSync(recursive: true); + file.writeAsStringSync(source); + addTearDown(() { + if (file.parent.existsSync()) file.parent.deleteSync(recursive: true); + }); + + // Only compile errors matter here. Lints and warnings are the *snippet + // consumer's* style problem, not a correctness signal for this + // generator - and the repo's own strict lint set would flag plenty in + // deliberately exhaustive generated code. + final ProcessResult result; + try { + result = await Process.run('dart', [ + 'analyze', + '--no-fatal-warnings', + '--format=machine', + file.path, + ]); + } on ProcessException catch (error) { + fail('Could not run `dart analyze` - is the Dart SDK on PATH? ($error)'); + } + + // `dart analyze` exits 0 (clean), 1 (infos), 2 (warnings) or 3 (errors); + // anything else means it didn't get far enough to have an opinion, and + // an empty diagnostic list would then be a false pass. + expect( + result.exitCode, + isIn([0, 1, 2, 3]), + reason: '`dart analyze` failed to run.\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}', + ); + + final diagnostics = const LineSplitter().convert(result.stdout.toString()); + final errors = diagnostics.where((line) => line.startsWith('ERROR|')).toList(); + + expect( + errors, + isEmpty, + reason: + 'The generated snippet does not compile against the real API.\n' + 'Analyzer errors:\n${errors.join('\n')}\n\n' + 'Generated source:\n${_numbered(source)}', + ); + }); +} diff --git a/apps/design_system_gallery/test/core/theme_code_generator_test.dart b/apps/design_system_gallery/test/core/theme_code_generator_test.dart new file mode 100644 index 00000000..b66d5c5c --- /dev/null +++ b/apps/design_system_gallery/test/core/theme_code_generator_test.dart @@ -0,0 +1,334 @@ +import 'package:dart_style/dart_style.dart'; +import 'package:design_system_gallery/config/theme_color_slot.dart'; +import 'package:design_system_gallery/core/theme_code_generator.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_core_flutter/core.dart'; + +/// Matches a `name(arg1, arg2, ...)` call regardless of how `dart_style` +/// wraps it across lines or whether it adds a trailing comma. +Matcher _callMatching(String name, List args) { + final argsPattern = args.map(RegExp.escape).join(r',\s*'); + return matches(RegExp('$name\\(\\s*$argsPattern,?\\s*\\)')); +} + +void main() { + group('generateThemeCode', () { + test('nothing set emits bare StreamColorScheme calls and no consts', () { + final code = generateThemeCode(light: const ThemeExportSide(), dark: const ThemeExportSide()); + + expect(code, isNot(contains('const '))); + expect(code, contains('StreamColorScheme.light()')); + expect(code, contains('StreamColorScheme.dark()')); + expect(code, contains('Brightness.light')); + expect(code, contains('Brightness.dark')); + }); + + test('assigns each StreamTheme to a named variable, referenced (not inlined) in extensions', () { + final code = generateThemeCode(light: const ThemeExportSide(), dark: const ThemeExportSide()); + + expect(code, contains('final lightStreamTheme = StreamTheme(')); + expect(code, contains('final darkStreamTheme = StreamTheme(')); + // The lightStreamTheme/darkStreamTheme assignments come before + // MaterialApp, so the Stream-specific part can be copied on its own. + expect(code.indexOf('lightStreamTheme ='), lessThan(code.indexOf('MaterialApp('))); + expect(code.indexOf('darkStreamTheme ='), lessThan(code.indexOf('MaterialApp('))); + expect(code, contains('extensions: [lightStreamTheme]')); + expect(code, contains('extensions: [darkStreamTheme]')); + }); + + test('a slot set to the same color on both sides becomes a single shared const', () { + const color = Color(0xFFAABBCC); + final code = generateThemeCode( + light: const ThemeExportSide(overrides: {ThemeColorSlot.accentError: color}), + dark: const ThemeExportSide(overrides: {ThemeColorSlot.accentError: color}), + ); + + expect('const accentError ='.allMatches(code).length, 1); + expect(code, contains('accentError: accentError')); + expect(code, isNot(contains('accentErrorLight'))); + expect(code, isNot(contains('accentErrorDark'))); + }); + + test('a slot set to different colors on each side becomes two suffixed consts', () { + final code = generateThemeCode( + light: const ThemeExportSide(overrides: {ThemeColorSlot.accentError: Color(0xFFF98C26)}), + dark: const ThemeExportSide(overrides: {ThemeColorSlot.accentError: Color(0xFFF6AB64)}), + ); + + expect(code, contains('const accentErrorLight = Color.fromARGB(255, 249, 140, 38);')); + expect(code, contains('const accentErrorDark = Color.fromARGB(255, 246, 171, 100);')); + expect(code, contains('accentError: accentErrorLight')); + expect(code, contains('accentError: accentErrorDark')); + }); + + test('a slot set on only one side is emitted for that side only', () { + final code = generateThemeCode( + light: const ThemeExportSide(overrides: {ThemeColorSlot.borderFocus: Color(0xFF112233)}), + dark: const ThemeExportSide(), + ); + + expect(code, contains('const borderFocusLight = Color.fromARGB(255, 17, 34, 51);')); + expect(code, contains('borderFocus: borderFocusLight')); + expect(code, isNot(contains('borderFocusDark'))); + + final darkSchemeSpan = code.substring(code.indexOf('StreamColorScheme.dark(')); + expect(darkSchemeSpan, isNot(contains('borderFocus'))); + }); + + test('brand-only customization derives chrome from brand at neutral chroma, on both sides', () { + const brand = Color(0xFF01D151); + final code = generateThemeCode( + light: const ThemeExportSide(brandSeed: brand), + dark: const ThemeExportSide(brandSeed: brand), + ); + + expect('const brand ='.allMatches(code).length, 1); + expect(code, _callMatching('brand: StreamColorSwatch.fromColor', ['brand', 'brightness: Brightness.light'])); + expect(code, _callMatching('brand: StreamColorSwatch.fromColor', ['brand', 'brightness: Brightness.dark'])); + expect( + code, + _callMatching('chrome: StreamColorSwatch.fromColor', [ + 'brand', + 'brightness: Brightness.light', + 'chroma: StreamColorScheme.neutralChroma', + ]), + ); + expect( + code, + _callMatching('chrome: StreamColorSwatch.fromColor', [ + 'brand', + 'brightness: Brightness.dark', + 'chroma: StreamColorScheme.neutralChroma', + ]), + ); + }); + + test('brand shared + chrome customized only on dark reproduces the target shape', () { + const brand = Color(0xFF01D151); + const chromeDark = Color(0xFFCCFADE); + final code = generateThemeCode( + light: const ThemeExportSide(brandSeed: brand), + dark: const ThemeExportSide(brandSeed: brand, chromeSeed: chromeDark), + ); + + expect('const brand ='.allMatches(code).length, 1); + expect(code, contains('const chromeDark = Color.fromARGB(255, 204, 250, 222);')); + expect(code, isNot(contains('const chromeLight'))); + + final lightSchemeSpan = code.substring( + code.indexOf('StreamColorScheme.light('), + code.indexOf('StreamColorScheme.dark('), + ); + expect(lightSchemeSpan, contains('chroma: StreamColorScheme.neutralChroma')); + + final darkSchemeSpan = code.substring(code.indexOf('StreamColorScheme.dark(')); + expect( + darkSchemeSpan, + _callMatching('chrome: StreamColorSwatch.fromColor', ['chromeDark', 'brightness: Brightness.dark']), + ); + expect(darkSchemeSpan, isNot(contains('neutralChroma'))); + }); + + test('avatar palette is emitted only when customized, shared when equal on both sides', () { + const palette = [ + StreamAvatarColorPair(backgroundColor: Color(0xFFAAAAAA), foregroundColor: Color(0xFF111111)), + ]; + final withPalette = generateThemeCode( + light: const ThemeExportSide(avatarPalette: palette), + dark: const ThemeExportSide(avatarPalette: palette), + ); + expect(withPalette, contains('const avatarPalette = [')); + expect(withPalette, contains('avatarPalette: avatarPalette')); + + final withoutPalette = generateThemeCode(light: const ThemeExportSide(), dark: const ThemeExportSide()); + expect(withoutPalette, isNot(contains('avatarPalette'))); + }); + + test('avatar palettes that differ between sides are emitted separately', () { + const lightPalette = [ + StreamAvatarColorPair(backgroundColor: Color(0xFFAAAAAA), foregroundColor: Color(0xFF111111)), + ]; + const darkPalette = [ + StreamAvatarColorPair(backgroundColor: Color(0xFF222222), foregroundColor: Color(0xFFBBBBBB)), + ]; + final code = generateThemeCode( + light: const ThemeExportSide(avatarPalette: lightPalette), + dark: const ThemeExportSide(avatarPalette: darkPalette), + ); + + expect(code, contains('const avatarPaletteLight = [')); + expect(code, contains('const avatarPaletteDark = [')); + expect(code, contains('avatarPalette: avatarPaletteLight')); + expect(code, contains('avatarPalette: avatarPaletteDark')); + }); + + test('emitted const names are unique even when a suffixed name could collide with a real slot name', () { + // backgroundOverlayLight/backgroundOverlayDark are real slot names - + // suffixing another base with Light/Dark must not silently collide. + final code = generateThemeCode( + light: const ThemeExportSide( + overrides: { + ThemeColorSlot.backgroundOverlayLight: Color(0xFF000001), + ThemeColorSlot.backgroundOverlayDark: Color(0xFF000002), + }, + ), + dark: const ThemeExportSide( + overrides: { + ThemeColorSlot.backgroundOverlayLight: Color(0xFF000003), + ThemeColorSlot.backgroundOverlayDark: Color(0xFF000004), + }, + ), + ); + + final declaredNames = RegExp(r'const (\w+) =').allMatches(code).map((m) => m.group(1)).toList(); + expect(declaredNames.toSet(), hasLength(declaredNames.length)); + }); + + test('the generated snippet is syntactically valid Dart', () { + final code = generateThemeCode( + light: const ThemeExportSide( + brandSeed: Color(0xFF01D151), + overrides: {ThemeColorSlot.accentError: Color(0xFFF98C26)}, + ), + dark: const ThemeExportSide( + brandSeed: Color(0xFF01D151), + chromeSeed: Color(0xFFCCFADE), + overrides: {ThemeColorSlot.accentError: Color(0xFFF6AB64)}, + ), + ); + + // Wrap the same way the generator does internally: if this parses and + // formats without throwing, every emitted const and argument is + // syntactically well-formed Dart. + final wrapped = 'void f() {\n$code\n}\n'; + expect( + () => DartFormatter(languageVersion: DartFormatter.latestLanguageVersion).format(wrapped), + returnsNormally, + ); + }); + + test('a component theme override is emitted as a named arg on StreamTheme, not StreamColorScheme', () { + final code = generateThemeCode( + light: const ThemeExportSide( + componentOverrides: { + 'Online Indicator': {'backgroundOnline': Color(0xFF112233)}, + }, + ), + dark: const ThemeExportSide( + componentOverrides: { + 'Online Indicator': {'backgroundOnline': Color(0xFF112233)}, + }, + ), + ); + + expect(code, contains('const onlineIndicatorBackgroundOnline = Color.fromARGB(255, 17, 34, 51);')); + expect( + code, + _callMatching('onlineIndicatorTheme: StreamOnlineIndicatorThemeData', [ + 'backgroundOnline: onlineIndicatorBackgroundOnline', + ]), + ); + // Not nested inside StreamColorScheme - it's a sibling of colorScheme: + // on StreamTheme(...). + expect(code, isNot(contains('StreamColorScheme.light(onlineIndicatorTheme'))); + }); + + test('only overridden properties of a component are passed to its constructor', () { + final code = generateThemeCode( + light: const ThemeExportSide( + componentOverrides: { + 'Online Indicator': {'backgroundOnline': Color(0xFF112233)}, + }, + ), + dark: const ThemeExportSide(), + ); + + final lightSchemeSpan = code.substring(0, code.indexOf('darkStreamTheme')); + expect( + lightSchemeSpan, + _callMatching('onlineIndicatorTheme: StreamOnlineIndicatorThemeData', [ + 'backgroundOnline: onlineIndicatorBackgroundOnlineLight', + ]), + ); + expect(lightSchemeSpan, isNot(contains('backgroundOffline'))); + // Dark side has no override at all - no onlineIndicatorTheme arg there. + final darkSchemeSpan = code.substring(code.indexOf('darkStreamTheme')); + expect(darkSchemeSpan, isNot(contains('onlineIndicatorTheme'))); + }); + + test('a component property set to different colors per side becomes two suffixed consts', () { + // Component property colors start unlinked on the export page, so + // diverging light/dark values are the common case, not an edge one. + final code = generateThemeCode( + light: const ThemeExportSide( + componentOverrides: { + 'Online Indicator': {'backgroundOnline': Color(0xFF112233)}, + }, + ), + dark: const ThemeExportSide( + componentOverrides: { + 'Online Indicator': {'backgroundOnline': Color(0xFF445566)}, + }, + ), + ); + + expect(code, contains('const onlineIndicatorBackgroundOnlineLight = Color.fromARGB(255, 17, 34, 51);')); + expect(code, contains('const onlineIndicatorBackgroundOnlineDark = Color.fromARGB(255, 68, 85, 102);')); + // No shared const, since the two sides disagree. + expect(code, isNot(contains('const onlineIndicatorBackgroundOnline ='))); + + // Each side references its own const. + final lightSpan = code.substring(0, code.indexOf('darkStreamTheme')); + final darkSpan = code.substring(code.indexOf('darkStreamTheme')); + expect( + lightSpan, + _callMatching('onlineIndicatorTheme: StreamOnlineIndicatorThemeData', [ + 'backgroundOnline: onlineIndicatorBackgroundOnlineLight', + ]), + ); + expect( + darkSpan, + _callMatching('onlineIndicatorTheme: StreamOnlineIndicatorThemeData', [ + 'backgroundOnline: onlineIndicatorBackgroundOnlineDark', + ]), + ); + }); + + test('a component with no overridden properties is omitted entirely', () { + final code = generateThemeCode(light: const ThemeExportSide(), dark: const ThemeExportSide()); + + expect(code, isNot(contains('onlineIndicatorTheme'))); + expect(code, isNot(contains('StreamOnlineIndicatorThemeData'))); + }); + + test('multiple components with several properties each are all emitted', () { + final code = generateThemeCode( + light: const ThemeExportSide( + componentOverrides: { + 'Online Indicator': {'backgroundOnline': Color(0xFF111111), 'backgroundOffline': Color(0xFF222222)}, + 'Badge Count': {'textColor': Color(0xFF333333)}, + }, + ), + dark: const ThemeExportSide( + componentOverrides: { + 'Online Indicator': {'backgroundOnline': Color(0xFF111111), 'backgroundOffline': Color(0xFF222222)}, + 'Badge Count': {'textColor': Color(0xFF333333)}, + }, + ), + ); + + expect( + code, + _callMatching('onlineIndicatorTheme: StreamOnlineIndicatorThemeData', [ + 'backgroundOnline: onlineIndicatorBackgroundOnline', + 'backgroundOffline: onlineIndicatorBackgroundOffline', + ]), + ); + expect( + code, + _callMatching('badgeCountTheme: StreamBadgeCountThemeData', ['textColor: badgeCountTextColor']), + ); + }); + }); +} diff --git a/apps/design_system_gallery/test/widgets/theme_studio/color_picker_tile_test.dart b/apps/design_system_gallery/test/widgets/theme_studio/color_picker_tile_test.dart new file mode 100644 index 00000000..e5a25795 --- /dev/null +++ b/apps/design_system_gallery/test/widgets/theme_studio/color_picker_tile_test.dart @@ -0,0 +1,233 @@ +import 'package:design_system_gallery/widgets/theme_studio/color_picker_tile.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_core_flutter/core.dart'; + +void main() { + Future pumpAt(WidgetTester tester, Widget child, double width) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(extensions: [StreamTheme.light()]), + home: Scaffold( + body: SizedBox(width: width, child: child), + ), + ), + ); + } + + Future pump(WidgetTester tester, Widget child) => pumpAt(tester, child, 400); + + testWidgets('a null color renders a neutral placeholder and the text "default", not a hex code', (tester) async { + await pump( + tester, + ColorPickerTile(label: 'backgroundOnline', color: null, isDefault: true, onColorChanged: (_) {}), + ); + + expect(find.text('default'), findsOneWidget); + expect(find.byIcon(Icons.help_outline), findsOneWidget); + expect(find.textContaining('#'), findsNothing); + }); + + testWidgets('a real, customized color renders its hex value, with the "default" caption hidden', (tester) async { + await pump( + tester, + ColorPickerTile( + label: 'accentError', + color: const Color(0xFFAABBCC), + onColorChanged: (_) {}, + ), + ); + + expect(find.text('#AABBCC'), findsOneWidget); + // The "default" caption is always laid out (so a default tile and a + // customized tile for the same slot are the same height), but it's + // transparent - not visible - when the color isn't the default. + final defaultCaption = tester.widget(find.text('default')); + expect(defaultCaption.style?.color, StreamColors.transparent); + }); + + testWidgets('a default tile and a customized tile of the same slot are the same height', (tester) async { + final defaultKey = GlobalKey(); + final customKey = GlobalKey(); + + await pump( + tester, + Column( + children: [ + ColorPickerTile( + key: defaultKey, + label: 'accentError', + color: const Color(0xFFAABBCC), + isDefault: true, + onColorChanged: (_) {}, + ), + ColorPickerTile( + key: customKey, + label: 'accentError', + color: const Color(0xFF112233), + onColorChanged: (_) {}, + ), + ], + ), + ); + + expect(tester.getSize(find.byKey(defaultKey)).height, tester.getSize(find.byKey(customKey)).height); + }); + + testWidgets('a long label is ellipsized rather than overflowing the row', (tester) async { + // This is the exact failure mode the fix addresses: a long component + // property name combined with the "default" badge used to overflow the + // row (RenderFlex overflowed), not merely wrap awkwardly. + await pump( + tester, + ColorPickerTile( + label: 'aVeryLongComponentThemePropertyNameThatWouldNotFit', + color: null, + isDefault: true, + onColorChanged: (_) {}, + ), + ); + + expect(tester.takeException(), isNull); + final text = tester.widget(find.text('aVeryLongComponentThemePropertyNameThatWouldNotFit')); + expect(text.maxLines, 1); + expect(text.overflow, TextOverflow.ellipsis); + }); + + testWidgets('compact: false - the hex value sits beside the label/default column, not below it', (tester) async { + await pumpAt( + tester, + ColorPickerTile(label: 'accentError', color: const Color(0xFFAABBCC), onColorChanged: (_) {}), + 300, + ); + + // The "default" caption is the second (bottom) line of the label + // column - in the inline layout the hex value is a sibling of that + // whole column, vertically centered against it, so it sits above the + // caption's own line. In the stacked layout, checked below, hex is a + // third line *underneath* the caption instead. + final defaultCaptionTop = tester.getTopLeft(find.text('default')).dy; + final hexTop = tester.getTopLeft(find.text('#AABBCC')).dy; + + expect(hexTop, lessThan(defaultCaptionTop)); + expect(tester.takeException(), isNull); + }); + + testWidgets('compact: true - the hex value moves below the label instead of squeezing beside it', (tester) async { + await pumpAt( + tester, + ColorPickerTile(label: 'accentError', color: const Color(0xFFAABBCC), onColorChanged: (_) {}, compact: true), + 180, + ); + + final defaultCaptionTop = tester.getTopLeft(find.text('default')).dy; + final hexTop = tester.getTopLeft(find.text('#AABBCC')).dy; + + expect(hexTop, greaterThan(defaultCaptionTop)); + expect(tester.takeException(), isNull); + }); + + testWidgets('compact: true - the hex value never overflows, even with a reset icon at an extreme width', ( + tester, + ) async { + // The stacked row (hex + reset + edit icons) can itself run out of room + // at extreme widths - this is what forced the hex Text into a Flexible. + await pumpAt( + tester, + ColorPickerTile( + label: 'accentError', + color: const Color(0xFFAABBCC), + onColorChanged: (_) {}, + onReset: () {}, + compact: true, + ), + 180, + ); + + expect(find.byIcon(Icons.restart_alt), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('compact: true - a default tile and a customized tile of the same slot are still the same height', ( + tester, + ) async { + final defaultKey = GlobalKey(); + final customKey = GlobalKey(); + + await pumpAt( + tester, + Column( + children: [ + ColorPickerTile( + key: defaultKey, + label: 'accentError', + color: const Color(0xFFAABBCC), + isDefault: true, + onColorChanged: (_) {}, + compact: true, + ), + ColorPickerTile( + key: customKey, + label: 'accentError', + color: const Color(0xFF112233), + onColorChanged: (_) {}, + compact: true, + ), + ], + ), + 180, + ); + + expect(tester.getSize(find.byKey(defaultKey)).height, tester.getSize(find.byKey(customKey)).height); + }); + + // The tile is one tappable InkWell, so its rows merge into a single + // semantics node whose label concatenates them (e.g. + // "accentError\ndefault\n#AABBCC"). Hence a RegExp: matching by exact + // String would never hit an individual row's text. + testWidgets('a customized tile does not announce "default" to a screen reader', (tester) async { + // Disposed inline rather than via addTearDown: the binding's + // "SemanticsHandle was active at the end of the test" check runs before + // addTearDown callbacks do. + final handle = tester.ensureSemantics(); + + await pump( + tester, + ColorPickerTile(label: 'accentError', color: const Color(0xFFAABBCC), onColorChanged: (_) {}), + ); + + // The caption stays in the widget tree (it reserves height so default + // and customized tiles align), but must be out of the semantics tree. + expect(find.text('default'), findsOneWidget); + expect(find.bySemanticsLabel(RegExp('default')), findsNothing); + // The rest of the tile is still announced. + expect(find.bySemanticsLabel(RegExp('accentError')), findsOneWidget); + + handle.dispose(); + }); + + testWidgets('a default tile does announce "default"', (tester) async { + final handle = tester.ensureSemantics(); + + await pump( + tester, + ColorPickerTile(label: 'accentError', color: const Color(0xFFAABBCC), isDefault: true, onColorChanged: (_) {}), + ); + + expect(find.bySemanticsLabel(RegExp('default')), findsOneWidget); + + handle.dispose(); + }); + + group('isColorPickerTileCompact', () { + const spacing = StreamSpacing(); + + test('a wide outer width does not need the stacked layout', () { + expect(isColorPickerTileCompact(350, spacing), isFalse); + }); + + test('a narrow outer width needs the stacked layout', () { + expect(isColorPickerTileCompact(200, spacing), isTrue); + }); + }); +}