From 514393f24e3a2e1a0e439fc88247e3e87ce0c165 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 21 Jan 2026 15:57:53 +0100 Subject: [PATCH 1/5] feat: Introduce Stream Design System foundations and gallery app This commit introduces the foundational elements of the new Stream Design System and a comprehensive gallery application (Widgetbook) for showcasing and customizing components. **Core Package (`stream_core_flutter`):** * **Design Tokens**: Added foundational design tokens for colors, spacing, radius, typography, and box shadows. These tokens are organized into primitive and semantic layers. * `StreamColors`: Defines the primitive color palette. * `StreamColorScheme`: Provides semantic color roles (e.g., `accentPrimary`, `textSecondary`). * `StreamTypography`, `StreamTextTheme`: Defines font primitives and semantic text styles. * `StreamSpacing`, `StreamRadius`, `StreamBoxShadow`: Adds tokens for layout and elevation. * **`StreamTheme`**: Implemented a new `ThemeExtension` to aggregate all design tokens and component themes, enabling centralized theming. * **New Components**: Added `StreamAvatar`, `StreamAvatarStack`, and `StreamOnlineIndicator`. * **Theming Infrastructure**: Added `theme_extensions_builder` to auto-generate `copyWith`, `lerp`, and `==`/`hashCode` for theme classes. **Design System Gallery (`design_system_gallery`):** * **Gallery App**: Replaced the basic Widgetbook setup with a new, fully-featured gallery application (`StreamDesignSystemGallery`). * **Theme Studio**: Added a "Theme Studio" panel for real-time customization of all `StreamColorScheme` properties. * **Enhanced Previews**: * Added a toolbar with controls for device selection (`DeviceFrame`), text scaling, and light/dark mode toggles. * Created a `PreviewWrapper` to apply theme, device frame, and text scale to all use cases. * **New Use Cases**: * Added comprehensive use cases for new components (`StreamAvatar`, `StreamAvatarStack`, `StreamOnlineIndicator`). * Added showcases for semantic tokens (`Typography`, `Elevations`). * Improved `StreamButton` use cases with more variants and real-world examples. * **Agent Guide**: Added `AGENTS.md`, a guide for AI agents on contributing to the gallery. --- analysis_options.yaml | 3 +- apps/design_system_gallery/AGENTS.md | 243 ++++++ .../lib/app/gallery_app.dart | 61 ++ .../lib/app/gallery_app.directories.g.dart | 176 +++++ .../lib/app/gallery_shell.dart | 74 ++ .../lib/components/button.dart | 397 +++++++++- .../lib/components/stream_avatar.dart | 309 ++++++++ .../lib/components/stream_avatar_stack.dart | 224 ++++++ .../components/stream_online_indicator.dart | 285 +++++++ .../lib/config/config.dart | 7 + .../lib/config/preview_configuration.dart | 62 ++ .../lib/config/theme_configuration.dart | 572 +++++++++++++++ apps/design_system_gallery/lib/core/core.dart | 6 + .../lib/core/preview_wrapper.dart | 85 +++ apps/design_system_gallery/lib/main.dart | 35 +- .../lib/main.directories.g.dart | 44 -- .../lib/semantics/elevations.dart | 572 +++++++++++++++ .../lib/semantics/typography.dart | 446 +++++++++++ .../lib/theme_config.dart | 74 -- .../theme_studio/avatar_palette_section.dart | 276 +++++++ .../theme_studio/color_picker_tile.dart | 126 ++++ .../lib/widgets/theme_studio/mode_button.dart | 65 ++ .../widgets/theme_studio/section_card.dart | 75 ++ .../theme_customization_panel.dart | 680 +++++++++++++++++ .../theme_studio/theme_studio_widgets.dart | 10 + .../lib/widgets/toolbar/device_selector.dart | 75 ++ .../widgets/toolbar/text_scale_selector.dart | 73 ++ .../widgets/toolbar/theme_mode_toggle.dart | 92 +++ .../lib/widgets/toolbar/toolbar.dart | 190 +++++ .../lib/widgets/toolbar/toolbar_button.dart | 53 ++ .../lib/widgets/toolbar/toolbar_widgets.dart | 10 + .../macos/Runner/DebugProfile.entitlements | 2 + .../macos/Runner/Release.entitlements | 2 + apps/design_system_gallery/pubspec.yaml | 5 +- melos.yaml | 4 + .../lib/src/components.dart | 5 +- .../src/components/avatar/stream_avatar.dart | 226 ++++++ .../avatar/stream_avatar_stack.dart | 184 +++++ .../indicator/stream_online_indicator.dart | 147 ++++ .../src/{theme => factory}/components.dart | 0 .../components/stream_button.dart | 5 +- .../components/stream_button_theme.dart | 14 + .../stream_component_factory.dart | 4 +- .../lib/src/factory/stream_theme.dart | 43 ++ .../stream_core_flutter/lib/src/theme.dart | 14 + .../theme/components/stream_avatar_theme.dart | 159 ++++ .../stream_avatar_theme.g.theme.dart | 106 +++ .../stream_online_indicator_theme.dart | 124 ++++ ...stream_online_indicator_theme.g.theme.dart | 104 +++ .../src/theme/primitives/stream_colors.dart | 261 +++++++ .../src/theme/primitives/stream_radius.dart | 159 ++++ .../primitives/stream_radius.g.theme.dart | 144 ++++ .../src/theme/primitives/stream_spacing.dart | 93 +++ .../primitives/stream_spacing.g.theme.dart | 132 ++++ .../theme/primitives/stream_typography.dart | 263 +++++++ .../primitives/stream_typography.g.theme.dart | 393 ++++++++++ .../theme/semantics/stream_box_shadow.dart | 222 ++++++ .../semantics/stream_box_shadow.g.theme.dart | 106 +++ .../theme/semantics/stream_color_scheme.dart | 693 ++++++++++++++++++ .../stream_color_scheme.g.theme.dart | 342 +++++++++ .../theme/semantics/stream_text_theme.dart | 568 ++++++++++++++ .../semantics/stream_text_theme.g.theme.dart | 210 ++++++ .../lib/src/theme/stream_theme.dart | 223 +++++- .../lib/src/theme/stream_theme.g.theme.dart | 123 ++++ .../src/theme/stream_theme_extensions.dart | 64 ++ .../lib/stream_core_flutter.dart | 3 +- packages/stream_core_flutter/pubspec.yaml | 42 +- 67 files changed, 10341 insertions(+), 248 deletions(-) create mode 100644 apps/design_system_gallery/AGENTS.md create mode 100644 apps/design_system_gallery/lib/app/gallery_app.dart create mode 100644 apps/design_system_gallery/lib/app/gallery_app.directories.g.dart create mode 100644 apps/design_system_gallery/lib/app/gallery_shell.dart create mode 100644 apps/design_system_gallery/lib/components/stream_avatar.dart create mode 100644 apps/design_system_gallery/lib/components/stream_avatar_stack.dart create mode 100644 apps/design_system_gallery/lib/components/stream_online_indicator.dart create mode 100644 apps/design_system_gallery/lib/config/config.dart create mode 100644 apps/design_system_gallery/lib/config/preview_configuration.dart create mode 100644 apps/design_system_gallery/lib/config/theme_configuration.dart create mode 100644 apps/design_system_gallery/lib/core/core.dart create mode 100644 apps/design_system_gallery/lib/core/preview_wrapper.dart delete mode 100644 apps/design_system_gallery/lib/main.directories.g.dart create mode 100644 apps/design_system_gallery/lib/semantics/elevations.dart create mode 100644 apps/design_system_gallery/lib/semantics/typography.dart delete mode 100644 apps/design_system_gallery/lib/theme_config.dart create mode 100644 apps/design_system_gallery/lib/widgets/theme_studio/avatar_palette_section.dart create mode 100644 apps/design_system_gallery/lib/widgets/theme_studio/color_picker_tile.dart create mode 100644 apps/design_system_gallery/lib/widgets/theme_studio/mode_button.dart create mode 100644 apps/design_system_gallery/lib/widgets/theme_studio/section_card.dart create mode 100644 apps/design_system_gallery/lib/widgets/theme_studio/theme_customization_panel.dart create mode 100644 apps/design_system_gallery/lib/widgets/theme_studio/theme_studio_widgets.dart create mode 100644 apps/design_system_gallery/lib/widgets/toolbar/device_selector.dart create mode 100644 apps/design_system_gallery/lib/widgets/toolbar/text_scale_selector.dart create mode 100644 apps/design_system_gallery/lib/widgets/toolbar/theme_mode_toggle.dart create mode 100644 apps/design_system_gallery/lib/widgets/toolbar/toolbar.dart create mode 100644 apps/design_system_gallery/lib/widgets/toolbar/toolbar_button.dart create mode 100644 apps/design_system_gallery/lib/widgets/toolbar/toolbar_widgets.dart create mode 100644 packages/stream_core_flutter/lib/src/components/avatar/stream_avatar.dart create mode 100644 packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_stack.dart create mode 100644 packages/stream_core_flutter/lib/src/components/indicator/stream_online_indicator.dart rename packages/stream_core_flutter/lib/src/{theme => factory}/components.dart (100%) rename packages/stream_core_flutter/lib/src/{ => factory}/components/stream_button.dart (96%) create mode 100644 packages/stream_core_flutter/lib/src/factory/components/stream_button_theme.dart rename packages/stream_core_flutter/lib/src/{theme => factory}/stream_component_factory.dart (75%) create mode 100644 packages/stream_core_flutter/lib/src/factory/stream_theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_avatar_theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_avatar_theme.g.theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_online_indicator_theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_online_indicator_theme.g.theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/primitives/stream_colors.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/primitives/stream_radius.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/primitives/stream_radius.g.theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/primitives/stream_spacing.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/primitives/stream_spacing.g.theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/primitives/stream_typography.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/primitives/stream_typography.g.theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/semantics/stream_box_shadow.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/semantics/stream_box_shadow.g.theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.g.theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/semantics/stream_text_theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/semantics/stream_text_theme.g.theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/stream_theme.g.theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/stream_theme_extensions.dart diff --git a/analysis_options.yaml b/analysis_options.yaml index df6701f..09b7cd4 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -15,7 +15,7 @@ analyzer: - packages/*/lib/**/*.*.dart formatter: - page_width: 100 + page_width: 120 trailing_commas: preserve linter: @@ -23,6 +23,7 @@ linter: ## Disabled rules because the repository doesn't respect them (yet) avoid_setters_without_getters: false discarded_futures: false + comment_references: false ############# diff --git a/apps/design_system_gallery/AGENTS.md b/apps/design_system_gallery/AGENTS.md new file mode 100644 index 0000000..ea83a67 --- /dev/null +++ b/apps/design_system_gallery/AGENTS.md @@ -0,0 +1,243 @@ +# Design System Gallery - Agent Guide + +This document provides guidance for AI agents working on the Stream Design System Gallery (Widgetbook). + +## Overview + +The gallery showcases Stream's design system components and foundation tokens. It uses: +- **Widgetbook** for component documentation +- **Provider** for state management +- **device_frame_plus** for device previews + +## Project Structure + +``` +apps/design_system_gallery/ +├── lib/ +│ ├── app/ +│ │ ├── gallery_app.dart # Entry point with ChangeNotifierProvider setup +│ │ ├── gallery_app.directories.g.dart # Generated widgetbook directories (DO NOT EDIT) +│ │ └── gallery_shell.dart # Main shell with toolbar and layout +│ ├── components/ # Component use cases +│ │ ├── button.dart +│ │ ├── stream_avatar.dart +│ │ ├── stream_avatar_stack.dart +│ │ └── stream_online_indicator.dart +│ ├── semantics/ # Semantic token showcases +│ │ ├── typography.dart # StreamTextTheme showcase +│ │ └── elevations.dart # StreamBoxShadow showcase +│ ├── config/ +│ │ ├── theme_configuration.dart # Theme state (colors, brightness, etc.) +│ │ └── preview_configuration.dart # Preview state (device, text scale) +│ ├── core/ +│ │ └── preview_wrapper.dart # Wraps use cases with theme/device frame +│ └── widgets/ +│ ├── toolbar/ # Top toolbar widgets +│ └── theme_studio/ # Theme customization panel widgets +``` + +## Adding New Components + +### 1. Create Use Case File + +Create a new file in `lib/components/`: + +```dart +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; + +@widgetbook.UseCase( + name: 'Playground', + type: MyComponent, + path: '[Components]/MyComponent', // Category in brackets, then folder +) +Widget buildMyComponentPlayground(BuildContext context) { + // Use knobs for interactive controls + final label = context.knobs.string( + label: 'Label', + initialValue: 'Default', + description: 'Description for this knob.', + ); + + // Access theme + final streamTheme = StreamTheme.of(context); + final colorScheme = streamTheme.colorScheme; + + return MyComponent(label: label); +} +``` + +### 2. Use Case Naming Convention + +Each component should have these use cases (in order): +1. **Playground** - Interactive with knobs +2. **Type/Size Variants** - Shows all variants side by side +3. **Real-world Example** - Contextual usage + +### 3. Regenerate Directories + +After adding/modifying use cases: + +```bash +cd apps/design_system_gallery +dart run build_runner build --delete-conflicting-outputs +dart format lib/ +flutter analyze +``` + +## Adding Semantic Token Showcases + +Semantic tokens (like `StreamTextTheme`, `StreamBoxShadow`) are showcased in `lib/semantics/`: + +1. Create a new file in `lib/semantics/` +2. Use path `[App Foundation]/TokenName` in the `@UseCase` annotation +3. Follow the same regeneration process as components + +**Example:** +```dart +@widgetbook.UseCase( + name: 'All Styles', + type: StreamTextTheme, + path: '[App Foundation]/Typography', +) +Widget buildStreamTextThemeShowcase(BuildContext context) { + // Showcase implementation +} +``` + +## Category Ordering + +The widgetbook generator sorts categories **alphabetically**. To control order, use prefixes: + +- `[App Foundation]` - Sorts first (for tokens/foundations) +- `[Components]` - Sorts second + +**Current structure:** +``` +├── App Foundation (typography, elevations) +└── Components (avatar, button, etc.) +``` + +## Theme Configuration + +### Accessing Theme in Use Cases + +```dart +final streamTheme = StreamTheme.of(context); +final colorScheme = streamTheme.colorScheme; +final textTheme = streamTheme.textTheme; +final boxShadow = streamTheme.boxShadow; +``` + +### 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` + +## Knobs Best Practices + +### Do's +- Always add `description` parameter to knobs +- Use `context.knobs.object.dropdown` for enums +- Remove knobs for properties controlled by Theme Studio + +### Don'ts +- Don't add knobs that duplicate Theme Studio controls +- Don't use deprecated `context.knobs.list` (use `object.dropdown`) + +## Preview Wrapper + +The `PreviewWrapper` applies: +- StreamTheme as a Material theme extension +- Device frame (optional) +- Text scale + +**Important:** StreamTheme is provided via `ThemeData.extensions` so `StreamTheme.of(context)` works correctly. + +## Color Picker Usage + +When using `flutter_colorpicker`, note that it may not rebuild correctly with `StatefulBuilder`. The current implementation uses a simple local variable approach: + +```dart +var pickerColor = initialColor; +// Don't wrap in StatefulBuilder - let ColorPicker manage its own state +ColorPicker( + pickerColor: pickerColor, + onColorChanged: (c) => pickerColor = c, +) +``` + +## Styling Guidelines + +### Use StreamTheme tokens +```dart +// Good +style: textTheme.captionDefault.copyWith(color: colorScheme.textSecondary) + +// Bad - hardcoded values +style: TextStyle(fontSize: 12, color: Colors.grey) +``` + +### Use StreamBoxShadow +```dart +// Good +boxShadow: streamTheme.boxShadow.elevation2 + +// Bad - custom shadows +boxShadow: [BoxShadow(blurRadius: 10, ...)] +``` + +### Border handling +Use `foregroundDecoration` for borders to prevent clipping: +```dart +Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colorScheme.backgroundSurface, + borderRadius: BorderRadius.circular(12), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colorScheme.borderSurfaceSubtle), + ), + child: ..., +) +``` + +## Common Commands + +```bash +# Regenerate widgetbook directories +dart run build_runner build --delete-conflicting-outputs + +# Format code +dart format lib/ + +# Analyze +flutter analyze + +# Run gallery +flutter run -d chrome # or macos/windows +``` + +## Troubleshooting + +### Theme changes not reflecting in use cases +Ensure `StreamTheme` is added to `ThemeData.extensions` in `PreviewWrapper`: +```dart +Theme( + data: ThemeData( + extensions: [streamTheme], // Required! + ), + child: ..., +) +``` + +### Generated file has wrong order +The generator sorts alphabetically. Use category name prefixes to control order (e.g., "App Foundation" before "Components"). + diff --git a/apps/design_system_gallery/lib/app/gallery_app.dart b/apps/design_system_gallery/lib/app/gallery_app.dart new file mode 100644 index 0000000..dd841e3 --- /dev/null +++ b/apps/design_system_gallery/lib/app/gallery_app.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; + +import '../config/preview_configuration.dart'; +import '../config/theme_configuration.dart'; +import 'gallery_shell.dart'; + +/// Stream Design System Gallery +/// +/// A production-level design system gallery for exploring and customizing +/// Stream components. Inspired by Moon Design System and FlexColorScheme. +@widgetbook.App() +class StreamDesignSystemGallery extends StatefulWidget { + const StreamDesignSystemGallery({super.key}); + + @override + State createState() => _StreamDesignSystemGalleryState(); +} + +class _StreamDesignSystemGalleryState extends State { + final _themeConfig = ThemeConfiguration.light(); + final _previewConfig = PreviewConfiguration(); + var _showThemePanel = true; + + @override + void dispose() { + _themeConfig.dispose(); + _previewConfig.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: _themeConfig), + ChangeNotifierProvider.value(value: _previewConfig), + ], + child: Consumer( + builder: (context, themeConfig, _) { + final materialTheme = themeConfig.buildMaterialTheme(); + final isDark = themeConfig.brightness == Brightness.dark; + + return MaterialApp( + title: 'Stream Design System', + debugShowCheckedModeBanner: false, + // Use Stream-themed Material theme for all regular widgets + theme: materialTheme, + darkTheme: materialTheme, + themeMode: isDark ? ThemeMode.dark : ThemeMode.light, + home: GalleryShell( + showThemePanel: _showThemePanel, + onToggleThemePanel: () => setState(() => _showThemePanel = !_showThemePanel), + ), + ); + }, + ), + ); + } +} diff --git a/apps/design_system_gallery/lib/app/gallery_app.directories.g.dart b/apps/design_system_gallery/lib/app/gallery_app.directories.g.dart new file mode 100644 index 0000000..bc646e7 --- /dev/null +++ b/apps/design_system_gallery/lib/app/gallery_app.directories.g.dart @@ -0,0 +1,176 @@ +// dart format width=80 +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_import, prefer_relative_imports, directives_ordering + +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ************************************************************************** +// AppGenerator +// ************************************************************************** + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'package:design_system_gallery/components/button.dart' + as _design_system_gallery_components_button; +import 'package:design_system_gallery/components/stream_avatar.dart' + as _design_system_gallery_components_stream_avatar; +import 'package:design_system_gallery/components/stream_avatar_stack.dart' + as _design_system_gallery_components_stream_avatar_stack; +import 'package:design_system_gallery/components/stream_online_indicator.dart' + as _design_system_gallery_components_stream_online_indicator; +import 'package:design_system_gallery/semantics/elevations.dart' + as _design_system_gallery_semantics_elevations; +import 'package:design_system_gallery/semantics/typography.dart' + as _design_system_gallery_semantics_typography; +import 'package:widgetbook/widgetbook.dart' as _widgetbook; + +final directories = <_widgetbook.WidgetbookNode>[ + _widgetbook.WidgetbookCategory( + name: 'App Foundation', + children: [ + _widgetbook.WidgetbookFolder( + name: 'Elevations', + children: [ + _widgetbook.WidgetbookComponent( + name: 'StreamBoxShadow', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'All Elevations', + builder: _design_system_gallery_semantics_elevations + .buildStreamBoxShadowShowcase, + ), + ], + ), + ], + ), + _widgetbook.WidgetbookFolder( + name: 'Typography', + children: [ + _widgetbook.WidgetbookComponent( + name: 'StreamTextTheme', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'All Styles', + builder: _design_system_gallery_semantics_typography + .buildStreamTextThemeShowcase, + ), + ], + ), + ], + ), + ], + ), + _widgetbook.WidgetbookCategory( + name: 'Components', + children: [ + _widgetbook.WidgetbookFolder( + name: 'Avatar', + children: [ + _widgetbook.WidgetbookComponent( + name: 'StreamAvatar', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Color Palette', + builder: _design_system_gallery_components_stream_avatar + .buildStreamAvatarPalette, + ), + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: _design_system_gallery_components_stream_avatar + .buildStreamAvatarPlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Real-world Example', + builder: _design_system_gallery_components_stream_avatar + .buildStreamAvatarExample, + ), + _widgetbook.WidgetbookUseCase( + name: 'Size Variants', + builder: _design_system_gallery_components_stream_avatar + .buildStreamAvatarSizes, + ), + ], + ), + _widgetbook.WidgetbookComponent( + name: 'StreamAvatarStack', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: _design_system_gallery_components_stream_avatar_stack + .buildStreamAvatarStackPlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Real-world Example', + builder: _design_system_gallery_components_stream_avatar_stack + .buildStreamAvatarStackExample, + ), + ], + ), + ], + ), + _widgetbook.WidgetbookFolder( + name: 'Button', + children: [ + _widgetbook.WidgetbookComponent( + name: 'StreamButton', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: _design_system_gallery_components_button + .buildStreamButtonPlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Real-world Example', + builder: _design_system_gallery_components_button + .buildStreamButtonExample, + ), + _widgetbook.WidgetbookUseCase( + name: 'Size Variants', + builder: _design_system_gallery_components_button + .buildStreamButtonSizes, + ), + _widgetbook.WidgetbookUseCase( + name: 'Type Variants', + builder: _design_system_gallery_components_button + .buildStreamButtonTypes, + ), + _widgetbook.WidgetbookUseCase( + name: 'With Icons', + builder: _design_system_gallery_components_button + .buildStreamButtonWithIcons, + ), + ], + ), + ], + ), + _widgetbook.WidgetbookFolder( + name: 'Indicator', + children: [ + _widgetbook.WidgetbookComponent( + name: 'StreamOnlineIndicator', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_stream_online_indicator + .buildStreamOnlineIndicatorPlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Real-world Example', + builder: + _design_system_gallery_components_stream_online_indicator + .buildStreamOnlineIndicatorExample, + ), + _widgetbook.WidgetbookUseCase( + name: 'Size Variants', + builder: + _design_system_gallery_components_stream_online_indicator + .buildStreamOnlineIndicatorSizes, + ), + ], + ), + ], + ), + ], + ), +]; diff --git a/apps/design_system_gallery/lib/app/gallery_shell.dart b/apps/design_system_gallery/lib/app/gallery_shell.dart new file mode 100644 index 0000000..2bd1e0d --- /dev/null +++ b/apps/design_system_gallery/lib/app/gallery_shell.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:widgetbook/widgetbook.dart'; + +import '../config/theme_configuration.dart'; +import '../core/preview_wrapper.dart'; +import '../widgets/theme_studio/theme_customization_panel.dart'; +import '../widgets/toolbar/toolbar.dart'; +import 'gallery_app.directories.g.dart'; + +/// The main shell that wraps the widgetbook with custom Stream branding. +/// +/// This widget provides the overall layout including: +/// - Top toolbar with branding and controls +/// - Main widgetbook content area +/// - Optional theme customization panel +class GalleryShell extends StatelessWidget { + const GalleryShell({ + super.key, + required this.showThemePanel, + required this.onToggleThemePanel, + }); + + final bool showThemePanel; + final VoidCallback onToggleThemePanel; + + @override + Widget build(BuildContext context) { + final themeConfig = context.watch(); + final isDark = themeConfig.brightness == Brightness.dark; + + return Scaffold( + backgroundColor: _getShellBackground(themeConfig.brightness), + body: Column( + children: [ + // Toolbar spans across the entire width + GalleryToolbar( + showThemePanel: showThemePanel, + onToggleThemePanel: onToggleThemePanel, + ), + // Content area below toolbar + Expanded( + child: Row( + children: [ + // Widgetbook area + Expanded( + child: Widgetbook.material( + // Theme updates via themeMode/lightTheme/darkTheme props + // Preview updates via PreviewWrapper's ListenableBuilder + themeMode: isDark ? ThemeMode.dark : ThemeMode.light, + lightTheme: themeConfig.buildMaterialTheme(), + darkTheme: themeConfig.buildMaterialTheme(), + directories: directories, + appBuilder: (context, child) => PreviewWrapper(child: child), + ), + ), + // Theme customization panel - aligned with widgetbook content + if (showThemePanel) + SizedBox( + width: 340, + child: ThemeCustomizationPanel(configuration: themeConfig), + ), + ], + ), + ), + ], + ), + ); + } + + Color _getShellBackground(Brightness brightness) { + return brightness == Brightness.dark ? const Color(0xFF0A0A0A) : const Color(0xFFF8F9FA); + } +} diff --git a/apps/design_system_gallery/lib/components/button.dart b/apps/design_system_gallery/lib/components/button.dart index d9d2175..cf0fb6e 100644 --- a/apps/design_system_gallery/lib/components/button.dart +++ b/apps/design_system_gallery/lib/components/button.dart @@ -3,29 +3,386 @@ import 'package:stream_core_flutter/stream_core_flutter.dart'; import 'package:widgetbook/widgetbook.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; -// Import the widget from your app +// ============================================================================= +// Playground +// ============================================================================= + +@widgetbook.UseCase( + name: 'Playground', + type: StreamButton, + path: '[Components]/Button', +) +Widget buildStreamButtonPlayground(BuildContext context) { + final label = context.knobs.string( + label: 'Label', + initialValue: 'Click me', + description: 'The text displayed on the button.', + ); + + final type = context.knobs.object.dropdown( + label: 'Type', + options: StreamButtonType.values, + initialOption: StreamButtonType.primary, + labelBuilder: (option) => option.name, + description: 'Button visual style variant.', + ); + + final size = context.knobs.object.dropdown( + label: 'Size', + options: StreamButtonSize.values, + initialOption: StreamButtonSize.large, + labelBuilder: (option) => option.name, + description: 'Button size preset (affects padding and font size).', + ); + + final isDisabled = context.knobs.boolean( + label: 'Disabled', + description: 'Whether the button is disabled (non-interactive).', + ); + + final showLeadingIcon = context.knobs.boolean( + label: 'Leading Icon', + description: 'Show an icon before the label.', + ); + + final showTrailingIcon = context.knobs.boolean( + label: 'Trailing Icon', + description: 'Show an icon after the label.', + ); -@widgetbook.UseCase(name: 'Default', type: StreamButton) -Widget buildCoolButtonUseCase(BuildContext context) { return Center( child: StreamButton( - label: context.knobs.string(label: 'Label', initialValue: 'Click me'), - onTap: () { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Button clicked'))); - }, - type: context.knobs.object.dropdown( - label: 'Type', - options: StreamButtonType.values, - initialOption: StreamButtonType.primary, - labelBuilder: (option) => option.name, - ), - size: context.knobs.object.dropdown( - label: 'Size', - options: StreamButtonSize.values, - initialOption: StreamButtonSize.large, - labelBuilder: (option) => option.name, + label: label, + type: type, + size: size, + onTap: isDisabled + ? null + : () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Button tapped!'), + duration: Duration(seconds: 1), + ), + ); + }, + iconLeft: showLeadingIcon ? const Icon(Icons.add) : null, + iconRight: showTrailingIcon ? const Icon(Icons.arrow_forward) : null, + ), + ); +} + +// ============================================================================= +// Type Variants +// ============================================================================= + +@widgetbook.UseCase( + name: 'Type Variants', + type: StreamButton, + path: '[Components]/Button', +) +Widget buildStreamButtonTypes(BuildContext context) { + final theme = StreamTheme.of(context); + final colorScheme = theme.colorScheme; + final textTheme = theme.textTheme; + + return Center( + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: colorScheme.backgroundSurface, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final type in StreamButtonType.values) ...[ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 100, + child: Text( + type.name, + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + ), + StreamButton( + label: 'Button', + type: type, + onTap: () {}, + ), + ], + ), + if (type != StreamButtonType.values.last) const SizedBox(height: 16), + ], + ], + ), + ), + ); +} + +// ============================================================================= +// Size Variants +// ============================================================================= + +@widgetbook.UseCase( + name: 'Size Variants', + type: StreamButton, + path: '[Components]/Button', +) +Widget buildStreamButtonSizes(BuildContext context) { + final theme = StreamTheme.of(context); + final colorScheme = theme.colorScheme; + final textTheme = theme.textTheme; + + return Center( + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: colorScheme.backgroundSurface, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final size in StreamButtonSize.values) ...[ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 80, + child: Text( + size.name, + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + ), + StreamButton( + label: 'Button', + size: size, + onTap: () {}, + ), + ], + ), + if (size != StreamButtonSize.values.last) const SizedBox(height: 16), + ], + ], + ), + ), + ); +} + +// ============================================================================= +// With Icons +// ============================================================================= + +@widgetbook.UseCase( + name: 'With Icons', + type: StreamButton, + path: '[Components]/Button', +) +Widget buildStreamButtonWithIcons(BuildContext context) { + final theme = StreamTheme.of(context); + final colorScheme = theme.colorScheme; + final textTheme = theme.textTheme; + + return Center( + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: colorScheme.backgroundSurface, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 80, + child: Text( + 'Leading', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + ), + StreamButton( + label: 'Add Item', + iconLeft: const Icon(Icons.add), + onTap: () {}, + ), + ], + ), + const SizedBox(height: 16), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 80, + child: Text( + 'Trailing', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + ), + StreamButton( + label: 'Continue', + iconRight: const Icon(Icons.arrow_forward), + onTap: () {}, + ), + ], + ), + const SizedBox(height: 16), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 80, + child: Text( + 'Both', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + ), + StreamButton( + label: 'Upload', + iconLeft: const Icon(Icons.cloud_upload), + iconRight: const Icon(Icons.arrow_forward), + onTap: () {}, + ), + ], + ), + ], + ), + ), + ); +} + +// ============================================================================= +// Real-world Example +// ============================================================================= + +@widgetbook.UseCase( + name: 'Real-world Example', + type: StreamButton, + path: '[Components]/Button', +) +Widget buildStreamButtonExample(BuildContext context) { + final theme = StreamTheme.of(context); + final colorScheme = theme.colorScheme; + final textTheme = theme.textTheme; + + return Center( + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: colorScheme.backgroundSurface, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Common Patterns', + style: textTheme.headingSm.copyWith( + color: colorScheme.textPrimary, + ), + ), + const SizedBox(height: 16), + // Dialog actions + Container( + padding: const EdgeInsets.all(16), + width: 280, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colorScheme.backgroundApp, + borderRadius: BorderRadius.circular(8), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colorScheme.borderSurfaceSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Delete conversation?', + style: textTheme.bodyEmphasis.copyWith( + color: colorScheme.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + 'This action cannot be undone.', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + StreamButton( + label: 'Cancel', + type: StreamButtonType.secondary, + size: StreamButtonSize.small, + onTap: () {}, + ), + const SizedBox(width: 8), + StreamButton( + label: 'Delete', + type: StreamButtonType.destructive, + size: StreamButtonSize.small, + onTap: () {}, + ), + ], + ), + ], + ), + ), + const SizedBox(height: 12), + // Form submit + Container( + padding: const EdgeInsets.all(16), + width: 280, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colorScheme.backgroundApp, + borderRadius: BorderRadius.circular(8), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colorScheme.borderSurfaceSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Ready to send?', + style: textTheme.bodyEmphasis.copyWith( + color: colorScheme.textPrimary, + ), + ), + const SizedBox(height: 12), + StreamButton( + label: 'Send Message', + iconRight: const Icon(Icons.send), + onTap: () {}, + ), + ], + ), + ), + ], ), ), ); diff --git a/apps/design_system_gallery/lib/components/stream_avatar.dart b/apps/design_system_gallery/lib/components/stream_avatar.dart new file mode 100644 index 0000000..9768c7c --- /dev/null +++ b/apps/design_system_gallery/lib/components/stream_avatar.dart @@ -0,0 +1,309 @@ +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; + +const _sampleImageUrl = 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=200'; + +// ============================================================================= +// Playground +// ============================================================================= + +@widgetbook.UseCase( + name: 'Playground', + type: StreamAvatar, + path: '[Components]/Avatar', +) +Widget buildStreamAvatarPlayground(BuildContext context) { + final imageUrl = context.knobs.stringOrNull( + label: 'Image URL', + initialValue: _sampleImageUrl, + description: 'URL for the avatar image. Leave empty to show placeholder.', + ); + + final size = context.knobs.object.dropdown( + label: 'Size', + options: StreamAvatarSize.values, + initialOption: StreamAvatarSize.lg, + labelBuilder: (option) => '${option.name.toUpperCase()} (${option.value.toInt()}px)', + description: 'Avatar diameter size preset.', + ); + + final showBorder = context.knobs.boolean( + label: 'Show Border', + initialValue: true, + description: 'Whether to show a border around the avatar.', + ); + + final initials = context.knobs.string( + label: 'Initials', + initialValue: 'JD', + description: 'Text shown when no image is available (max 2 chars).', + ); + + return Center( + child: StreamAvatar( + imageUrl: (imageUrl?.isNotEmpty ?? false) ? imageUrl : null, + size: size, + showBorder: showBorder, + placeholder: (context) => Text( + initials.substring(0, initials.length.clamp(0, 2)).toUpperCase(), + ), + ), + ); +} + +// ============================================================================= +// Size Variants +// ============================================================================= + +@widgetbook.UseCase( + name: 'Size Variants', + type: StreamAvatar, + path: '[Components]/Avatar', +) +Widget buildStreamAvatarSizes(BuildContext context) { + final theme = StreamTheme.of(context); + final colorScheme = theme.colorScheme; + final textTheme = theme.textTheme; + + return Center( + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: colorScheme.backgroundSurface, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final size in StreamAvatarSize.values) ...[ + Column( + mainAxisSize: MainAxisSize.min, + children: [ + StreamAvatar( + imageUrl: _sampleImageUrl, + size: size, + placeholder: (context) => const Text('AB'), + ), + const SizedBox(height: 8), + Text( + size.name.toUpperCase(), + style: textTheme.captionEmphasis.copyWith( + color: colorScheme.textPrimary, + ), + ), + Text( + '${size.value.toInt()}px', + style: textTheme.metadataDefault.copyWith( + color: colorScheme.textTertiary, + ), + ), + ], + ), + if (size != StreamAvatarSize.values.last) const SizedBox(width: 24), + ], + ], + ), + ), + ); +} + +// ============================================================================= +// Color Palette +// ============================================================================= + +@widgetbook.UseCase( + name: 'Color Palette', + type: StreamAvatar, + path: '[Components]/Avatar', +) +Widget buildStreamAvatarPalette(BuildContext context) { + final theme = StreamTheme.of(context); + final colorScheme = theme.colorScheme; + final textTheme = theme.textTheme; + final palette = colorScheme.avatarPalette; + + return Center( + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: colorScheme.backgroundSurface, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Theme Avatar Palette', + style: textTheme.headingSm.copyWith( + color: colorScheme.textPrimary, + ), + ), + Text( + 'Automatically assigned based on user ID', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textTertiary, + ), + ), + const SizedBox(height: 16), + Wrap( + spacing: 16, + runSpacing: 16, + children: [ + for (var i = 0; i < palette.length; i++) + Column( + mainAxisSize: MainAxisSize.min, + children: [ + StreamAvatar( + size: StreamAvatarSize.lg, + backgroundColor: palette[i].backgroundColor, + foregroundColor: palette[i].foregroundColor, + placeholder: (context) => Text(_getInitials(i)), + ), + const SizedBox(height: 8), + Text( + 'Palette ${i + 1}', + style: textTheme.metadataDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + ], + ), + ], + ), + ], + ), + ), + ); +} + +// ============================================================================= +// Real-world Example +// ============================================================================= + +@widgetbook.UseCase( + name: 'Real-world Example', + type: StreamAvatar, + path: '[Components]/Avatar', +) +Widget buildStreamAvatarExample(BuildContext context) { + final theme = StreamTheme.of(context); + final colorScheme = theme.colorScheme; + final textTheme = theme.textTheme; + final palette = colorScheme.avatarPalette; + + return Center( + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: colorScheme.backgroundSurface, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Common Patterns', + style: textTheme.headingSm.copyWith( + color: colorScheme.textPrimary, + ), + ), + const SizedBox(height: 16), + // User profile header + Container( + padding: const EdgeInsets.all(16), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colorScheme.backgroundApp, + borderRadius: BorderRadius.circular(8), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colorScheme.borderSurfaceSubtle), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + StreamAvatar( + imageUrl: _sampleImageUrl, + size: StreamAvatarSize.lg, + placeholder: (context) => const Text('JD'), + ), + const SizedBox(width: 16), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Jane Doe', + style: textTheme.headingSm.copyWith( + color: colorScheme.textPrimary, + ), + ), + Text( + 'Product Designer', + style: textTheme.bodyDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 12), + // Message list item + Container( + padding: const EdgeInsets.all(12), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colorScheme.backgroundApp, + borderRadius: BorderRadius.circular(8), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colorScheme.borderSurfaceSubtle), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + StreamAvatar( + size: StreamAvatarSize.md, + backgroundColor: palette[0].backgroundColor, + foregroundColor: palette[0].foregroundColor, + placeholder: (context) => const Text('JD'), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'John Doe', + style: textTheme.bodyEmphasis.copyWith( + color: colorScheme.textPrimary, + ), + ), + Text( + 'Hey! Are you free for a call?', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); +} + +String _getInitials(int index) { + const names = ['AB', 'CD', 'EF', 'GH', 'IJ']; + return names[index % names.length]; +} diff --git a/apps/design_system_gallery/lib/components/stream_avatar_stack.dart b/apps/design_system_gallery/lib/components/stream_avatar_stack.dart new file mode 100644 index 0000000..debf6b8 --- /dev/null +++ b/apps/design_system_gallery/lib/components/stream_avatar_stack.dart @@ -0,0 +1,224 @@ +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; + +const _sampleImages = [ + 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=200', + 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=200', + 'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=200', + 'https://images.unsplash.com/photo-1539571696357-5a69c17a67c6?w=200', + 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?w=200', +]; + +// ============================================================================= +// Playground +// ============================================================================= + +@widgetbook.UseCase( + name: 'Playground', + type: StreamAvatarStack, + path: '[Components]/Avatar', +) +Widget buildStreamAvatarStackPlayground(BuildContext context) { + final avatarCount = context.knobs.int.slider( + label: 'Avatar Count', + initialValue: 4, + min: 1, + max: 10, + description: 'Total number of avatars in the stack.', + ); + + final size = context.knobs.object.dropdown( + label: 'Size', + options: StreamAvatarSize.values, + initialOption: StreamAvatarSize.md, + labelBuilder: (option) => '${option.name.toUpperCase()} (${option.value.toInt()}px)', + description: 'Size of each avatar in the stack.', + ); + + final overlap = context.knobs.double.slider( + label: 'Overlap', + initialValue: 0.3, + max: 0.8, + description: 'How much avatars overlap (0 = none, 0.8 = 80%).', + ); + + final maxAvatars = context.knobs.int.slider( + label: 'Max Visible', + initialValue: 5, + min: 2, + max: 10, + description: 'Max avatars shown before "+N" indicator.', + ); + + final showImages = context.knobs.boolean( + label: 'Show Images', + initialValue: true, + description: 'Use images or show initials placeholder.', + ); + + final colorScheme = StreamTheme.of(context).colorScheme; + final palette = colorScheme.avatarPalette; + + return Center( + child: StreamAvatarStack( + size: size, + overlap: overlap, + max: maxAvatars, + children: [ + for (var i = 0; i < avatarCount; i++) + StreamAvatar( + imageUrl: showImages ? _sampleImages[i % _sampleImages.length] : null, + backgroundColor: palette[i % palette.length].backgroundColor, + foregroundColor: palette[i % palette.length].foregroundColor, + placeholder: (context) => Text(_getInitials(i)), + ), + ], + ), + ); +} + +// ============================================================================= +// Real-world Example +// ============================================================================= + +@widgetbook.UseCase( + name: 'Real-world Example', + type: StreamAvatarStack, + path: '[Components]/Avatar', +) +Widget buildStreamAvatarStackExample(BuildContext context) { + final theme = StreamTheme.of(context); + final colorScheme = theme.colorScheme; + final textTheme = theme.textTheme; + final palette = colorScheme.avatarPalette; + + return Center( + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: colorScheme.backgroundSurface, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Common Patterns', + style: textTheme.headingSm.copyWith( + color: colorScheme.textPrimary, + ), + ), + const SizedBox(height: 16), + // Group chat + Container( + padding: const EdgeInsets.all(12), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colorScheme.backgroundApp, + borderRadius: BorderRadius.circular(8), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colorScheme.borderSurfaceSubtle), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + StreamAvatarStack( + size: StreamAvatarSize.sm, + children: [ + for (var i = 0; i < 3; i++) + StreamAvatar( + imageUrl: _sampleImages[i], + backgroundColor: palette[i % palette.length].backgroundColor, + foregroundColor: palette[i % palette.length].foregroundColor, + placeholder: (context) => Text(_getInitials(i)), + ), + ], + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'John, Sarah, Mike', + style: textTheme.bodyEmphasis.copyWith( + color: colorScheme.textPrimary, + ), + ), + Text( + 'Active now', + style: textTheme.captionDefault.copyWith( + color: colorScheme.accentSuccess, + ), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 12), + // Team with overflow + Container( + padding: const EdgeInsets.all(12), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colorScheme.backgroundApp, + borderRadius: BorderRadius.circular(8), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colorScheme.borderSurfaceSubtle), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + StreamAvatarStack( + size: StreamAvatarSize.sm, + max: 4, + children: [ + for (var i = 0; i < 8; i++) + StreamAvatar( + imageUrl: _sampleImages[i % _sampleImages.length], + backgroundColor: palette[i % palette.length].backgroundColor, + foregroundColor: palette[i % palette.length].foregroundColor, + placeholder: (context) => Text(_getInitials(i)), + ), + ], + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Design Team', + style: textTheme.bodyEmphasis.copyWith( + color: colorScheme.textPrimary, + ), + ), + Text( + '8 members', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); +} + +String _getInitials(int index) { + const names = ['AB', 'CD', 'EF', 'GH', 'IJ', 'KL', 'MN', 'OP']; + return names[index % names.length]; +} diff --git a/apps/design_system_gallery/lib/components/stream_online_indicator.dart b/apps/design_system_gallery/lib/components/stream_online_indicator.dart new file mode 100644 index 0000000..eeb20ce --- /dev/null +++ b/apps/design_system_gallery/lib/components/stream_online_indicator.dart @@ -0,0 +1,285 @@ +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; + +// ============================================================================= +// Playground +// ============================================================================= + +@widgetbook.UseCase( + name: 'Playground', + type: StreamOnlineIndicator, + path: '[Components]/Indicator', +) +Widget buildStreamOnlineIndicatorPlayground(BuildContext context) { + final isOnline = context.knobs.boolean( + label: 'Is Online', + initialValue: true, + description: 'Whether the user is currently online.', + ); + + final size = context.knobs.object.dropdown( + label: 'Size', + options: StreamOnlineIndicatorSize.values, + initialOption: StreamOnlineIndicatorSize.lg, + labelBuilder: (option) => option.name.toUpperCase(), + description: 'The size of the indicator.', + ); + + return Center( + child: StreamOnlineIndicator( + isOnline: isOnline, + size: size, + ), + ); +} + +// ============================================================================= +// Size Variants +// ============================================================================= + +@widgetbook.UseCase( + name: 'Size Variants', + type: StreamOnlineIndicator, + path: '[Components]/Indicator', +) +Widget buildStreamOnlineIndicatorSizes(BuildContext context) { + final streamTheme = StreamTheme.of(context); + final textTheme = streamTheme.textTheme; + final colorScheme = streamTheme.colorScheme; + + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _SizeVariant( + label: 'Small (8px)', + size: StreamOnlineIndicatorSize.sm, + isOnline: true, + textTheme: textTheme, + colorScheme: colorScheme, + ), + const SizedBox(height: 24), + _SizeVariant( + label: 'Medium (12px)', + size: StreamOnlineIndicatorSize.md, + isOnline: true, + textTheme: textTheme, + colorScheme: colorScheme, + ), + const SizedBox(height: 24), + _SizeVariant( + label: 'Large (14px)', + size: StreamOnlineIndicatorSize.lg, + isOnline: true, + textTheme: textTheme, + colorScheme: colorScheme, + ), + const SizedBox(height: 32), + Divider(color: colorScheme.borderSurfaceSubtle), + const SizedBox(height: 32), + _SizeVariant( + label: 'Small (8px)', + size: StreamOnlineIndicatorSize.sm, + isOnline: false, + textTheme: textTheme, + colorScheme: colorScheme, + ), + const SizedBox(height: 24), + _SizeVariant( + label: 'Medium (12px)', + size: StreamOnlineIndicatorSize.md, + isOnline: false, + textTheme: textTheme, + colorScheme: colorScheme, + ), + const SizedBox(height: 24), + _SizeVariant( + label: 'Large (14px)', + size: StreamOnlineIndicatorSize.lg, + isOnline: false, + textTheme: textTheme, + colorScheme: colorScheme, + ), + ], + ), + ); +} + +class _SizeVariant extends StatelessWidget { + const _SizeVariant({ + required this.label, + required this.size, + required this.isOnline, + required this.textTheme, + required this.colorScheme, + }); + + final String label; + final StreamOnlineIndicatorSize size; + final bool isOnline; + final StreamTextTheme textTheme; + final StreamColorScheme colorScheme; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + StreamOnlineIndicator( + isOnline: isOnline, + size: size, + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: textTheme.captionEmphasis.copyWith( + color: colorScheme.textPrimary, + ), + ), + Text( + isOnline ? 'Online' : 'Offline', + style: textTheme.metadataDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + ], + ), + ], + ); + } +} + +// ============================================================================= +// Real-world Example +// ============================================================================= + +@widgetbook.UseCase( + name: 'Real-world Example', + type: StreamOnlineIndicator, + path: '[Components]/Indicator', +) +Widget buildStreamOnlineIndicatorExample(BuildContext context) { + final streamTheme = StreamTheme.of(context); + final textTheme = streamTheme.textTheme; + final colorScheme = streamTheme.colorScheme; + + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Online Status Indicators', + style: textTheme.headingSm.copyWith( + color: colorScheme.textPrimary, + ), + ), + const SizedBox(height: 8), + Text( + 'Indicators positioned on avatars to show presence', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + const SizedBox(height: 32), + Wrap( + spacing: 24, + runSpacing: 24, + alignment: WrapAlignment.center, + children: [ + _AvatarWithIndicator( + name: 'Sarah Chen', + isOnline: true, + textTheme: textTheme, + colorScheme: colorScheme, + ), + _AvatarWithIndicator( + name: 'Alex Kim', + isOnline: true, + textTheme: textTheme, + colorScheme: colorScheme, + ), + _AvatarWithIndicator( + name: 'Jordan Lee', + isOnline: false, + textTheme: textTheme, + colorScheme: colorScheme, + ), + _AvatarWithIndicator( + name: 'Taylor Park', + isOnline: true, + textTheme: textTheme, + colorScheme: colorScheme, + ), + ], + ), + ], + ), + ), + ); +} + +class _AvatarWithIndicator extends StatelessWidget { + const _AvatarWithIndicator({ + required this.name, + required this.isOnline, + required this.textTheme, + required this.colorScheme, + }); + + final String name; + final bool isOnline; + final StreamTextTheme textTheme; + final StreamColorScheme colorScheme; + + @override + Widget build(BuildContext context) { + final initials = name.split(' ').map((n) => n[0]).join(); + + return Column( + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + StreamAvatar( + size: StreamAvatarSize.lg, + placeholder: (context) => Text( + initials, + style: textTheme.captionEmphasis.copyWith( + color: colorScheme.textOnAccent, + ), + ), + ), + Positioned( + right: 0, + top: 0, + child: StreamOnlineIndicator( + isOnline: isOnline, + size: StreamOnlineIndicatorSize.lg, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + name, + style: textTheme.captionDefault.copyWith( + color: colorScheme.textPrimary, + ), + ), + Text( + isOnline ? 'Online' : 'Offline', + style: textTheme.metadataDefault.copyWith( + color: isOnline ? colorScheme.accentSuccess : colorScheme.textTertiary, + ), + ), + ], + ); + } +} diff --git a/apps/design_system_gallery/lib/config/config.dart b/apps/design_system_gallery/lib/config/config.dart new file mode 100644 index 0000000..be5979d --- /dev/null +++ b/apps/design_system_gallery/lib/config/config.dart @@ -0,0 +1,7 @@ +/// Configuration classes for the design system gallery. +/// +/// This barrel file exports all configuration-related classes. +library; + +export 'preview_configuration.dart'; +export 'theme_configuration.dart'; diff --git a/apps/design_system_gallery/lib/config/preview_configuration.dart b/apps/design_system_gallery/lib/config/preview_configuration.dart new file mode 100644 index 0000000..7ec158d --- /dev/null +++ b/apps/design_system_gallery/lib/config/preview_configuration.dart @@ -0,0 +1,62 @@ +import 'package:device_frame_plus/device_frame_plus.dart'; +import 'package:flutter/foundation.dart'; + +/// Preview configuration for device frame and text scale. +/// +/// Manages the device frame, text scale, and device frame visibility +/// for the widget preview area. +class PreviewConfiguration extends ChangeNotifier { + PreviewConfiguration(); + + // ========================================================================= + // State + // ========================================================================= + + DeviceInfo _selectedDevice = Devices.ios.iPhone13ProMax; + var _textScale = 1.0; + var _showDeviceFrame = false; + + // ========================================================================= + // Getters + // ========================================================================= + + DeviceInfo get selectedDevice => _selectedDevice; + double get textScale => _textScale; + bool get showDeviceFrame => _showDeviceFrame; + + // ========================================================================= + // Static Options + // ========================================================================= + + static final deviceOptions = [ + Devices.ios.iPhone13ProMax, + Devices.ios.iPhone13Mini, + Devices.ios.iPhoneSE, + Devices.ios.iPad, + Devices.android.samsungGalaxyS20, + Devices.android.samsungGalaxyNote20, + ]; + + static const textScaleOptions = [0.85, 1, 1.15, 1.3, 2]; + + // ========================================================================= + // Setters + // ========================================================================= + + void setDevice(DeviceInfo device) { + if (_selectedDevice == device) return; + _selectedDevice = device; + notifyListeners(); + } + + void setTextScale(double scale) { + if (_textScale == scale) return; + _textScale = scale; + notifyListeners(); + } + + void toggleDeviceFrame() { + _showDeviceFrame = !_showDeviceFrame; + notifyListeners(); + } +} diff --git a/apps/design_system_gallery/lib/config/theme_configuration.dart b/apps/design_system_gallery/lib/config/theme_configuration.dart new file mode 100644 index 0000000..89729be --- /dev/null +++ b/apps/design_system_gallery/lib/config/theme_configuration.dart @@ -0,0 +1,572 @@ +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/stream_core_flutter.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]. +class ThemeConfiguration extends ChangeNotifier { + ThemeConfiguration({ + Brightness brightness = Brightness.light, + }) : _brightness = brightness { + _rebuildTheme(); + } + + factory ThemeConfiguration.light() => ThemeConfiguration(); + factory ThemeConfiguration.dark() => ThemeConfiguration(brightness: Brightness.dark); + + // ========================================================================= + // Core State + // ========================================================================= + + var _themeData = StreamTheme.light(); + StreamTheme get themeData => _themeData; + + Brightness _brightness; + Brightness get brightness => _brightness; + + // ========================================================================= + // Accent Colors + // ========================================================================= + Color? _accentPrimary; + Color? _accentSuccess; + Color? _accentWarning; + Color? _accentError; + Color? _accentNeutral; + + // ========================================================================= + // Text Colors + // ========================================================================= + Color? _textPrimary; + Color? _textSecondary; + Color? _textTertiary; + Color? _textDisabled; + Color? _textInverse; + Color? _textLink; + Color? _textOnAccent; + + // ========================================================================= + // Background Colors + // ========================================================================= + Color? _backgroundApp; + Color? _backgroundSurface; + Color? _backgroundSurfaceSubtle; + Color? _backgroundSurfaceStrong; + Color? _backgroundOverlay; + + // ========================================================================= + // Border Colors - Core + // ========================================================================= + Color? _borderSurface; + Color? _borderSurfaceSubtle; + Color? _borderSurfaceStrong; + Color? _borderOnDark; + Color? _borderOnAccent; + Color? _borderSubtle; + Color? _borderImage; + + // ========================================================================= + // Border Colors - Utility + // ========================================================================= + Color? _borderFocus; + Color? _borderDisabled; + Color? _borderError; + Color? _borderWarning; + Color? _borderSuccess; + Color? _borderSelected; + + // ========================================================================= + // State Colors + // ========================================================================= + Color? _stateHover; + Color? _statePressed; + Color? _stateSelected; + Color? _stateFocused; + Color? _stateDisabled; + + // ========================================================================= + // System Colors + // ========================================================================= + Color? _systemText; + Color? _systemScrollbar; + + // ========================================================================= + // Avatar Palette + // ========================================================================= + List? _avatarPalette; + + // ========================================================================= + // Brand Color + // ========================================================================= + Color? _brandPrimaryColor; + + // ========================================================================= + // 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; + + // ========================================================================= + // 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 textInverse => _textInverse ?? _themeData.colorScheme.textInverse; + Color get textLink => _textLink ?? _themeData.colorScheme.textLink; + Color get textOnAccent => _textOnAccent ?? _themeData.colorScheme.textOnAccent; + + // ========================================================================= + // 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 backgroundOverlay => _backgroundOverlay ?? _themeData.colorScheme.backgroundOverlay; + + // ========================================================================= + // Getters - Border Core + // ========================================================================= + Color get borderSurface => _borderSurface ?? _themeData.colorScheme.borderSurface; + Color get borderSurfaceSubtle => _borderSurfaceSubtle ?? _themeData.colorScheme.borderSurfaceSubtle; + Color get borderSurfaceStrong => _borderSurfaceStrong ?? _themeData.colorScheme.borderSurfaceStrong; + Color get borderOnDark => _borderOnDark ?? _themeData.colorScheme.borderOnDark; + Color get borderOnAccent => _borderOnAccent ?? _themeData.colorScheme.borderOnAccent; + Color get borderSubtle => _borderSubtle ?? _themeData.colorScheme.borderSubtle; + Color get borderImage => _borderImage ?? _themeData.colorScheme.borderImage; + + // ========================================================================= + // Getters - Border Utility + // ========================================================================= + Color get borderFocus => _borderFocus ?? _themeData.colorScheme.borderFocus; + Color get borderDisabled => _borderDisabled ?? _themeData.colorScheme.borderDisabled; + 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; + + // ========================================================================= + // Getters - State + // ========================================================================= + Color get stateHover => _stateHover ?? _themeData.colorScheme.stateHover; + Color get statePressed => _statePressed ?? _themeData.colorScheme.statePressed; + Color get stateSelected => _stateSelected ?? _themeData.colorScheme.stateSelected; + Color get stateFocused => _stateFocused ?? _themeData.colorScheme.stateFocused; + Color get stateDisabled => _stateDisabled ?? _themeData.colorScheme.stateDisabled; + + // ========================================================================= + // Getters - System + // ========================================================================= + Color get systemText => _systemText ?? _themeData.colorScheme.systemText; + Color get systemScrollbar => _systemScrollbar ?? _themeData.colorScheme.systemScrollbar; + + // ========================================================================= + // Getters - Avatar Palette + // ========================================================================= + List get avatarPalette => _avatarPalette ?? _themeData.colorScheme.avatarPalette; + + // ========================================================================= + // Getters - Brand + // ========================================================================= + Color get brandPrimaryColor => _brandPrimaryColor ?? _themeData.colorScheme.brand.shade500; + + // ========================================================================= + // Setters + // ========================================================================= + + void setBrightness(Brightness brightness) { + if (_brightness == brightness) return; + _brightness = brightness; + _rebuildTheme(); + 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 setTextInverse(Color color) => _update(() => _textInverse = 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 setBackgroundOverlay(Color color) => _update(() => _backgroundOverlay = color); + + // Border Core + void setBorderSurface(Color color) => _update(() => _borderSurface = color); + void setBorderSurfaceSubtle(Color color) => _update(() => _borderSurfaceSubtle = color); + void setBorderSurfaceStrong(Color color) => _update(() => _borderSurfaceStrong = color); + void setBorderOnDark(Color color) => _update(() => _borderOnDark = color); + void setBorderOnAccent(Color color) => _update(() => _borderOnAccent = color); + void setBorderSubtle(Color color) => _update(() => _borderSubtle = color); + void setBorderImage(Color color) => _update(() => _borderImage = color); + + // Border Utility + void setBorderFocus(Color color) => _update(() => _borderFocus = color); + void setBorderDisabled(Color color) => _update(() => _borderDisabled = 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); + + // State + void setStateHover(Color color) => _update(() => _stateHover = color); + void setStatePressed(Color color) => _update(() => _statePressed = color); + void setStateSelected(Color color) => _update(() => _stateSelected = color); + void setStateFocused(Color color) => _update(() => _stateFocused = color); + void setStateDisabled(Color color) => _update(() => _stateDisabled = 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); + + // Brand + void setBrandPrimaryColor(Color color) => _update(() => _brandPrimaryColor = color); + + void updateAvatarPaletteAt(int index, StreamAvatarColorPair pair) { + final current = List.from(avatarPalette); + if (index < current.length) { + current[index] = pair; + _update(() => _avatarPalette = current); + } + } + + void addAvatarPaletteEntry(StreamAvatarColorPair pair) { + final current = List.from(avatarPalette); + current.add(pair); + _update(() => _avatarPalette = current); + } + + void removeAvatarPaletteAt(int index) { + final current = List.from(avatarPalette); + if (index < current.length && current.length > 1) { + current.removeAt(index); + _update(() => _avatarPalette = current); + } + } + + void _update(VoidCallback setter) { + setter(); + _rebuildTheme(); + notifyListeners(); + } + + void resetToDefaults() { + // Accent + _accentPrimary = null; + _accentSuccess = null; + _accentWarning = null; + _accentError = null; + _accentNeutral = null; + // Text + _textPrimary = null; + _textSecondary = null; + _textTertiary = null; + _textDisabled = null; + _textInverse = null; + _textLink = null; + _textOnAccent = null; + // Background + _backgroundApp = null; + _backgroundSurface = null; + _backgroundSurfaceSubtle = null; + _backgroundSurfaceStrong = null; + _backgroundOverlay = null; + // Border Core + _borderSurface = null; + _borderSurfaceSubtle = null; + _borderSurfaceStrong = null; + _borderOnDark = null; + _borderOnAccent = null; + _borderSubtle = null; + _borderImage = null; + // Border Utility + _borderFocus = null; + _borderDisabled = null; + _borderError = null; + _borderWarning = null; + _borderSuccess = null; + _borderSelected = null; + // State + _stateHover = null; + _statePressed = null; + _stateSelected = null; + _stateFocused = null; + _stateDisabled = null; + // System + _systemText = null; + _systemScrollbar = null; + // Avatar + _avatarPalette = null; + // Brand + _brandPrimaryColor = null; + + _rebuildTheme(); + notifyListeners(); + } + + void _rebuildTheme() { + final baseColorScheme = _brightness == Brightness.dark ? StreamColorScheme.dark() : StreamColorScheme.light(); + + // If brand primary is set, use it for accentPrimary (unless explicitly overridden) + final effectiveAccentPrimary = _accentPrimary ?? _brandPrimaryColor; + + final colorScheme = baseColorScheme.copyWith( + // Accent - brand primary affects accentPrimary + accentPrimary: effectiveAccentPrimary, + accentSuccess: _accentSuccess, + accentWarning: _accentWarning, + accentError: _accentError, + accentNeutral: _accentNeutral, + // Text + textPrimary: _textPrimary, + textSecondary: _textSecondary, + textTertiary: _textTertiary, + textDisabled: _textDisabled, + textInverse: _textInverse, + textLink: _textLink, + textOnAccent: _textOnAccent, + // Background + backgroundApp: _backgroundApp, + backgroundSurface: _backgroundSurface, + backgroundSurfaceSubtle: _backgroundSurfaceSubtle, + backgroundSurfaceStrong: _backgroundSurfaceStrong, + backgroundOverlay: _backgroundOverlay, + // Border Core + borderSurface: _borderSurface, + borderSurfaceSubtle: _borderSurfaceSubtle, + borderSurfaceStrong: _borderSurfaceStrong, + borderOnDark: _borderOnDark, + borderOnAccent: _borderOnAccent, + borderSubtle: _borderSubtle, + borderImage: _borderImage, + // Border Utility + borderFocus: _borderFocus, + borderDisabled: _borderDisabled, + borderError: _borderError, + borderWarning: _borderWarning, + borderSuccess: _borderSuccess, + borderSelected: _borderSelected, + // State + stateHover: _stateHover, + statePressed: _statePressed, + stateSelected: _stateSelected, + stateFocused: _stateFocused, + stateDisabled: _stateDisabled, + // System + systemText: _systemText, + systemScrollbar: _systemScrollbar, + // Avatar + avatarPalette: _avatarPalette, + ); + + _themeData = StreamTheme( + brightness: _brightness, + colorScheme: colorScheme, + ); + } + + /// Builds a Material ThemeData that uses Stream colors. + /// Use this for applying Stream theming to regular Flutter widgets. + ThemeData buildMaterialTheme() { + final cs = _themeData.colorScheme; + final isDark = _brightness == Brightness.dark; + + return ThemeData( + brightness: _brightness, + useMaterial3: true, + // Colors + primaryColor: cs.accentPrimary, + scaffoldBackgroundColor: cs.backgroundApp, + cardColor: cs.backgroundSurface, + dividerColor: cs.borderSurfaceSubtle, + disabledColor: cs.textDisabled, + hintColor: cs.textTertiary, + // Color Scheme + colorScheme: isDark + ? ColorScheme.dark( + primary: cs.accentPrimary, + secondary: cs.accentPrimary, + tertiary: cs.accentNeutral, + error: cs.accentError, + surface: cs.backgroundSurface, + surfaceContainerHighest: cs.backgroundSurfaceSubtle, + onPrimary: cs.textOnAccent, + onSecondary: cs.textOnAccent, + onSurface: cs.textPrimary, + onSurfaceVariant: cs.textSecondary, + onError: cs.textOnAccent, + outline: cs.borderSurface, + outlineVariant: cs.borderSurfaceSubtle, + ) + : ColorScheme.light( + primary: cs.accentPrimary, + secondary: cs.accentPrimary, + tertiary: cs.accentNeutral, + error: cs.accentError, + surface: cs.backgroundSurface, + surfaceContainerHighest: cs.backgroundSurfaceSubtle, + onPrimary: cs.textOnAccent, + onSecondary: cs.textOnAccent, + onSurface: cs.textPrimary, + onSurfaceVariant: cs.textSecondary, + onError: cs.textOnAccent, + outline: cs.borderSurface, + outlineVariant: cs.borderSurfaceSubtle, + ), + // Dialog + dialogTheme: DialogThemeData( + backgroundColor: cs.backgroundSurface, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: cs.borderSurfaceSubtle), + ), + titleTextStyle: TextStyle( + color: cs.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + contentTextStyle: TextStyle(color: cs.textSecondary, fontSize: 14), + ), + // AppBar + appBarTheme: AppBarTheme( + backgroundColor: cs.backgroundSurface, + foregroundColor: cs.textPrimary, + surfaceTintColor: Colors.transparent, + elevation: 0, + ), + // Buttons + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: cs.accentPrimary, + foregroundColor: cs.textOnAccent, + disabledBackgroundColor: cs.stateDisabled, + disabledForegroundColor: cs.textDisabled, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: cs.textPrimary, + side: BorderSide(color: cs.borderSurface), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: cs.accentPrimary, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: cs.backgroundSurface, + foregroundColor: cs.textPrimary, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + // Input + inputDecorationTheme: InputDecorationTheme( + fillColor: cs.backgroundApp, + filled: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: cs.borderSurface), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: cs.borderSurfaceSubtle), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: cs.accentPrimary, width: 2), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: cs.accentError), + ), + hintStyle: TextStyle(color: cs.textTertiary), + labelStyle: TextStyle(color: cs.textSecondary), + ), + // Dropdown + dropdownMenuTheme: DropdownMenuThemeData( + menuStyle: MenuStyle( + backgroundColor: WidgetStatePropertyAll(cs.backgroundSurface), + surfaceTintColor: const WidgetStatePropertyAll(Colors.transparent), + shape: WidgetStatePropertyAll( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide(color: cs.borderSurfaceSubtle), + ), + ), + ), + ), + // PopupMenu + popupMenuTheme: PopupMenuThemeData( + color: cs.backgroundSurface, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide(color: cs.borderSurfaceSubtle), + ), + textStyle: TextStyle(color: cs.textPrimary), + ), + // Scrollbar + scrollbarTheme: ScrollbarThemeData( + thumbColor: WidgetStatePropertyAll(cs.systemScrollbar), + trackColor: WidgetStatePropertyAll(cs.backgroundSurfaceSubtle), + radius: const Radius.circular(4), + thickness: const WidgetStatePropertyAll(6), + ), + // Tooltip + tooltipTheme: TooltipThemeData( + decoration: BoxDecoration( + color: cs.backgroundSurfaceStrong, + borderRadius: BorderRadius.circular(6), + ), + textStyle: TextStyle(color: cs.textPrimary, fontSize: 12), + ), + // Divider + dividerTheme: DividerThemeData( + color: cs.borderSurfaceSubtle, + thickness: 1, + ), + // Icon + iconTheme: IconThemeData(color: cs.textSecondary), + // Text + textTheme: TextTheme( + bodyLarge: TextStyle(color: cs.textPrimary), + bodyMedium: TextStyle(color: cs.textPrimary), + bodySmall: TextStyle(color: cs.textSecondary), + labelLarge: TextStyle(color: cs.textPrimary), + labelMedium: TextStyle(color: cs.textSecondary), + labelSmall: TextStyle(color: cs.textTertiary), + titleLarge: TextStyle(color: cs.textPrimary, fontWeight: FontWeight.w600), + titleMedium: TextStyle(color: cs.textPrimary, fontWeight: FontWeight.w600), + titleSmall: TextStyle(color: cs.textPrimary, fontWeight: FontWeight.w500), + ), + ); + } +} diff --git a/apps/design_system_gallery/lib/core/core.dart b/apps/design_system_gallery/lib/core/core.dart new file mode 100644 index 0000000..ab152af --- /dev/null +++ b/apps/design_system_gallery/lib/core/core.dart @@ -0,0 +1,6 @@ +/// Core widgets and utilities for the design system gallery. +/// +/// This barrel file exports core functionality like the preview wrapper. +library; + +export 'preview_wrapper.dart'; diff --git a/apps/design_system_gallery/lib/core/preview_wrapper.dart b/apps/design_system_gallery/lib/core/preview_wrapper.dart new file mode 100644 index 0000000..600bd05 --- /dev/null +++ b/apps/design_system_gallery/lib/core/preview_wrapper.dart @@ -0,0 +1,85 @@ +import 'package:device_frame_plus/device_frame_plus.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../config/preview_configuration.dart'; +import '../config/theme_configuration.dart'; + +/// Wrapper widget that applies device frame and text scale to the preview. +/// +/// Uses [ListenableBuilder] to explicitly react to [ThemeConfiguration] +/// and [PreviewConfiguration] changes. +class PreviewWrapper extends StatelessWidget { + const PreviewWrapper({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + // Use ListenableBuilder to explicitly listen to both configurations + return ListenableBuilder( + listenable: Listenable.merge([ + context.read(), + context.read(), + ]), + builder: (context, _) { + final themeConfig = context.read(); + final previewConfig = context.read(); + + final streamTheme = themeConfig.themeData; + final colorScheme = streamTheme.colorScheme; + final boxShadow = streamTheme.boxShadow; + + // Provide StreamTheme via Material theme extension - this is how + // StreamTheme.of(context) finds the theme in the widget tree + final content = Theme( + data: ThemeData( + brightness: themeConfig.brightness, + useMaterial3: true, + scaffoldBackgroundColor: colorScheme.backgroundApp, + extensions: [streamTheme], + ), + child: MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.linear(previewConfig.textScale), + ), + child: ColoredBox( + color: colorScheme.backgroundApp, + child: child, + ), + ), + ); + + if (!previewConfig.showDeviceFrame) { + return Center( + child: Container( + constraints: const BoxConstraints(maxWidth: 540, maxHeight: 900), + margin: const EdgeInsets.all(24), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colorScheme.backgroundApp, + borderRadius: BorderRadius.circular(20), + boxShadow: boxShadow.elevation3, + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + border: Border.all(color: colorScheme.borderSurfaceSubtle), + ), + child: content, + ), + ); + } + + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: DeviceFrame( + device: previewConfig.selectedDevice, + screen: content, + ), + ), + ); + }, + ); + } +} diff --git a/apps/design_system_gallery/lib/main.dart b/apps/design_system_gallery/lib/main.dart index 4cdefa7..cc9edd8 100644 --- a/apps/design_system_gallery/lib/main.dart +++ b/apps/design_system_gallery/lib/main.dart @@ -1,38 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:widgetbook/widgetbook.dart'; -import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; -// This file does not exist yet, -// it will be generated in the next step -import 'main.directories.g.dart'; -import 'theme_config.dart'; +import 'app/gallery_app.dart'; void main() { - runApp(const WidgetbookApp()); -} - -@widgetbook.App() -class WidgetbookApp extends StatelessWidget { - const WidgetbookApp({super.key}); - - @override - Widget build(BuildContext context) { - return ChangeNotifierProvider( - create: (context) => ThemeConfiguration.empty(), - child: Widgetbook.material( - themeMode: ThemeMode.light, - // The [directories] variable does not exist yet, - // it will be generated in the next step - directories: directories, - - appBuilder: (context, child) => MaterialApp( - theme: ThemeData.light().copyWith( - extensions: [context.watch().themeData], - ), - home: Scaffold(body: child), - ), - ), - ); - } + runApp(const StreamDesignSystemGallery()); } diff --git a/apps/design_system_gallery/lib/main.directories.g.dart b/apps/design_system_gallery/lib/main.directories.g.dart deleted file mode 100644 index 040bc92..0000000 --- a/apps/design_system_gallery/lib/main.directories.g.dart +++ /dev/null @@ -1,44 +0,0 @@ -// dart format width=80 -// coverage:ignore-file -// ignore_for_file: type=lint -// ignore_for_file: unused_import, prefer_relative_imports, directives_ordering - -// GENERATED CODE - DO NOT MODIFY BY HAND - -// ************************************************************************** -// AppGenerator -// ************************************************************************** - -// ignore_for_file: no_leading_underscores_for_library_prefixes -import 'package:design_system_gallery/components/button.dart' - as _design_system_gallery_components_button; -import 'package:design_system_gallery/theme_config.dart' - as _design_system_gallery_theme_config; -import 'package:widgetbook/widgetbook.dart' as _widgetbook; - -final directories = <_widgetbook.WidgetbookNode>[ - _widgetbook.WidgetbookComponent( - name: 'ThemeConfig', - useCases: [ - _widgetbook.WidgetbookUseCase( - name: 'Default', - builder: _design_system_gallery_theme_config.buildCoolButtonUseCase, - ), - ], - ), - _widgetbook.WidgetbookFolder( - name: 'components', - children: [ - _widgetbook.WidgetbookComponent( - name: 'StreamButton', - useCases: [ - _widgetbook.WidgetbookUseCase( - name: 'Default', - builder: - _design_system_gallery_components_button.buildCoolButtonUseCase, - ), - ], - ), - ], - ), -]; diff --git a/apps/design_system_gallery/lib/semantics/elevations.dart b/apps/design_system_gallery/lib/semantics/elevations.dart new file mode 100644 index 0000000..2d4cc37 --- /dev/null +++ b/apps/design_system_gallery/lib/semantics/elevations.dart @@ -0,0 +1,572 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart'; + +@UseCase( + name: 'All Elevations', + type: StreamBoxShadow, + path: '[App Foundation]/Elevations', +) +Widget buildStreamBoxShadowShowcase(BuildContext context) { + final streamTheme = StreamTheme.of(context); + final boxShadow = streamTheme.boxShadow; + final colorScheme = streamTheme.colorScheme; + final textTheme = streamTheme.textTheme; + + return DefaultTextStyle( + style: TextStyle(color: colorScheme.textPrimary), + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Text( + 'Elevation System', + style: textTheme.headingMd.copyWith(color: colorScheme.textPrimary), + ), + const SizedBox(height: 4), + Text( + 'Shadows create depth hierarchy and visual separation between surfaces', + style: textTheme.bodyDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + const SizedBox(height: 24), + + // 3D Stacked visualization + _StackedElevationDemo( + boxShadow: boxShadow, + colorScheme: colorScheme, + textTheme: textTheme, + ), + const SizedBox(height: 32), + + // Elevation cards grid + _ElevationGrid( + boxShadow: boxShadow, + colorScheme: colorScheme, + textTheme: textTheme, + ), + const SizedBox(height: 32), + + // Technical details + _TechnicalDetails( + boxShadow: boxShadow, + colorScheme: colorScheme, + textTheme: textTheme, + ), + ], + ), + ), + ); +} + +class _StackedElevationDemo extends StatelessWidget { + const _StackedElevationDemo({ + required this.boxShadow, + required this.colorScheme, + required this.textTheme, + }); + + final StreamBoxShadow boxShadow; + final StreamColorScheme colorScheme; + final StreamTextTheme textTheme; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: colorScheme.backgroundSurfaceSubtle, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colorScheme.borderSurfaceSubtle), + ), + child: Column( + children: [ + Row( + children: [ + Icon( + Icons.layers_outlined, + size: 16, + color: colorScheme.textSecondary, + ), + const SizedBox(width: 8), + Text( + 'Depth Hierarchy', + style: textTheme.captionEmphasis.copyWith( + color: colorScheme.textSecondary, + ), + ), + ], + ), + const SizedBox(height: 20), + SizedBox( + height: 180, + child: Stack( + alignment: Alignment.center, + children: [ + // Base layer + Positioned( + bottom: 0, + child: _buildLayer( + 'Base', + boxShadow.elevation1, + 160, + colorScheme.backgroundSurface.withValues(alpha: 0.6), + ), + ), + // elevation2 + Positioned( + bottom: 30, + child: _buildLayer( + 'elevation2', + boxShadow.elevation2, + 140, + colorScheme.backgroundSurface.withValues(alpha: 0.8), + ), + ), + // elevation3 + Positioned( + bottom: 60, + child: _buildLayer( + 'elevation3', + boxShadow.elevation3, + 120, + colorScheme.backgroundSurface.withValues(alpha: 0.9), + ), + ), + // elevation4 + Positioned( + bottom: 90, + child: _buildLayer( + 'elevation4', + boxShadow.elevation4, + 100, + colorScheme.backgroundSurface, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + Text( + 'Higher elevations appear closer to the viewer', + style: textTheme.metadataDefault.copyWith( + color: colorScheme.textTertiary, + fontStyle: FontStyle.italic, + ), + ), + ], + ), + ); + } + + Widget _buildLayer( + String label, + List shadow, + double width, + Color color, + ) { + return Container( + width: width, + height: 50, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(8), + boxShadow: shadow, + border: Border.all( + color: colorScheme.borderSurfaceSubtle.withValues(alpha: 0.5), + ), + ), + child: Center( + child: Text( + label, + style: textTheme.metadataEmphasis.copyWith( + color: colorScheme.textSecondary, + ), + ), + ), + ); + } +} + +class _ElevationGrid extends StatelessWidget { + const _ElevationGrid({ + required this.boxShadow, + required this.colorScheme, + required this.textTheme, + }); + + final StreamBoxShadow boxShadow; + final StreamColorScheme colorScheme; + final StreamTextTheme textTheme; + + @override + Widget build(BuildContext context) { + final elevations = [ + _ElevationData( + name: 'elevation1', + shadow: boxShadow.elevation1, + icon: Icons.layers_outlined, + description: 'Subtle lift for resting state', + useCases: ['Cards', 'List items', 'Input fields'], + ), + _ElevationData( + name: 'elevation2', + shadow: boxShadow.elevation2, + icon: Icons.flip_to_front, + description: 'Moderate lift for hover/focus', + useCases: ['Dropdowns', 'Menus', 'Popovers'], + ), + _ElevationData( + name: 'elevation3', + shadow: boxShadow.elevation3, + icon: Icons.picture_in_picture, + description: 'High lift for prominent UI', + useCases: ['Modals', 'Dialogs', 'Drawers'], + ), + _ElevationData( + name: 'elevation4', + shadow: boxShadow.elevation4, + icon: Icons.web_asset, + description: 'Highest lift for alerts', + useCases: ['Toasts', 'Notifications', 'Snackbars'], + ), + ]; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: colorScheme.accentPrimary, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'LEVELS', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w700, + letterSpacing: 1, + color: colorScheme.textOnAccent, + ), + ), + ), + const SizedBox(width: 10), + Text( + 'Tap to copy usage code', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textTertiary, + ), + ), + ], + ), + const SizedBox(height: 12), + ...elevations.map( + (e) => _ElevationCard( + data: e, + colorScheme: colorScheme, + textTheme: textTheme, + ), + ), + ], + ); + } +} + +class _ElevationCard extends StatelessWidget { + const _ElevationCard({ + required this.data, + required this.colorScheme, + required this.textTheme, + }); + + final _ElevationData data; + final StreamColorScheme colorScheme; + final StreamTextTheme textTheme; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: InkWell( + onTap: () { + Clipboard.setData( + ClipboardData(text: 'boxShadow: streamTheme.boxShadow.${data.name}'), + ); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Copied: boxShadow.${data.name}'), + duration: const Duration(seconds: 1), + behavior: SnackBarBehavior.floating, + ), + ); + }, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: colorScheme.backgroundSurface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colorScheme.borderSurfaceSubtle), + ), + child: Row( + children: [ + // Preview box with shadow + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: colorScheme.backgroundSurface, + borderRadius: BorderRadius.circular(10), + boxShadow: data.shadow, + ), + child: Icon( + data.icon, + color: colorScheme.textTertiary, + size: 24, + ), + ), + const SizedBox(width: 16), + // Info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + data.name, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 12, + fontWeight: FontWeight.w600, + color: colorScheme.textPrimary, + ), + ), + const SizedBox(width: 6), + Icon( + Icons.copy, + size: 11, + color: colorScheme.textTertiary, + ), + ], + ), + const SizedBox(height: 4), + Text( + data.description, + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + const SizedBox(height: 6), + Wrap( + spacing: 6, + runSpacing: 4, + children: data.useCases.map((use) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: colorScheme.backgroundSurfaceSubtle, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + use, + style: textTheme.metadataDefault.copyWith( + color: colorScheme.textTertiary, + ), + ), + ); + }).toList(), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +class _TechnicalDetails extends StatelessWidget { + const _TechnicalDetails({ + required this.boxShadow, + required this.colorScheme, + required this.textTheme, + }); + + final StreamBoxShadow boxShadow; + final StreamColorScheme colorScheme; + final StreamTextTheme textTheme; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: colorScheme.accentNeutral, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'TECHNICAL', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w700, + letterSpacing: 1, + color: colorScheme.textOnAccent, + ), + ), + ), + const SizedBox(width: 10), + Text( + 'Shadow values for each elevation', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textTertiary, + ), + ), + ], + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: colorScheme.backgroundSurface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colorScheme.borderSurfaceSubtle), + ), + child: Column( + children: [ + _ShadowRow( + name: 'elevation1', + shadows: boxShadow.elevation1, + colorScheme: colorScheme, + textTheme: textTheme, + ), + Divider(color: colorScheme.borderSurfaceSubtle, height: 20), + _ShadowRow( + name: 'elevation2', + shadows: boxShadow.elevation2, + colorScheme: colorScheme, + textTheme: textTheme, + ), + Divider(color: colorScheme.borderSurfaceSubtle, height: 20), + _ShadowRow( + name: 'elevation3', + shadows: boxShadow.elevation3, + colorScheme: colorScheme, + textTheme: textTheme, + ), + Divider(color: colorScheme.borderSurfaceSubtle, height: 20), + _ShadowRow( + name: 'elevation4', + shadows: boxShadow.elevation4, + colorScheme: colorScheme, + textTheme: textTheme, + ), + ], + ), + ), + ], + ); + } +} + +class _ShadowRow extends StatelessWidget { + const _ShadowRow({ + required this.name, + required this.shadows, + required this.colorScheme, + required this.textTheme, + }); + + final String name; + final List shadows; + final StreamColorScheme colorScheme; + final StreamTextTheme textTheme; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 11, + fontWeight: FontWeight.w600, + color: colorScheme.textPrimary, + ), + ), + const SizedBox(height: 6), + ...shadows.asMap().entries.map((entry) { + final shadow = entry.value; + final hex = shadow.color.toARGB32().toRadixString(16).toUpperCase(); + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + children: [ + Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: shadow.color, + borderRadius: BorderRadius.circular(2), + border: Border.all( + color: colorScheme.borderSurface.withValues(alpha: 0.3), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'offset(${shadow.offset.dx.toInt()}, ${shadow.offset.dy.toInt()}) ' + 'blur(${shadow.blurRadius.toInt()}) ' + 'spread(${shadow.spreadRadius.toInt()}) ' + '#$hex', + style: TextStyle( + fontFamily: 'monospace', + fontSize: 10, + color: colorScheme.textTertiary, + ), + ), + ), + ], + ), + ); + }), + ], + ); + } +} + +class _ElevationData { + const _ElevationData({ + required this.name, + required this.shadow, + required this.icon, + required this.description, + required this.useCases, + }); + + final String name; + final List shadow; + final IconData icon; + final String description; + final List useCases; +} diff --git a/apps/design_system_gallery/lib/semantics/typography.dart b/apps/design_system_gallery/lib/semantics/typography.dart new file mode 100644 index 0000000..34be88d --- /dev/null +++ b/apps/design_system_gallery/lib/semantics/typography.dart @@ -0,0 +1,446 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart'; + +@UseCase( + name: 'All Styles', + type: StreamTextTheme, + path: '[App Foundation]/Typography', +) +Widget buildStreamTextThemeShowcase(BuildContext context) { + final streamTheme = StreamTheme.of(context); + final textTheme = streamTheme.textTheme; + final colorScheme = streamTheme.colorScheme; + + return DefaultTextStyle( + style: TextStyle(color: colorScheme.textPrimary), + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Visual Type Scale + _TypeScale(textTheme: textTheme, colorScheme: colorScheme), + const SizedBox(height: 32), + + // Complete Reference + _CompleteReference(textTheme: textTheme, colorScheme: colorScheme), + ], + ), + ), + ); +} + +/// Visual type scale showing hierarchy at a glance +class _TypeScale extends StatelessWidget { + const _TypeScale({required this.textTheme, required this.colorScheme}); + + final StreamTextTheme textTheme; + final StreamColorScheme colorScheme; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionLabel(label: 'TYPE SCALE', colorScheme: colorScheme), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + colorScheme.backgroundSurface, + colorScheme.backgroundSurfaceSubtle, + ], + ), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: colorScheme.borderSurfaceSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ScaleItem( + label: 'Heading Lg', + style: textTheme.headingLg, + colorScheme: colorScheme, + ), + const SizedBox(height: 12), + _ScaleItem( + label: 'Heading Md', + style: textTheme.headingMd, + colorScheme: colorScheme, + ), + const SizedBox(height: 12), + _ScaleItem( + label: 'Heading Sm', + style: textTheme.headingSm, + colorScheme: colorScheme, + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Divider(color: colorScheme.borderSurfaceSubtle), + ), + _ScaleItem( + label: 'Body', + style: textTheme.bodyDefault, + colorScheme: colorScheme, + ), + const SizedBox(height: 12), + _ScaleItem( + label: 'Caption', + style: textTheme.captionDefault, + colorScheme: colorScheme, + ), + const SizedBox(height: 12), + _ScaleItem( + label: 'Metadata', + style: textTheme.metadataDefault, + colorScheme: colorScheme, + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Divider(color: colorScheme.borderSurfaceSubtle), + ), + _ScaleItem( + label: 'Numeric Lg', + style: textTheme.numericLg, + colorScheme: colorScheme, + sampleText: '1,234', + ), + const SizedBox(height: 12), + _ScaleItem( + label: 'Numeric Md', + style: textTheme.numericMd, + colorScheme: colorScheme, + sampleText: '99+', + ), + const SizedBox(height: 12), + _ScaleItem( + label: 'Numeric Sm', + style: textTheme.numericSm, + colorScheme: colorScheme, + sampleText: '5', + ), + ], + ), + ), + ], + ); + } +} + +class _ScaleItem extends StatelessWidget { + const _ScaleItem({ + required this.label, + required this.style, + required this.colorScheme, + this.sampleText, + }); + + final String label; + final TextStyle style; + final StreamColorScheme colorScheme; + final String? sampleText; + + @override + Widget build(BuildContext context) { + final size = style.fontSize?.toInt() ?? 0; + return Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + SizedBox( + width: 32, + child: Text( + '$size', + style: TextStyle( + fontFamily: 'monospace', + fontSize: 10, + color: colorScheme.textTertiary, + ), + ), + ), + Container( + width: 3, + height: size.toDouble().clamp(8, 40), + margin: const EdgeInsets.only(right: 12), + decoration: BoxDecoration( + color: colorScheme.accentPrimary, + borderRadius: BorderRadius.circular(2), + ), + ), + Expanded( + child: Text( + sampleText ?? 'The quick brown fox jumps over', + style: style.copyWith(color: colorScheme.textPrimary), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + Text( + label, + style: TextStyle( + fontSize: 9, + color: colorScheme.textTertiary, + fontWeight: FontWeight.w500, + ), + ), + ], + ); + } +} + +/// Complete reference table +class _CompleteReference extends StatelessWidget { + const _CompleteReference({ + required this.textTheme, + required this.colorScheme, + }); + + final StreamTextTheme textTheme; + final StreamColorScheme colorScheme; + + @override + Widget build(BuildContext context) { + final styles = [ + ('headingLg', textTheme.headingLg, 'Page titles'), + ('headingMd', textTheme.headingMd, 'Section headers'), + ('headingSm', textTheme.headingSm, 'Card titles'), + ('bodyDefault', textTheme.bodyDefault, 'Paragraphs'), + ('bodyEmphasis', textTheme.bodyEmphasis, 'Important text'), + ('bodyLink', textTheme.bodyLink, 'Links'), + ('bodyLinkEmphasis', textTheme.bodyLinkEmphasis, 'Bold links'), + ('captionDefault', textTheme.captionDefault, 'Labels'), + ('captionEmphasis', textTheme.captionEmphasis, 'Bold labels'), + ('captionLink', textTheme.captionLink, 'Small links'), + ('captionLinkEmphasis', textTheme.captionLinkEmphasis, 'Bold small links'), + ('metadataDefault', textTheme.metadataDefault, 'Timestamps'), + ('metadataEmphasis', textTheme.metadataEmphasis, 'Bold metadata'), + ('metadataLink', textTheme.metadataLink, 'Tiny links'), + ('metadataLinkEmphasis', textTheme.metadataLinkEmphasis, 'Bold tiny links'), + ('numericLg', textTheme.numericLg, 'Counters'), + ('numericMd', textTheme.numericMd, 'Badges'), + ('numericSm', textTheme.numericSm, 'Indicators'), + ]; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionLabel(label: 'REFERENCE', colorScheme: colorScheme), + const SizedBox(height: 16), + Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colorScheme.backgroundSurface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colorScheme.borderSurfaceSubtle), + ), + child: Column( + children: [ + // Header + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: colorScheme.backgroundSurfaceSubtle, + borderRadius: const BorderRadius.vertical(top: Radius.circular(11)), + ), + child: Row( + children: [ + Expanded( + flex: 3, + child: Text( + 'Name', + style: textTheme.metadataEmphasis.copyWith( + color: colorScheme.textSecondary, + ), + ), + ), + Expanded( + flex: 2, + child: Text( + 'Size', + style: textTheme.metadataEmphasis.copyWith( + color: colorScheme.textSecondary, + ), + ), + ), + Expanded( + flex: 2, + child: Text( + 'Weight', + style: textTheme.metadataEmphasis.copyWith( + color: colorScheme.textSecondary, + ), + ), + ), + Expanded( + flex: 2, + child: Text( + 'Use', + style: textTheme.metadataEmphasis.copyWith( + color: colorScheme.textSecondary, + ), + ), + ), + ], + ), + ), + // Rows + ...styles.asMap().entries.map((entry) { + final (name, style, usage) = entry.value; + final isLast = entry.key == styles.length - 1; + return _ReferenceRow( + name: name, + style: style, + usage: usage, + textTheme: textTheme, + colorScheme: colorScheme, + showBorder: !isLast, + ); + }), + ], + ), + ), + ], + ); + } +} + +class _ReferenceRow extends StatelessWidget { + const _ReferenceRow({ + required this.name, + required this.style, + required this.usage, + required this.textTheme, + required this.colorScheme, + required this.showBorder, + }); + + final String name; + final TextStyle style; + final String usage; + final StreamTextTheme textTheme; + final StreamColorScheme colorScheme; + final bool showBorder; + + @override + Widget build(BuildContext context) { + final size = style.fontSize?.toInt() ?? 0; + final weight = _weightName(style.fontWeight); + + return InkWell( + onTap: () { + Clipboard.setData(ClipboardData(text: 'textTheme.$name')); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Copied: textTheme.$name'), + duration: const Duration(seconds: 1), + behavior: SnackBarBehavior.floating, + ), + ); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + border: showBorder ? Border(bottom: BorderSide(color: colorScheme.borderSurfaceSubtle)) : null, + ), + child: Row( + children: [ + Expanded( + flex: 3, + child: Row( + children: [ + Text( + name, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 10, + fontWeight: FontWeight.w500, + color: colorScheme.textPrimary, + ), + ), + const SizedBox(width: 4), + Icon( + Icons.copy, + size: 10, + color: colorScheme.textTertiary, + ), + ], + ), + ), + Expanded( + flex: 2, + child: Text( + '${size}px', + style: textTheme.metadataDefault.copyWith( + color: colorScheme.textSecondary, + fontFamily: 'monospace', + ), + ), + ), + Expanded( + flex: 2, + child: Text( + weight, + style: textTheme.metadataDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + ), + Expanded( + flex: 2, + child: Text( + usage, + style: textTheme.metadataDefault.copyWith( + color: colorScheme.textTertiary, + ), + ), + ), + ], + ), + ), + ); + } + + String _weightName(FontWeight? weight) { + return switch (weight) { + FontWeight.w400 => 'Regular', + FontWeight.w500 => 'Medium', + FontWeight.w600 => 'Semi', + FontWeight.w700 => 'Bold', + _ => '${weight?.value ?? "?"}', + }; + } +} + +class _SectionLabel extends StatelessWidget { + const _SectionLabel({required this.label, required this.colorScheme}); + + final String label; + final StreamColorScheme colorScheme; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: colorScheme.accentPrimary, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + label, + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w700, + letterSpacing: 1, + color: colorScheme.textOnAccent, + ), + ), + ); + } +} diff --git a/apps/design_system_gallery/lib/theme_config.dart b/apps/design_system_gallery/lib/theme_config.dart deleted file mode 100644 index e8af065..0000000 --- a/apps/design_system_gallery/lib/theme_config.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_colorpicker/flutter_colorpicker.dart'; -import 'package:provider/provider.dart'; -import 'package:stream_core_flutter/stream_core_flutter.dart'; -import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; - -@widgetbook.UseCase(name: 'Default', type: ThemeConfig) -Widget buildCoolButtonUseCase(BuildContext context) { - return const ThemeConfig(); -} - -class ThemeConfig extends StatelessWidget { - const ThemeConfig({super.key}); - - @override - Widget build(BuildContext context) { - final themeConfiguration = Provider.of(context); - - return Column( - children: [ - const Text('Theme config'), - Row( - spacing: 16, - children: [ - Container( - width: 25, - height: 25, - color: themeConfiguration.themeData.primaryColor, - ), - const Text('Primary color'), - StreamButton( - label: 'Pick color', - onTap: () => pickColor( - context, - themeConfiguration.themeData.primaryColor ?? Theme.of(context).colorScheme.primary, - themeConfiguration.setPrimaryColor, - ), - ), - ], - ), - ], - ); - } - - Future pickColor( - BuildContext context, - Color pickerColor, - ValueChanged onColorChanged, - ) { - return showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Pick a color'), - content: SingleChildScrollView( - child: MaterialPicker( - pickerColor: pickerColor, - onColorChanged: onColorChanged, - ), - ), - ), - ); - } -} - -class ThemeConfiguration extends ChangeNotifier { - ThemeConfiguration({required this.themeData}); - ThemeConfiguration.empty() : themeData = StreamTheme(); - StreamTheme themeData; - - void setPrimaryColor(Color color) { - themeData = themeData.copyWith(primaryColor: color); - notifyListeners(); - } -} diff --git a/apps/design_system_gallery/lib/widgets/theme_studio/avatar_palette_section.dart b/apps/design_system_gallery/lib/widgets/theme_studio/avatar_palette_section.dart new file mode 100644 index 0000000..a135cb2 --- /dev/null +++ b/apps/design_system_gallery/lib/widgets/theme_studio/avatar_palette_section.dart @@ -0,0 +1,276 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_colorpicker/flutter_colorpicker.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +/// A tile for editing a single avatar color pair (background and foreground). +class AvatarColorPairTile extends StatelessWidget { + const AvatarColorPairTile({ + super.key, + required this.index, + required this.pair, + required this.colorScheme, + required this.onBackgroundChanged, + required this.onForegroundChanged, + this.onRemove, + }); + + final int index; + final StreamAvatarColorPair pair; + final StreamColorScheme colorScheme; + final ValueChanged onBackgroundChanged; + final ValueChanged onForegroundChanged; + final VoidCallback? onRemove; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Container( + padding: const EdgeInsets.all(10), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colorScheme.backgroundApp, + borderRadius: BorderRadius.circular(8), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colorScheme.borderSurfaceSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + // Preview avatar + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: pair.backgroundColor, + shape: BoxShape.circle, + ), + child: Center( + child: Text( + 'AB', + style: TextStyle( + color: pair.foregroundColor, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Palette ${index + 1}', + style: TextStyle( + color: colorScheme.textPrimary, + fontWeight: FontWeight.w600, + fontSize: 11, + ), + ), + ), + if (onRemove != null) + IconButton( + onPressed: onRemove, + icon: Icon( + Icons.remove_circle_outline, + color: colorScheme.accentError, + size: 18, + ), + tooltip: 'Remove', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _SmallColorButton( + label: 'backgroundColor', + color: pair.backgroundColor, + colorScheme: colorScheme, + onTap: () => _showColorPicker( + context, + 'backgroundColor', + pair.backgroundColor, + onBackgroundChanged, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _SmallColorButton( + label: 'foregroundColor', + color: pair.foregroundColor, + colorScheme: colorScheme, + onTap: () => _showColorPicker( + context, + 'foregroundColor', + pair.foregroundColor, + onForegroundChanged, + ), + ), + ), + ], + ), + ], + ), + ), + ); + } + + Future _showColorPicker( + BuildContext context, + String label, + Color initialColor, + ValueChanged onChanged, + ) async { + var pickerColor = initialColor; + + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text( + label, + style: const TextStyle(fontFamily: 'monospace', fontSize: 16), + ), + content: SingleChildScrollView( + child: ColorPicker( + pickerColor: pickerColor, + onColorChanged: (c) => pickerColor = c, + labelTypes: const [], + pickerAreaHeightPercent: 0.8, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + onChanged(pickerColor); + Navigator.of(context).pop(); + }, + child: const Text('Apply'), + ), + ], + ), + ); + } +} + +class _SmallColorButton extends StatelessWidget { + const _SmallColorButton({ + required this.label, + required this.color, + required this.colorScheme, + required this.onTap, + }); + + final String label; + final Color color; + final StreamColorScheme colorScheme; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(6), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colorScheme.backgroundSurface, + borderRadius: BorderRadius.circular(6), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + border: Border.all(color: colorScheme.borderSurfaceSubtle), + ), + child: Row( + children: [ + Container( + width: 16, + height: 16, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(3), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.circular(3), + border: Border.all( + color: colorScheme.borderSurface.withValues(alpha: 0.3), + ), + ), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + label, + style: TextStyle( + color: colorScheme.textSecondary, + fontSize: 9, + fontFamily: 'monospace', + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ); + } +} + +/// A button to add a new palette entry. +class AddPaletteButton extends StatelessWidget { + const AddPaletteButton({ + super.key, + required this.colorScheme, + required this.onTap, + }); + + final StreamColorScheme colorScheme; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colorScheme.borderSurfaceSubtle), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.add, color: colorScheme.accentPrimary, size: 16), + const SizedBox(width: 6), + Text( + 'Add Palette Entry', + style: TextStyle( + color: colorScheme.accentPrimary, + fontWeight: FontWeight.w500, + fontSize: 11, + ), + ), + ], + ), + ), + ); + } +} 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 new file mode 100644 index 0000000..8474f5a --- /dev/null +++ b/apps/design_system_gallery/lib/widgets/theme_studio/color_picker_tile.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_colorpicker/flutter_colorpicker.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +/// A tile that displays a color and opens a color picker when tapped. +class ColorPickerTile extends StatelessWidget { + const ColorPickerTile({ + super.key, + required this.label, + required this.color, + required this.colorScheme, + required this.boxShadow, + required this.onColorChanged, + }); + + final String label; + final Color color; + final StreamColorScheme colorScheme; + final StreamBoxShadow boxShadow; + final ValueChanged onColorChanged; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: InkWell( + onTap: () => _showColorPicker(context), + borderRadius: BorderRadius.circular(6), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colorScheme.backgroundApp, + borderRadius: BorderRadius.circular(6), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + border: Border.all(color: colorScheme.borderSurfaceSubtle), + ), + child: Row( + children: [ + Container( + width: 24, + height: 24, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(4), + boxShadow: boxShadow.elevation1, + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: colorScheme.borderSurface.withValues(alpha: 0.3), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + label, + style: TextStyle( + color: colorScheme.textPrimary, + fontWeight: FontWeight.w500, + fontSize: 11, + fontFamily: 'monospace', + ), + ), + ), + Text( + _colorToHex(color), + style: TextStyle( + color: colorScheme.textTertiary, + fontSize: 9, + fontFamily: 'monospace', + ), + ), + const SizedBox(width: 6), + Icon(Icons.edit, color: colorScheme.textTertiary, size: 12), + ], + ), + ), + ), + ); + } + + String _colorToHex(Color color) { + final hex = color.toARGB32().toRadixString(16).toUpperCase().padLeft(8, '0'); + return color.a < 1.0 ? '#$hex' : '#${hex.substring(2)}'; + } + + Future _showColorPicker(BuildContext context) async { + var pickerColor = color; + + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text( + label, + style: const TextStyle(fontFamily: 'monospace', fontSize: 16), + ), + content: SingleChildScrollView( + child: ColorPicker( + pickerColor: pickerColor, + onColorChanged: (c) => pickerColor = c, + labelTypes: const [], + pickerAreaHeightPercent: 0.8, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + onColorChanged(pickerColor); + Navigator.of(context).pop(); + }, + child: const Text('Apply'), + ), + ], + ), + ); + } +} diff --git a/apps/design_system_gallery/lib/widgets/theme_studio/mode_button.dart b/apps/design_system_gallery/lib/widgets/theme_studio/mode_button.dart new file mode 100644 index 0000000..15a4af4 --- /dev/null +++ b/apps/design_system_gallery/lib/widgets/theme_studio/mode_button.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +/// A button for selecting light or dark mode in the theme studio. +class ThemeStudioModeButton extends StatelessWidget { + const ThemeStudioModeButton({ + super.key, + required this.label, + required this.icon, + required this.isSelected, + required this.colorScheme, + required this.textTheme, + required this.onTap, + }); + + final String label; + final IconData icon; + final bool isSelected; + final StreamColorScheme colorScheme; + final StreamTextTheme textTheme; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: isSelected ? colorScheme.accentPrimary.withValues(alpha: 0.1) : colorScheme.backgroundApp, + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isSelected ? colorScheme.accentPrimary : colorScheme.borderSurfaceSubtle, + width: isSelected ? 2 : 1, + ), + ), + child: Column( + children: [ + Icon( + icon, + color: isSelected ? colorScheme.accentPrimary : colorScheme.textTertiary, + size: 20, + ), + const SizedBox(height: 4), + Text( + label, + style: textTheme.captionEmphasis.copyWith( + color: isSelected ? colorScheme.accentPrimary : colorScheme.textTertiary, + fontSize: 11, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/design_system_gallery/lib/widgets/theme_studio/section_card.dart b/apps/design_system_gallery/lib/widgets/theme_studio/section_card.dart new file mode 100644 index 0000000..5181f20 --- /dev/null +++ b/apps/design_system_gallery/lib/widgets/theme_studio/section_card.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +/// A card container for theme customization sections. +/// +/// Provides consistent styling for grouping related theme controls. +class SectionCard extends StatelessWidget { + const SectionCard({ + super.key, + required this.colorScheme, + required this.title, + required this.subtitle, + required this.icon, + required this.child, + }); + + final StreamColorScheme colorScheme; + final String title; + final String subtitle; + final IconData icon; + final Widget child; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colorScheme.backgroundSurface, + borderRadius: BorderRadius.circular(10), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all(color: colorScheme.borderSurfaceSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: colorScheme.textTertiary, size: 14), + const SizedBox(width: 6), + Text( + title, + style: TextStyle( + color: colorScheme.textPrimary, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + decoration: BoxDecoration( + color: colorScheme.backgroundSurfaceSubtle, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + subtitle, + style: TextStyle( + color: colorScheme.textTertiary, + fontSize: 9, + fontFamily: 'monospace', + ), + ), + ), + ], + ), + const SizedBox(height: 10), + child, + ], + ), + ); + } +} 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 new file mode 100644 index 0000000..4c74ec1 --- /dev/null +++ b/apps/design_system_gallery/lib/widgets/theme_studio/theme_customization_panel.dart @@ -0,0 +1,680 @@ +import 'dart:math' show Random; + +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +import '../../config/theme_configuration.dart'; +import 'avatar_palette_section.dart'; +import 'color_picker_tile.dart'; +import 'mode_button.dart'; +import 'section_card.dart'; + +final _random = Random(); + +/// Generates a random avatar color pair matching StreamColors shade patterns. +/// +/// Light mode: background shade100 (~95% lightness), foreground shade800 (~35% lightness) +/// Dark mode: background shade800 (~35% lightness), foreground shade100 (~95% lightness) +StreamAvatarColorPair _generateRandomAvatarPair({required bool isDark}) { + final hue = _random.nextDouble() * 360; + const saturation = 0.7; // Vivid like StreamColors + + // Lightness values approximating StreamColors shade100 and shade800 + const lightShade = 0.92; // ~shade100 + const darkShade = 0.35; // ~shade800 + + final lightColor = HSLColor.fromAHSL(1, hue, saturation, lightShade).toColor(); + final darkColor = HSLColor.fromAHSL(1, hue, saturation, darkShade).toColor(); + + return StreamAvatarColorPair( + backgroundColor: isDark ? darkColor : lightColor, + foregroundColor: isDark ? lightColor : darkColor, + ); +} + +/// A panel widget for customizing the Stream theme. +/// +/// Organized into sections matching [StreamColorScheme] structure. +class ThemeCustomizationPanel extends StatefulWidget { + const ThemeCustomizationPanel({ + super.key, + required this.configuration, + }); + + final ThemeConfiguration configuration; + + @override + State createState() => _ThemeCustomizationPanelState(); +} + +class _ThemeCustomizationPanelState extends State { + final _scrollController = ScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: widget.configuration, + builder: (context, _) { + final streamTheme = widget.configuration.themeData; + final colorScheme = streamTheme.colorScheme; + final textTheme = streamTheme.textTheme; + final boxShadow = streamTheme.boxShadow; + final materialTheme = widget.configuration.buildMaterialTheme(); + + // Wrap with Theme to apply Stream theming to all widgets + return Theme( + data: materialTheme, + child: DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.backgroundApp, + border: Border( + left: BorderSide(color: colorScheme.borderSurfaceSubtle), + ), + ), + child: Column( + children: [ + _buildHeader(colorScheme, textTheme), + Expanded( + child: Scrollbar( + controller: _scrollController, + thumbVisibility: true, + child: SingleChildScrollView( + controller: _scrollController, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildAppearanceSection(colorScheme, textTheme), + const SizedBox(height: 16), + _buildBrandSection(colorScheme, boxShadow), + const SizedBox(height: 16), + _buildAccentColorsSection(colorScheme, boxShadow), + const SizedBox(height: 16), + _buildTextColorsSection(colorScheme, boxShadow), + const SizedBox(height: 16), + _buildBackgroundColorsSection(colorScheme, boxShadow), + const SizedBox(height: 16), + _buildBorderCoreSection(colorScheme, boxShadow), + const SizedBox(height: 16), + _buildBorderUtilitySection(colorScheme, boxShadow), + const SizedBox(height: 16), + _buildStateColorsSection(colorScheme, boxShadow), + const SizedBox(height: 16), + _buildSystemColorsSection(colorScheme, boxShadow), + const SizedBox(height: 16), + _buildAvatarPaletteSection(colorScheme), + const SizedBox(height: 16), + ], + ), + ), + ), + ), + ], + ), + ), + ); + }, + ); + } + + Widget _buildHeader( + StreamColorScheme colorScheme, + StreamTextTheme textTheme, + ) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.backgroundSurface, + border: Border( + bottom: BorderSide(color: colorScheme.borderSurfaceSubtle), + ), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: colorScheme.accentPrimary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(Icons.tune, color: colorScheme.accentPrimary, size: 20), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Theme Studio', + style: textTheme.bodyEmphasis.copyWith( + color: colorScheme.textPrimary, + ), + ), + Text( + 'StreamColorScheme', + style: textTheme.metadataDefault.copyWith( + color: colorScheme.textTertiary, + fontFamily: 'monospace', + ), + ), + ], + ), + ), + // Reset button + Tooltip( + message: 'Reset to defaults', + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(6), + child: InkWell( + onTap: widget.configuration.resetToDefaults, + borderRadius: BorderRadius.circular(6), + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon( + Icons.restart_alt, + color: colorScheme.textTertiary, + size: 20, + ), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildAppearanceSection( + StreamColorScheme colorScheme, + StreamTextTheme textTheme, + ) { + return SectionCard( + colorScheme: colorScheme, + title: 'Appearance', + subtitle: 'brightness', + icon: Icons.brightness_6, + child: Row( + children: [ + Expanded( + child: ThemeStudioModeButton( + label: 'Light', + icon: Icons.light_mode, + isSelected: widget.configuration.brightness == Brightness.light, + colorScheme: colorScheme, + textTheme: textTheme, + onTap: () => widget.configuration.setBrightness(Brightness.light), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ThemeStudioModeButton( + label: 'Dark', + icon: Icons.dark_mode, + isSelected: widget.configuration.brightness == Brightness.dark, + colorScheme: colorScheme, + textTheme: textTheme, + onTap: () => widget.configuration.setBrightness(Brightness.dark), + ), + ), + ], + ), + ); + } + + Widget _buildBrandSection( + StreamColorScheme colorScheme, + StreamBoxShadow boxShadow, + ) { + final config = widget.configuration; + + return SectionCard( + colorScheme: colorScheme, + title: 'Brand Color', + subtitle: 'brand', + icon: Icons.branding_watermark, + child: ColorPickerTile( + label: 'brandPrimary', + color: config.brandPrimaryColor, + colorScheme: colorScheme, + boxShadow: boxShadow, + onColorChanged: config.setBrandPrimaryColor, + ), + ); + } + + Widget _buildAccentColorsSection( + StreamColorScheme colorScheme, + StreamBoxShadow boxShadow, + ) { + final config = widget.configuration; + return SectionCard( + colorScheme: colorScheme, + title: 'Accent Colors', + subtitle: 'accent*', + icon: Icons.color_lens, + child: Column( + children: [ + ColorPickerTile( + label: 'accentPrimary', + color: config.accentPrimary, + colorScheme: colorScheme, + boxShadow: boxShadow, + onColorChanged: config.setAccentPrimary, + ), + ColorPickerTile( + label: 'accentSuccess', + color: config.accentSuccess, + colorScheme: colorScheme, + boxShadow: boxShadow, + onColorChanged: config.setAccentSuccess, + ), + ColorPickerTile( + label: 'accentWarning', + color: config.accentWarning, + colorScheme: colorScheme, + boxShadow: boxShadow, + onColorChanged: config.setAccentWarning, + ), + ColorPickerTile( + label: 'accentError', + color: config.accentError, + colorScheme: colorScheme, + boxShadow: boxShadow, + onColorChanged: config.setAccentError, + ), + ColorPickerTile( + label: 'accentNeutral', + color: config.accentNeutral, + colorScheme: colorScheme, + boxShadow: boxShadow, + onColorChanged: config.setAccentNeutral, + ), + ], + ), + ); + } + + Widget _buildTextColorsSection( + StreamColorScheme colorScheme, + StreamBoxShadow boxShadow, + ) { + final config = widget.configuration; + return SectionCard( + colorScheme: colorScheme, + title: 'Text Colors', + subtitle: 'text*', + icon: Icons.format_color_text, + child: Column( + children: [ + ColorPickerTile( + label: 'textPrimary', + color: config.textPrimary, + colorScheme: colorScheme, + boxShadow: boxShadow, + onColorChanged: config.setTextPrimary, + ), + ColorPickerTile( + label: 'textSecondary', + color: config.textSecondary, + colorScheme: colorScheme, + boxShadow: boxShadow, + onColorChanged: config.setTextSecondary, + ), + ColorPickerTile( + label: 'textTertiary', + color: config.textTertiary, + colorScheme: colorScheme, + boxShadow: boxShadow, + onColorChanged: config.setTextTertiary, + ), + ColorPickerTile( + label: 'textDisabled', + color: config.textDisabled, + colorScheme: colorScheme, + boxShadow: boxShadow, + onColorChanged: config.setTextDisabled, + ), + ColorPickerTile( + label: 'textInverse', + color: config.textInverse, + colorScheme: colorScheme, + boxShadow: boxShadow, + onColorChanged: config.setTextInverse, + ), + ColorPickerTile( + label: 'textLink', + color: config.textLink, + colorScheme: colorScheme, + boxShadow: boxShadow, + onColorChanged: config.setTextLink, + ), + ColorPickerTile( + label: 'textOnAccent', + color: config.textOnAccent, + colorScheme: colorScheme, + boxShadow: boxShadow, + onColorChanged: config.setTextOnAccent, + ), + ], + ), + ); + } + + Widget _buildBackgroundColorsSection( + StreamColorScheme colorScheme, + StreamBoxShadow boxShadow, + ) { + final config = widget.configuration; + return SectionCard( + colorScheme: colorScheme, + title: 'Background Colors', + subtitle: 'background*', + icon: Icons.format_paint, + child: Column( + children: [ + ColorPickerTile( + label: 'backgroundApp', + color: config.backgroundApp, + colorScheme: colorScheme, + boxShadow: boxShadow, + onColorChanged: config.setBackgroundApp, + ), + ColorPickerTile( + label: 'backgroundSurface', + color: config.backgroundSurface, + colorScheme: colorScheme, + boxShadow: boxShadow, + onColorChanged: config.setBackgroundSurface, + ), + ColorPickerTile( + label: 'backgroundSurfaceSubtle', + color: config.backgroundSurfaceSubtle, + colorScheme: colorScheme, + boxShadow: boxShadow, + onColorChanged: config.setBackgroundSurfaceSubtle, + ), + ColorPickerTile( + label: 'backgroundSurfaceStrong', + color: config.backgroundSurfaceStrong, + colorScheme: colorScheme, + boxShadow: boxShadow, + onColorChanged: config.setBackgroundSurfaceStrong, + ), + ColorPickerTile( + label: 'backgroundOverlay', + color: config.backgroundOverlay, + colorScheme: colorScheme, + boxShadow: boxShadow, + onColorChanged: config.setBackgroundOverlay, + ), + ], + ), + ); + } + + Widget _buildBorderCoreSection( + StreamColorScheme colorScheme, + StreamBoxShadow boxShadow, + ) { + final config = widget.configuration; + return SectionCard( + colorScheme: colorScheme, + title: 'Border Colors - Core', + subtitle: 'border*', + icon: Icons.border_all, + child: Column( + children: [ + ColorPickerTile( + label: 'borderSurface', + color: config.borderSurface, + colorScheme: colorScheme, + boxShadow: boxShadow, + onColorChanged: config.setBorderSurface, + ), + ColorPickerTile( + label: 'borderSurfaceSubtle', + color: config.borderSurfaceSubtle, + colorScheme: colorScheme, + boxShadow: boxShadow, + onColorChanged: config.setBorderSurfaceSubtle, + ), + ColorPickerTile( + label: 'borderSurfaceStrong', + color: config.borderSurfaceStrong, + colorScheme: colorScheme, + boxShadow: boxShadow, + onColorChanged: config.setBorderSurfaceStrong, + ), + ColorPickerTile( + label: 'borderOnDark', + color: config.borderOnDark, + colorScheme: colorScheme, + boxShadow: boxShadow, + onColorChanged: config.setBorderOnDark, + ), + ColorPickerTile( + label: 'borderOnAccent', + color: config.borderOnAccent, + colorScheme: colorScheme, + boxShadow: boxShadow, + onColorChanged: config.setBorderOnAccent, + ), + ColorPickerTile( + label: 'borderSubtle', + color: config.borderSubtle, + colorScheme: colorScheme, + boxShadow: boxShadow, + onColorChanged: config.setBorderSubtle, + ), + ColorPickerTile( + label: 'borderImage', + color: config.borderImage, + colorScheme: colorScheme, + boxShadow: boxShadow, + onColorChanged: config.setBorderImage, + ), + ], + ), + ); + } + + Widget _buildBorderUtilitySection( + StreamColorScheme colorScheme, + StreamBoxShadow boxShadow, + ) { + final config = widget.configuration; + return SectionCard( + colorScheme: colorScheme, + title: 'Border Colors - Utility', + subtitle: 'border*', + icon: Icons.border_style, + child: Column( + children: [ + ColorPickerTile( + label: 'borderFocus', + color: config.borderFocus, + colorScheme: colorScheme, + boxShadow: boxShadow, + onColorChanged: config.setBorderFocus, + ), + ColorPickerTile( + label: 'borderDisabled', + color: config.borderDisabled, + colorScheme: colorScheme, + boxShadow: boxShadow, + onColorChanged: config.setBorderDisabled, + ), + ColorPickerTile( + label: 'borderError', + color: config.borderError, + colorScheme: colorScheme, + boxShadow: boxShadow, + onColorChanged: config.setBorderError, + ), + ColorPickerTile( + label: 'borderWarning', + color: config.borderWarning, + colorScheme: colorScheme, + boxShadow: boxShadow, + onColorChanged: config.setBorderWarning, + ), + ColorPickerTile( + label: 'borderSuccess', + color: config.borderSuccess, + colorScheme: colorScheme, + boxShadow: boxShadow, + onColorChanged: config.setBorderSuccess, + ), + ColorPickerTile( + label: 'borderSelected', + color: config.borderSelected, + colorScheme: colorScheme, + boxShadow: boxShadow, + onColorChanged: config.setBorderSelected, + ), + ], + ), + ); + } + + Widget _buildStateColorsSection( + StreamColorScheme colorScheme, + StreamBoxShadow boxShadow, + ) { + final config = widget.configuration; + return SectionCard( + colorScheme: colorScheme, + title: 'State Colors', + subtitle: 'state*', + icon: Icons.touch_app, + child: Column( + children: [ + ColorPickerTile( + label: 'stateHover', + color: config.stateHover, + colorScheme: colorScheme, + boxShadow: boxShadow, + onColorChanged: config.setStateHover, + ), + ColorPickerTile( + label: 'statePressed', + color: config.statePressed, + colorScheme: colorScheme, + boxShadow: boxShadow, + onColorChanged: config.setStatePressed, + ), + ColorPickerTile( + label: 'stateSelected', + color: config.stateSelected, + colorScheme: colorScheme, + boxShadow: boxShadow, + onColorChanged: config.setStateSelected, + ), + ColorPickerTile( + label: 'stateFocused', + color: config.stateFocused, + colorScheme: colorScheme, + boxShadow: boxShadow, + onColorChanged: config.setStateFocused, + ), + ColorPickerTile( + label: 'stateDisabled', + color: config.stateDisabled, + colorScheme: colorScheme, + boxShadow: boxShadow, + onColorChanged: config.setStateDisabled, + ), + ], + ), + ); + } + + Widget _buildSystemColorsSection( + StreamColorScheme colorScheme, + StreamBoxShadow boxShadow, + ) { + final config = widget.configuration; + return SectionCard( + colorScheme: colorScheme, + title: 'System Colors', + subtitle: 'system*', + icon: Icons.settings_system_daydream, + child: Column( + children: [ + ColorPickerTile( + label: 'systemText', + color: config.systemText, + colorScheme: colorScheme, + boxShadow: boxShadow, + onColorChanged: config.setSystemText, + ), + ColorPickerTile( + label: 'systemScrollbar', + color: config.systemScrollbar, + colorScheme: colorScheme, + boxShadow: boxShadow, + onColorChanged: config.setSystemScrollbar, + ), + ], + ), + ); + } + + Widget _buildAvatarPaletteSection(StreamColorScheme colorScheme) { + final config = widget.configuration; + final palette = config.avatarPalette; + + return SectionCard( + colorScheme: colorScheme, + title: 'Avatar Palette', + subtitle: 'avatarPalette', + icon: Icons.palette, + child: Column( + children: [ + ...List.generate(palette.length, (index) { + final pair = palette[index]; + return AvatarColorPairTile( + index: index, + pair: pair, + colorScheme: colorScheme, + onBackgroundChanged: (color) { + config.updateAvatarPaletteAt( + index, + StreamAvatarColorPair( + backgroundColor: color, + foregroundColor: pair.foregroundColor, + ), + ); + }, + onForegroundChanged: (color) { + config.updateAvatarPaletteAt( + index, + StreamAvatarColorPair( + backgroundColor: pair.backgroundColor, + foregroundColor: color, + ), + ); + }, + onRemove: palette.length > 1 ? () => config.removeAvatarPaletteAt(index) : null, + ); + }), + const SizedBox(height: 8), + AddPaletteButton( + colorScheme: colorScheme, + onTap: () { + final isDark = config.brightness == Brightness.dark; + config.addAvatarPaletteEntry(_generateRandomAvatarPair(isDark: isDark)); + }, + ), + ], + ), + ); + } +} 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 new file mode 100644 index 0000000..8adef0e --- /dev/null +++ b/apps/design_system_gallery/lib/widgets/theme_studio/theme_studio_widgets.dart @@ -0,0 +1,10 @@ +/// Theme Studio widgets for the design system gallery. +/// +/// This barrel file exports all theme customization-related widgets. +library; + +export 'avatar_palette_section.dart'; +export 'color_picker_tile.dart'; +export 'mode_button.dart'; +export 'section_card.dart'; +export 'theme_customization_panel.dart'; diff --git a/apps/design_system_gallery/lib/widgets/toolbar/device_selector.dart b/apps/design_system_gallery/lib/widgets/toolbar/device_selector.dart new file mode 100644 index 0000000..6cc5caf --- /dev/null +++ b/apps/design_system_gallery/lib/widgets/toolbar/device_selector.dart @@ -0,0 +1,75 @@ +import 'package:device_frame_plus/device_frame_plus.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +/// A dropdown selector for choosing the preview device. +class DeviceSelector extends StatelessWidget { + const DeviceSelector({ + super.key, + required this.selectedDevice, + required this.devices, + required this.colorScheme, + required this.textTheme, + required this.onDeviceChanged, + }); + + final DeviceInfo selectedDevice; + final List devices; + final StreamColorScheme colorScheme; + final StreamTextTheme textTheme; + final ValueChanged onDeviceChanged; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colorScheme.backgroundApp, + borderRadius: BorderRadius.circular(8), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colorScheme.borderSurfaceSubtle), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: selectedDevice, + icon: Icon( + Icons.unfold_more, + color: colorScheme.textTertiary, + size: 16, + ), + style: textTheme.captionDefault.copyWith( + color: colorScheme.textPrimary, + ), + dropdownColor: colorScheme.backgroundSurface, + items: devices.map((device) { + final isPhone = + device.name.toLowerCase().contains('iphone') || + device.name.toLowerCase().contains('phone') || + device.name.toLowerCase().contains('galaxy'); + return DropdownMenuItem( + value: device, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isPhone ? Icons.phone_iphone : Icons.tablet_mac, + size: 14, + color: colorScheme.textTertiary, + ), + const SizedBox(width: 8), + Text(device.name, style: const TextStyle(fontSize: 13)), + ], + ), + ); + }).toList(), + onChanged: (device) { + if (device != null) onDeviceChanged(device); + }, + ), + ), + ); + } +} diff --git a/apps/design_system_gallery/lib/widgets/toolbar/text_scale_selector.dart b/apps/design_system_gallery/lib/widgets/toolbar/text_scale_selector.dart new file mode 100644 index 0000000..03625dd --- /dev/null +++ b/apps/design_system_gallery/lib/widgets/toolbar/text_scale_selector.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +/// A dropdown selector for choosing the text scale factor. +class TextScaleSelector extends StatelessWidget { + const TextScaleSelector({ + super.key, + required this.value, + required this.options, + required this.colorScheme, + required this.textTheme, + required this.onChanged, + }); + + final double value; + final List options; + final StreamColorScheme colorScheme; + final StreamTextTheme textTheme; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colorScheme.backgroundApp, + borderRadius: BorderRadius.circular(8), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colorScheme.borderSurfaceSubtle), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: value, + icon: Icon( + Icons.unfold_more, + color: colorScheme.textTertiary, + size: 16, + ), + style: textTheme.captionDefault.copyWith( + color: colorScheme.textPrimary, + ), + dropdownColor: colorScheme.backgroundSurface, + items: options.map((scale) { + return DropdownMenuItem( + value: scale, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.text_fields, + size: 14, + color: colorScheme.textTertiary, + ), + const SizedBox(width: 8), + Text( + '${(scale * 100).toInt()}%', + style: const TextStyle(fontSize: 13), + ), + ], + ), + ); + }).toList(), + onChanged: (scale) { + if (scale != null) onChanged(scale); + }, + ), + ), + ); + } +} diff --git a/apps/design_system_gallery/lib/widgets/toolbar/theme_mode_toggle.dart b/apps/design_system_gallery/lib/widgets/toolbar/theme_mode_toggle.dart new file mode 100644 index 0000000..295529d --- /dev/null +++ b/apps/design_system_gallery/lib/widgets/toolbar/theme_mode_toggle.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +/// A toggle button for switching between light and dark theme modes. +class ThemeModeToggle extends StatelessWidget { + const ThemeModeToggle({ + super.key, + required this.isDark, + required this.colorScheme, + required this.onLightTap, + required this.onDarkTap, + }); + + final bool isDark; + final StreamColorScheme colorScheme; + final VoidCallback onLightTap; + final VoidCallback onDarkTap; + + @override + Widget build(BuildContext context) { + return Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colorScheme.backgroundApp, + borderRadius: BorderRadius.circular(8), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colorScheme.borderSurfaceSubtle), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _ModeButton( + icon: Icons.light_mode, + isSelected: !isDark, + colorScheme: colorScheme, + onTap: onLightTap, + borderRadius: const BorderRadius.horizontal(left: Radius.circular(7)), + ), + ColoredBox( + color: colorScheme.borderSurfaceSubtle, + child: const SizedBox(width: 1, height: 28), + ), + _ModeButton( + icon: Icons.dark_mode, + isSelected: isDark, + colorScheme: colorScheme, + onTap: onDarkTap, + borderRadius: const BorderRadius.horizontal(right: Radius.circular(7)), + ), + ], + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.icon, + required this.isSelected, + required this.colorScheme, + required this.onTap, + required this.borderRadius, + }); + + final IconData icon; + final bool isSelected; + final StreamColorScheme colorScheme; + final VoidCallback onTap; + final BorderRadius borderRadius; + + @override + Widget build(BuildContext context) { + return Material( + color: isSelected ? colorScheme.accentPrimary.withValues(alpha: 0.1) : Colors.transparent, + borderRadius: borderRadius, + child: InkWell( + onTap: onTap, + borderRadius: borderRadius, + child: Padding( + padding: const EdgeInsets.all(10), + child: Icon( + icon, + size: 18, + color: isSelected ? colorScheme.accentPrimary : colorScheme.textTertiary, + ), + ), + ), + ); + } +} diff --git a/apps/design_system_gallery/lib/widgets/toolbar/toolbar.dart b/apps/design_system_gallery/lib/widgets/toolbar/toolbar.dart new file mode 100644 index 0000000..0f6d7c1 --- /dev/null +++ b/apps/design_system_gallery/lib/widgets/toolbar/toolbar.dart @@ -0,0 +1,190 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +import '../../config/preview_configuration.dart'; +import '../../config/theme_configuration.dart'; +import 'device_selector.dart'; +import 'text_scale_selector.dart'; +import 'theme_mode_toggle.dart'; +import 'toolbar_button.dart'; + +/// The main toolbar for the design system gallery. +/// +/// Contains branding, device controls, and theme controls. +class GalleryToolbar extends StatelessWidget { + const GalleryToolbar({ + super.key, + required this.showThemePanel, + required this.onToggleThemePanel, + }); + + final bool showThemePanel; + final VoidCallback onToggleThemePanel; + + @override + Widget build(BuildContext context) { + final themeConfig = context.watch(); + final previewConfig = context.watch(); + final streamTheme = themeConfig.themeData; + final colorScheme = streamTheme.colorScheme; + final textTheme = streamTheme.textTheme; + final boxShadow = streamTheme.boxShadow; + final isDark = themeConfig.brightness == Brightness.dark; + + return Container( + height: 64, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: colorScheme.backgroundSurface, + border: Border( + bottom: BorderSide(color: colorScheme.borderSurfaceSubtle), + ), + ), + child: Row( + children: [ + // Stream Logo and title + _StreamBranding( + colorScheme: colorScheme, + textTheme: textTheme, + boxShadow: boxShadow, + ), + const SizedBox(width: 24), + + // Toolbar controls - wrapped in Expanded to prevent overflow + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Device frame toggle + ToolbarButton( + icon: previewConfig.showDeviceFrame ? Icons.devices : Icons.phone_android, + tooltip: 'Device Frame', + isActive: previewConfig.showDeviceFrame, + colorScheme: colorScheme, + onTap: previewConfig.toggleDeviceFrame, + ), + const SizedBox(width: 8), + + // Device selector + if (previewConfig.showDeviceFrame) ...[ + DeviceSelector( + selectedDevice: previewConfig.selectedDevice, + devices: PreviewConfiguration.deviceOptions, + colorScheme: colorScheme, + textTheme: textTheme, + onDeviceChanged: previewConfig.setDevice, + ), + const SizedBox(width: 8), + ], + + // Text scale selector + TextScaleSelector( + value: previewConfig.textScale, + options: PreviewConfiguration.textScaleOptions, + colorScheme: colorScheme, + textTheme: textTheme, + onChanged: previewConfig.setTextScale, + ), + ], + ), + ), + ), + + const SizedBox(width: 16), + + // Theme mode toggle + ThemeModeToggle( + isDark: isDark, + colorScheme: colorScheme, + onLightTap: () => themeConfig.setBrightness(Brightness.light), + onDarkTap: () => themeConfig.setBrightness(Brightness.dark), + ), + const SizedBox(width: 8), + + // Theme panel toggle + ToolbarButton( + icon: showThemePanel ? Icons.palette : Icons.palette_outlined, + tooltip: 'Theme Studio', + isActive: showThemePanel, + colorScheme: colorScheme, + onTap: onToggleThemePanel, + ), + ], + ), + ); + } +} + +/// Stream branding logo and title. +class _StreamBranding extends StatelessWidget { + const _StreamBranding({ + required this.colorScheme, + required this.textTheme, + required this.boxShadow, + }); + + final StreamColorScheme colorScheme; + final StreamTextTheme textTheme; + final StreamBoxShadow boxShadow; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Stream Logo + Container( + width: 40, + height: 40, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + colorScheme.accentPrimary, + colorScheme.accentPrimary.withValues(alpha: 0.7), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(10), + boxShadow: boxShadow.elevation2, + ), + child: Center( + child: Text( + 'S', + style: TextStyle( + color: colorScheme.textOnAccent, + fontSize: 22, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + ), + ), + ), + ), + const SizedBox(width: 14), + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Stream', + style: textTheme.headingSm.copyWith( + color: colorScheme.textPrimary, + letterSpacing: -0.5, + ), + ), + Text( + 'Design System', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textTertiary, + ), + ), + ], + ), + ], + ); + } +} diff --git a/apps/design_system_gallery/lib/widgets/toolbar/toolbar_button.dart b/apps/design_system_gallery/lib/widgets/toolbar/toolbar_button.dart new file mode 100644 index 0000000..81ad31c --- /dev/null +++ b/apps/design_system_gallery/lib/widgets/toolbar/toolbar_button.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +/// A reusable toolbar button with icon and tooltip. +class ToolbarButton extends StatelessWidget { + const ToolbarButton({ + super.key, + required this.icon, + required this.tooltip, + required this.isActive, + required this.colorScheme, + required this.onTap, + }); + + final IconData icon; + final String tooltip; + final bool isActive; + final StreamColorScheme colorScheme; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Tooltip( + message: tooltip, + child: Material( + color: isActive ? colorScheme.accentPrimary.withValues(alpha: 0.1) : colorScheme.backgroundApp, + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.all(10), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isActive ? colorScheme.accentPrimary : colorScheme.borderSurfaceSubtle, + ), + ), + child: Icon( + icon, + size: 20, + color: isActive ? colorScheme.accentPrimary : colorScheme.textSecondary, + ), + ), + ), + ), + ); + } +} diff --git a/apps/design_system_gallery/lib/widgets/toolbar/toolbar_widgets.dart b/apps/design_system_gallery/lib/widgets/toolbar/toolbar_widgets.dart new file mode 100644 index 0000000..c6c2206 --- /dev/null +++ b/apps/design_system_gallery/lib/widgets/toolbar/toolbar_widgets.dart @@ -0,0 +1,10 @@ +/// Toolbar widgets for the design system gallery. +/// +/// This barrel file exports all toolbar-related widgets. +library; + +export 'device_selector.dart'; +export 'text_scale_selector.dart'; +export 'theme_mode_toggle.dart'; +export 'toolbar.dart'; +export 'toolbar_button.dart'; diff --git a/apps/design_system_gallery/macos/Runner/DebugProfile.entitlements b/apps/design_system_gallery/macos/Runner/DebugProfile.entitlements index dddb8a3..3ba6c12 100644 --- a/apps/design_system_gallery/macos/Runner/DebugProfile.entitlements +++ b/apps/design_system_gallery/macos/Runner/DebugProfile.entitlements @@ -6,6 +6,8 @@ com.apple.security.cs.allow-jit + com.apple.security.network.client + com.apple.security.network.server diff --git a/apps/design_system_gallery/macos/Runner/Release.entitlements b/apps/design_system_gallery/macos/Runner/Release.entitlements index 852fa1a..ee95ab7 100644 --- a/apps/design_system_gallery/macos/Runner/Release.entitlements +++ b/apps/design_system_gallery/macos/Runner/Release.entitlements @@ -4,5 +4,7 @@ com.apple.security.app-sandbox + com.apple.security.network.client + diff --git a/apps/design_system_gallery/pubspec.yaml b/apps/design_system_gallery/pubspec.yaml index b3dd78d..65c02a4 100644 --- a/apps/design_system_gallery/pubspec.yaml +++ b/apps/design_system_gallery/pubspec.yaml @@ -1,13 +1,14 @@ name: design_system_gallery -description: "A new Flutter project." +description: "Stream Design System Gallery - A comprehensive widgetbook for exploring and customizing Stream components." publish_to: 'none' version: 1.0.0+1 environment: sdk: ^3.10.0 + flutter: ">=3.38.1" dependencies: - cupertino_icons: ^1.0.8 + device_frame_plus: ^1.0.0 flutter: sdk: flutter flutter_colorpicker: ^1.1.0 diff --git a/melos.yaml b/melos.yaml index b87e969..3f7da17 100644 --- a/melos.yaml +++ b/melos.yaml @@ -18,6 +18,7 @@ command: # List of all the dependencies used in the project. dependencies: + cached_network_image: ^3.4.1 collection: ^1.19.0 cross_file: ^0.3.4+2 dio: ^5.8.0+1 @@ -29,7 +30,9 @@ command: meta: ^1.15.0 mime: ^2.0.0 rxdart: ^0.28.0 + stream_core: ^0.4.0 synchronized: ^3.3.0 + theme_extensions_builder_annotation: ^7.1.0 uuid: ^4.5.1 web: ^1.1.1 web_socket_channel: ^3.0.1 @@ -41,6 +44,7 @@ command: melos: ^6.2.0 mocktail: ^1.0.4 test: ^1.26.2 + theme_extensions_builder: ^7.1.0 scripts: postclean: diff --git a/packages/stream_core_flutter/lib/src/components.dart b/packages/stream_core_flutter/lib/src/components.dart index c7b076f..d4a4b13 100644 --- a/packages/stream_core_flutter/lib/src/components.dart +++ b/packages/stream_core_flutter/lib/src/components.dart @@ -1 +1,4 @@ -export 'components/stream_button.dart' hide DefaultStreamButton; +export 'components/avatar/stream_avatar.dart'; +export 'components/avatar/stream_avatar_stack.dart'; +export 'components/indicator/stream_online_indicator.dart'; +export 'factory/components/stream_button.dart' hide DefaultStreamButton; diff --git a/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar.dart b/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar.dart new file mode 100644 index 0000000..6e9155a --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar.dart @@ -0,0 +1,226 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; + +import '../../theme/components/stream_avatar_theme.dart'; +import '../../theme/primitives/stream_colors.dart'; +import '../../theme/semantics/stream_color_scheme.dart'; +import '../../theme/semantics/stream_text_theme.dart'; +import '../../theme/stream_theme_extensions.dart'; + +/// A circular avatar component for the Stream design system. +/// +/// [StreamAvatar] displays a user's profile image or a placeholder (typically +/// initials or an icon) when no image is available. It supports multiple sizes, +/// customizable colors, and an optional border. +/// +/// The avatar automatically handles: +/// - Loading states while fetching network images +/// - Error states when images fail to load +/// - Text scaling (disabled to prevent overflow) +/// - Theme-aware colors with light/dark mode support +/// +/// {@tool snippet} +/// +/// Basic usage with initials placeholder: +/// +/// ```dart +/// StreamAvatar( +/// imageUrl: user.avatarUrl, +/// placeholder: (context) => Text(user.initials), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Custom size and colors: +/// +/// ```dart +/// StreamAvatar( +/// imageUrl: user.avatarUrl, +/// size: StreamAvatarSize.sm, +/// backgroundColor: Colors.blue.shade100, +/// foregroundColor: Colors.blue.shade800, +/// placeholder: (context) => Text(user.initials), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// With icon placeholder: +/// +/// ```dart +/// StreamAvatar( +/// size: StreamAvatarSize.md, +/// showBorder: false, +/// placeholder: (context) => Icon(Icons.person), +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Theming +/// +/// [StreamAvatar] uses [StreamAvatarThemeData] for default styling. Colors +/// can be customized globally via the theme or per-instance via constructor +/// parameters. The color palette for deterministic user-based colors is +/// available in [StreamColorScheme.avatarPalette]. +/// +/// See also: +/// +/// * [StreamAvatarSize], which defines the available size variants. +/// * [StreamAvatarThemeData], which provides theme-level customization. +/// * [StreamColorScheme.avatarPalette], which provides colors for user avatars. +class StreamAvatar extends StatelessWidget { + /// Creates a Stream avatar. + /// + /// The [placeholder] is required and is shown when [imageUrl] is null, + /// while the image is loading, or if the image fails to load. + const StreamAvatar({ + super.key, + this.size, + this.imageUrl, + required this.placeholder, + this.backgroundColor, + this.foregroundColor, + this.showBorder = true, + }); + + /// The URL of the avatar image. + /// + /// When null, the [placeholder] is displayed. The image is loaded + /// asynchronously with caching support. + final String? imageUrl; + + /// The size of the avatar. + /// + /// If null, uses [StreamAvatarThemeData.size], or falls back to + /// [StreamAvatarSize.lg] (40.0). + final StreamAvatarSize? size; + + /// A builder for the placeholder content. + /// + /// This is displayed when [imageUrl] is null, while the image is loading, + /// or if the image fails to load. Typically contains initials text or + /// an icon. + /// + /// The placeholder inherits [DefaultTextStyle] and [IconTheme] from the + /// avatar, so text and icons will automatically use [foregroundColor]. + final WidgetBuilder placeholder; + + /// The background color of the avatar. + /// + /// If null, uses [StreamAvatarThemeData.backgroundColor], or falls back + /// to the first color in [StreamColorScheme.avatarPalette]. + final Color? backgroundColor; + + /// The foreground color for text and icons in the placeholder. + /// + /// If null, uses [StreamAvatarThemeData.foregroundColor], or falls back + /// to the first color in [StreamColorScheme.avatarPalette]. + final Color? foregroundColor; + + /// Whether to show a border around the avatar. + /// + /// Defaults to true. The border color is determined by + /// [StreamAvatarThemeData.borderColor]. + final bool showBorder; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final textTheme = context.streamTextTheme; + final avatarTheme = context.streamAvatarTheme; + final defaults = _StreamAvatarThemeDefaults(context); + + final effectiveSize = size ?? avatarTheme.size ?? defaults.size; + final effectiveBackgroundColor = backgroundColor ?? avatarTheme.backgroundColor ?? defaults.backgroundColor; + final effectiveForegroundColor = foregroundColor ?? avatarTheme.foregroundColor ?? defaults.foregroundColor; + final effectiveBorderColor = avatarTheme.borderColor ?? defaults.borderColor; + + final border = showBorder ? Border.all(color: effectiveBorderColor) : null; + final textStyle = _textStyleForSize(effectiveSize, textTheme).copyWith(color: effectiveForegroundColor); + final iconTheme = theme.iconTheme.copyWith(color: effectiveForegroundColor, size: _iconSizeForSize(effectiveSize)); + + return AnimatedContainer( + alignment: .center, + clipBehavior: .antiAlias, + width: effectiveSize.value, + height: effectiveSize.value, + duration: kThemeChangeDuration, + foregroundDecoration: BoxDecoration(shape: .circle, border: border), + decoration: BoxDecoration(shape: .circle, color: effectiveBackgroundColor), + child: Center( + // Need to disable text scaling here so that the text doesn't + // escape the avatar when the textScaleFactor is large. + child: MediaQuery.withNoTextScaling( + child: IconTheme( + data: iconTheme, + child: DefaultTextStyle( + style: textStyle, + child: switch (imageUrl) { + final imageUrl? => CachedNetworkImage( + fit: .cover, + imageUrl: imageUrl, + width: effectiveSize.value, + height: effectiveSize.value, + placeholder: (context, _) => Center(child: placeholder.call(context)), + errorWidget: (context, _, _) => Center(child: placeholder.call(context)), + ), + _ => placeholder.call(context), + }, + ), + ), + ), + ), + ); + } + + // Returns the appropriate text style for the given avatar size. + TextStyle _textStyleForSize( + StreamAvatarSize size, + StreamTextTheme textTheme, + ) => switch (size) { + .xs => textTheme.metadataEmphasis, + .sm || .md => textTheme.captionEmphasis, + .lg => textTheme.bodyEmphasis, + }; + + // Returns the appropriate icon size for the given avatar size. + double _iconSizeForSize( + StreamAvatarSize size, + ) => switch (size) { + .xs => 10, + .sm => 12, + .md => 16, + .lg => 20, + }; +} + +// Default theme values for [StreamAvatar]. +// +// These defaults are used when no explicit value is provided via +// constructor parameters or [StreamAvatarThemeData]. +// +// The defaults are context-aware and use colors from +// [StreamColorScheme.avatarPalette]. +class _StreamAvatarThemeDefaults extends StreamAvatarThemeData { + _StreamAvatarThemeDefaults( + this.context, + ) : _colorScheme = context.streamColorScheme; + + final BuildContext context; + final StreamColorScheme _colorScheme; + + @override + StreamAvatarSize get size => StreamAvatarSize.lg; + + @override + Color get borderColor => StreamColors.black10; + + @override + Color get backgroundColor => _colorScheme.avatarPalette.first.backgroundColor; + + @override + Color get foregroundColor => _colorScheme.avatarPalette.first.foregroundColor; +} diff --git a/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_stack.dart b/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_stack.dart new file mode 100644 index 0000000..4348965 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_stack.dart @@ -0,0 +1,184 @@ +import 'package:flutter/material.dart'; + +import '../../theme/components/stream_avatar_theme.dart'; +import '../../theme/stream_theme_extensions.dart'; +import 'stream_avatar.dart'; + +/// A widget that displays a stack of [StreamAvatar] widgets with overlap. +/// +/// This is useful for showing multiple participants in a chat, group, or team. +/// The [size], [overlap], and [max] can be customized. +/// +/// {@tool snippet} +/// +/// Basic usage with default size and overlap: +/// +/// ```dart +/// StreamAvatarStack( +/// children: [ +/// StreamAvatar(placeholder: (context) => Text('A')), +/// StreamAvatar(placeholder: (context) => Text('B')), +/// StreamAvatar(placeholder: (context) => Text('C')), +/// ], +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// With max limit showing "+X" for overflow: +/// +/// ```dart +/// StreamAvatarStack( +/// max: 3, +/// children: [ +/// StreamAvatar(placeholder: (context) => Text('A')), +/// StreamAvatar(placeholder: (context) => Text('B')), +/// StreamAvatar(placeholder: (context) => Text('C')), +/// StreamAvatar(placeholder: (context) => Text('D')), +/// StreamAvatar(placeholder: (context) => Text('E')), +/// ], +/// ) +/// // Shows: [A] [B] [C] [+2] +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Custom size and overlap: +/// +/// ```dart +/// StreamAvatarStack( +/// size: StreamAvatarSize.sm, +/// overlap: 0.5, // 50% overlap +/// children: [ +/// StreamAvatar(placeholder: (context) => Text('A')), +/// StreamAvatar(placeholder: (context) => Text('B')), +/// StreamAvatar(placeholder: (context) => Text('C')), +/// ], +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamAvatar], the individual avatar widget. +/// * [StreamAvatarThemeData], for customizing avatar theme properties. +class StreamAvatarStack extends StatelessWidget { + /// Creates a [StreamAvatarStack] with the given children. + /// + /// The [children] are typically [StreamAvatar] widgets. + /// The [overlap] controls how much each avatar overlaps the previous one, + /// ranging from 0.0 (no overlap) to 1.0 (fully stacked). + const StreamAvatarStack({ + super.key, + this.size, + required this.children, + this.overlap = 0.33, + this.max = 5, + this.extraAvatarBuilder, + }) : assert(max >= 2, 'max must be at least 2'); + + /// The list of widgets to display in the stack. + /// + /// Typically a list of [StreamAvatar] widgets. + final List children; + + /// The size of the avatars in the stack. + /// + /// If null, uses [StreamAvatarThemeData.size], or falls back to + /// [StreamAvatarSize.lg]. + final StreamAvatarSize? size; + + /// How much each avatar overlaps the previous one, as a fraction of size. + /// + /// - `0.0`: No overlap (side by side) + /// - `0.33`: 33% overlap (default) + /// - `1.0`: Fully stacked + final double overlap; + + /// Maximum number of avatars to display before showing "+X". + /// + /// When [children] exceeds this value, displays [max] avatars followed + /// by a "+X" avatar showing the overflow count. + /// + /// Must be at least 2. Defaults to 5. + final int max; + + /// Builder for the extra avatar showing the overflow count. + /// + /// If null, a default [StreamAvatar] with "+X" text is used. + /// + /// The [extraCount] parameter indicates how many avatars are hidden. + /// + /// {@tool snippet} + /// + /// Custom extra avatar: + /// + /// ```dart + /// StreamAvatarStack( + /// max: 3, + /// extraAvatarBuilder: (context, extraCount) => StreamAvatar( + /// backgroundColor: Colors.grey, + /// placeholder: (context) => Text('+$extraCount'), + /// ), + /// children: [...], + /// ) + /// ``` + /// {@end-tool} + final Widget Function(BuildContext context, int extraCount)? extraAvatarBuilder; + + @override + Widget build(BuildContext context) { + if (children.isEmpty) return const SizedBox.shrink(); + + final avatarTheme = context.streamAvatarTheme; + final colorScheme = context.streamColorScheme; + + final effectiveSize = size ?? avatarTheme.size ?? .lg; + final diameter = effectiveSize.value; + + // Split children into visible and overflow + final visible = children.take(max).toList(); + final extraCount = children.length - visible.length; + + // Build the list of widgets to display + final displayChildren = [ + ...visible, + if (extraCount > 0) ...[ + switch (extraAvatarBuilder) { + final builder? => builder.call(context, extraCount), + _ => StreamAvatar( + backgroundColor: colorScheme.backgroundSurfaceStrong, + foregroundColor: colorScheme.textSecondary, + placeholder: (context) => Text('+$extraCount'), + ), + }, + ], + ]; + + // Calculate the offset between each avatar (how much of each avatar is visible) + final visiblePortion = diameter * (1 - overlap); + + // Total width: first avatar full + remaining avatars visible portion + final totalWidth = diameter + (displayChildren.length - 1) * visiblePortion; + + return SizedBox( + width: totalWidth, + height: diameter, + child: Stack( + alignment: AlignmentDirectional.center, + children: [ + for (var i = 0; i < displayChildren.length; i++) + Positioned( + left: i * visiblePortion, + child: StreamAvatarTheme( + data: StreamAvatarThemeData(size: effectiveSize), + child: displayChildren[i], + ), + ), + ], + ), + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/components/indicator/stream_online_indicator.dart b/packages/stream_core_flutter/lib/src/components/indicator/stream_online_indicator.dart new file mode 100644 index 0000000..cb1a29b --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/indicator/stream_online_indicator.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; + +import '../../theme/components/stream_online_indicator_theme.dart'; +import '../../theme/semantics/stream_color_scheme.dart'; +import '../../theme/stream_theme_extensions.dart'; + +/// Predefined sizes for the online indicator. +/// +/// Each size corresponds to a specific diameter in logical pixels. +/// +/// See also: +/// +/// * [StreamOnlineIndicator], which uses these size variants. +/// * [StreamOnlineIndicatorThemeData], for customizing indicator appearance. +enum StreamOnlineIndicatorSize { + /// Small indicator (8px diameter). + sm(8), + + /// Medium indicator (12px diameter). + md(12), + + /// Large indicator (14px diameter). + lg(14) + ; + + const StreamOnlineIndicatorSize(this.value); + + /// The diameter of the indicator in logical pixels. + final double value; +} + +/// A circular indicator showing online/offline presence status. +/// +/// This indicator is typically positioned on or near an avatar to show +/// whether a user is currently online or offline. +/// +/// {@tool snippet} +/// +/// Basic usage: +/// +/// ```dart +/// StreamOnlineIndicator(isOnline: true) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Positioned on an avatar: +/// +/// ```dart +/// Stack( +/// children: [ +/// StreamAvatar(placeholder: (context) => Text('AB')), +/// Positioned( +/// right: 0, +/// bottom: 0, +/// child: StreamOnlineIndicator(isOnline: user.isOnline), +/// ), +/// ], +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Custom size: +/// +/// ```dart +/// StreamOnlineIndicator( +/// isOnline: false, +/// size: StreamOnlineIndicatorSize.lg, +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamOnlineIndicatorThemeData], for customizing indicator appearance. +/// * [StreamOnlineIndicatorTheme], for overriding theme in a widget subtree. +/// * [StreamAvatar], which often displays this indicator. +class StreamOnlineIndicator extends StatelessWidget { + /// Creates an online indicator. + const StreamOnlineIndicator({ + super.key, + required this.isOnline, + this.size, + }); + + /// Whether the user is online. + /// + /// When true, displays [StreamOnlineIndicatorThemeData.backgroundOnline]. + /// When false, displays [StreamOnlineIndicatorThemeData.backgroundOffline]. + final bool isOnline; + + /// The size of the indicator. + /// + /// Defaults to [StreamOnlineIndicatorSize.lg]. + final StreamOnlineIndicatorSize? size; + + @override + Widget build(BuildContext context) { + final onlineIndicatorTheme = context.streamOnlineIndicatorTheme; + final defaults = _StreamOnlineIndicatorThemeDefaults(context); + + final effectiveSize = size ?? StreamOnlineIndicatorSize.lg; + final effectiveBackgroundOnline = onlineIndicatorTheme.backgroundOnline ?? defaults.backgroundOnline; + final effectiveBackgroundOffline = onlineIndicatorTheme.backgroundOffline ?? defaults.backgroundOffline; + final effectiveBorderColor = onlineIndicatorTheme.borderColor ?? defaults.borderColor; + + final color = isOnline ? effectiveBackgroundOnline : effectiveBackgroundOffline; + final border = Border.all(color: effectiveBorderColor, width: _borderWidthForSize(effectiveSize)); + + return AnimatedContainer( + width: effectiveSize.value, + height: effectiveSize.value, + duration: kThemeChangeDuration, + decoration: BoxDecoration(shape: BoxShape.circle, color: color), + foregroundDecoration: BoxDecoration(shape: BoxShape.circle, border: border), + ); + } + + // Returns the appropriate border width for the given indicator size. + double _borderWidthForSize( + StreamOnlineIndicatorSize size, + ) => switch (size) { + .sm => 1, + .md || .lg => 2, + }; +} + +// Provides default values for [StreamOnlineIndicatorThemeData] based on +// the current [StreamColorScheme]. +class _StreamOnlineIndicatorThemeDefaults extends StreamOnlineIndicatorThemeData { + _StreamOnlineIndicatorThemeDefaults(this.context) : _colorScheme = context.streamColorScheme; + + final BuildContext context; + final StreamColorScheme _colorScheme; + + @override + Color get backgroundOnline => _colorScheme.accentSuccess; + + @override + Color get backgroundOffline => _colorScheme.accentNeutral; + + @override + Color get borderColor => _colorScheme.borderOnDark; +} diff --git a/packages/stream_core_flutter/lib/src/theme/components.dart b/packages/stream_core_flutter/lib/src/factory/components.dart similarity index 100% rename from packages/stream_core_flutter/lib/src/theme/components.dart rename to packages/stream_core_flutter/lib/src/factory/components.dart diff --git a/packages/stream_core_flutter/lib/src/components/stream_button.dart b/packages/stream_core_flutter/lib/src/factory/components/stream_button.dart similarity index 96% rename from packages/stream_core_flutter/lib/src/components/stream_button.dart rename to packages/stream_core_flutter/lib/src/factory/components/stream_button.dart index 21a2b81..bb1f0b5 100644 --- a/packages/stream_core_flutter/lib/src/components/stream_button.dart +++ b/packages/stream_core_flutter/lib/src/factory/components/stream_button.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; -import '../../stream_core_flutter.dart'; -import '../theme/stream_component_factory.dart'; +import '../stream_component_factory.dart' show StreamComponentBuilder; +import '../stream_theme.dart'; +import 'stream_button_theme.dart'; class StreamButton extends StatelessWidget { StreamButton({ diff --git a/packages/stream_core_flutter/lib/src/factory/components/stream_button_theme.dart b/packages/stream_core_flutter/lib/src/factory/components/stream_button_theme.dart new file mode 100644 index 0000000..17d1053 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/factory/components/stream_button_theme.dart @@ -0,0 +1,14 @@ + +import 'package:flutter/widgets.dart'; + +import '../stream_theme.dart'; + +class StreamButtonTheme { + StreamButtonTheme({this.primaryColor}); + + final WidgetStateProperty? primaryColor; + + static StreamButtonTheme of(BuildContext context) { + return StreamTheme.of(context).buttonTheme; + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/stream_component_factory.dart b/packages/stream_core_flutter/lib/src/factory/stream_component_factory.dart similarity index 75% rename from packages/stream_core_flutter/lib/src/theme/stream_component_factory.dart rename to packages/stream_core_flutter/lib/src/factory/stream_component_factory.dart index 687ac2a..39d6289 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_component_factory.dart +++ b/packages/stream_core_flutter/lib/src/factory/stream_component_factory.dart @@ -1,7 +1,7 @@ +import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; -import '../../stream_core_flutter.dart'; -import '../components/stream_button.dart' show DefaultStreamButton; +import 'components/stream_button.dart' show DefaultStreamButton, StreamButtonProps; typedef StreamComponentBuilder = Widget Function(BuildContext context, T props); diff --git a/packages/stream_core_flutter/lib/src/factory/stream_theme.dart b/packages/stream_core_flutter/lib/src/factory/stream_theme.dart new file mode 100644 index 0000000..8c1e77f --- /dev/null +++ b/packages/stream_core_flutter/lib/src/factory/stream_theme.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +import 'components/stream_button_theme.dart'; +import 'stream_component_factory.dart'; + +class StreamTheme extends ThemeExtension { + StreamTheme({ + StreamComponentFactory? componentFactory, + this.primaryColor, + StreamButtonTheme? buttonTheme, + }) : componentFactory = componentFactory ?? StreamComponentFactory(), + buttonTheme = buttonTheme ?? StreamButtonTheme(); + + final StreamComponentFactory componentFactory; + + final Color? primaryColor; + + final StreamButtonTheme buttonTheme; + + static StreamTheme of(BuildContext context) { + return Theme.of(context).extension() ?? StreamTheme(); + } + + @override + StreamTheme copyWith({Color? primaryColor, StreamButtonTheme? buttonTheme}) { + return StreamTheme( + componentFactory: componentFactory, + primaryColor: primaryColor ?? this.primaryColor, + buttonTheme: buttonTheme ?? this.buttonTheme, + ); + } + + @override + ThemeExtension lerp( + covariant ThemeExtension? other, + double t, + ) { + if (other is! StreamTheme) { + return this; + } + return StreamTheme(); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme.dart b/packages/stream_core_flutter/lib/src/theme.dart new file mode 100644 index 0000000..68facfb --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme.dart @@ -0,0 +1,14 @@ +export 'theme/components/stream_avatar_theme.dart'; +export 'theme/components/stream_online_indicator_theme.dart'; + +export 'theme/primitives/stream_colors.dart'; +export 'theme/primitives/stream_radius.dart'; +export 'theme/primitives/stream_spacing.dart'; +export 'theme/primitives/stream_typography.dart'; + +export 'theme/semantics/stream_box_shadow.dart'; +export 'theme/semantics/stream_color_scheme.dart'; +export 'theme/semantics/stream_text_theme.dart'; + +export 'theme/stream_theme.dart'; +export 'theme/stream_theme_extensions.dart'; diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_avatar_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_avatar_theme.dart new file mode 100644 index 0000000..947f7bd --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_avatar_theme.dart @@ -0,0 +1,159 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../semantics/stream_color_scheme.dart'; +import '../stream_theme.dart'; + +part 'stream_avatar_theme.g.theme.dart'; + +/// Predefined avatar sizes for the Stream design system. +/// +/// Each size corresponds to a specific diameter in logical pixels. +/// +/// See also: +/// +/// * [StreamAvatar], which uses these size variants. +/// * [StreamAvatarThemeData.size], for setting a global default size. +enum StreamAvatarSize { + /// Extra small avatar (20px diameter). + xs(20), + + /// Small avatar (24px diameter). + sm(24), + + /// Medium avatar (32px diameter). + md(32), + + /// Large avatar (40px diameter). + lg(40) + ; + + /// Constructs a [StreamAvatarSize] with the given diameter. + const StreamAvatarSize(this.value); + + /// The diameter of the avatar in logical pixels. + final double value; +} + +/// Applies an avatar theme to descendant [StreamAvatar] widgets. +/// +/// Wrap a subtree with [StreamAvatarTheme] to override avatar styling. +/// Access the merged theme using [BuildContext.streamAvatarTheme]. +/// +/// {@tool snippet} +/// +/// Override avatar colors for a specific section: +/// +/// ```dart +/// StreamAvatarTheme( +/// data: StreamAvatarThemeData( +/// backgroundColor: Colors.blue.shade100, +/// foregroundColor: Colors.blue.shade800, +/// ), +/// child: Row( +/// children: [ +/// StreamAvatar(placeholder: (context) => Text('A')), +/// StreamAvatar(placeholder: (context) => Text('B')), +/// ], +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamAvatarThemeData], which describes the avatar theme. +/// * [StreamAvatar], the widget affected by this theme. +class StreamAvatarTheme extends InheritedTheme { + /// Creates an avatar theme that controls the styling of descendant avatars. + const StreamAvatarTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The avatar theme data for descendant widgets. + final StreamAvatarThemeData data; + + /// Returns the [StreamAvatarThemeData] merged from local and global themes. + /// + /// Local values from the nearest [StreamAvatarTheme] ancestor take + /// precedence over global values from [StreamTheme.of]. + /// + /// This allows partial overrides - for example, overriding only [size] + /// while inheriting colors from the global theme. + static StreamAvatarThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamTheme.of(context).avatarTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamAvatarTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamAvatarTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing [StreamAvatar] widgets. +/// +/// {@tool snippet} +/// +/// Customize avatar appearance globally: +/// +/// ```dart +/// StreamTheme( +/// avatarTheme: StreamAvatarThemeData( +/// backgroundColor: Colors.grey.shade200, +/// foregroundColor: Colors.grey.shade800, +/// borderColor: Colors.grey.shade300, +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamAvatar], the widget that uses this theme data. +/// * [StreamAvatarTheme], for overriding theme in a widget subtree. +/// * [StreamColorScheme.avatarPalette], for user-specific avatar colors. +@themeGen +@immutable +class StreamAvatarThemeData with _$StreamAvatarThemeData { + /// Creates an avatar theme with optional style overrides. + const StreamAvatarThemeData({ + this.size, + this.backgroundColor, + this.foregroundColor, + this.borderColor, + }); + + /// The default size for avatars. + /// + /// Falls back to [StreamAvatarSize.lg]. The text style for initials is + /// automatically determined based on this size. + final StreamAvatarSize? size; + + /// The background color for this avatar. + /// + /// Used as the fill color behind the avatar image or placeholder content. + final Color? backgroundColor; + + /// The foreground color for this avatar's placeholder content. + /// + /// Applied to text (initials) and icons when no image is available. + final Color? foregroundColor; + + /// The border color for this avatar. + /// + /// Applied when [StreamAvatar.showBorder] is true. + final Color? borderColor; + + /// Linearly interpolate between two [StreamAvatarThemeData] objects. + static StreamAvatarThemeData? lerp( + StreamAvatarThemeData? a, + StreamAvatarThemeData? b, + double t, + ) => _$StreamAvatarThemeData.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_avatar_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_avatar_theme.g.theme.dart new file mode 100644 index 0000000..f156a05 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_avatar_theme.g.theme.dart @@ -0,0 +1,106 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'stream_avatar_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamAvatarThemeData { + bool get canMerge => true; + + static StreamAvatarThemeData? lerp( + StreamAvatarThemeData? a, + StreamAvatarThemeData? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamAvatarThemeData( + size: t < 0.5 ? a.size : b.size, + backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), + foregroundColor: Color.lerp(a.foregroundColor, b.foregroundColor, t), + borderColor: Color.lerp(a.borderColor, b.borderColor, t), + ); + } + + StreamAvatarThemeData copyWith({ + StreamAvatarSize? size, + Color? backgroundColor, + Color? foregroundColor, + Color? borderColor, + }) { + final _this = (this as StreamAvatarThemeData); + + return StreamAvatarThemeData( + size: size ?? _this.size, + backgroundColor: backgroundColor ?? _this.backgroundColor, + foregroundColor: foregroundColor ?? _this.foregroundColor, + borderColor: borderColor ?? _this.borderColor, + ); + } + + StreamAvatarThemeData merge(StreamAvatarThemeData? other) { + final _this = (this as StreamAvatarThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + size: other.size, + backgroundColor: other.backgroundColor, + foregroundColor: other.foregroundColor, + borderColor: other.borderColor, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamAvatarThemeData); + final _other = (other as StreamAvatarThemeData); + + return _other.size == _this.size && + _other.backgroundColor == _this.backgroundColor && + _other.foregroundColor == _this.foregroundColor && + _other.borderColor == _this.borderColor; + } + + @override + int get hashCode { + final _this = (this as StreamAvatarThemeData); + + return Object.hash( + runtimeType, + _this.size, + _this.backgroundColor, + _this.foregroundColor, + _this.borderColor, + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_online_indicator_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_online_indicator_theme.dart new file mode 100644 index 0000000..4e7ff1b --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_online_indicator_theme.dart @@ -0,0 +1,124 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../stream_theme.dart'; + +part 'stream_online_indicator_theme.g.theme.dart'; + +/// Applies an online indicator theme to descendant [StreamOnlineIndicator] +/// widgets. +/// +/// Wrap a subtree with [StreamOnlineIndicatorTheme] to override indicator +/// styling. Access the merged theme using [BuildContext.streamOnlineIndicatorTheme]. +/// +/// {@tool snippet} +/// +/// Override indicator colors for a specific section: +/// +/// ```dart +/// StreamOnlineIndicatorTheme( +/// data: StreamOnlineIndicatorThemeData( +/// backgroundOnline: Colors.green, +/// backgroundOffline: Colors.grey, +/// ), +/// child: Row( +/// children: [ +/// StreamOnlineIndicator(state: StreamPresenceState.online), +/// StreamOnlineIndicator(state: StreamPresenceState.offline), +/// ], +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamOnlineIndicatorThemeData], which describes the indicator theme. +/// * [StreamOnlineIndicator], the widget affected by this theme. +class StreamOnlineIndicatorTheme extends InheritedTheme { + /// Creates an online indicator theme that controls descendant indicators. + const StreamOnlineIndicatorTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The online indicator theme data for descendant widgets. + final StreamOnlineIndicatorThemeData data; + + /// Returns the [StreamOnlineIndicatorThemeData] merged from local and global + /// themes. + /// + /// Local values from the nearest [StreamOnlineIndicatorTheme] ancestor take + /// precedence over global values from [StreamTheme.of]. + /// + /// This allows partial overrides - for example, overriding only + /// [backgroundOnline] while inheriting other properties from the global theme. + static StreamOnlineIndicatorThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamTheme.of(context).onlineIndicatorTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamOnlineIndicatorTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamOnlineIndicatorTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing [StreamOnlineIndicator] widgets. +/// +/// {@tool snippet} +/// +/// Customize indicator appearance globally: +/// +/// ```dart +/// StreamTheme( +/// onlineIndicatorTheme: StreamOnlineIndicatorThemeData( +/// backgroundOnline: Colors.green, +/// backgroundOffline: Colors.grey, +/// borderColor: Colors.white, +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamOnlineIndicator], the widget that uses this theme data. +/// * [StreamOnlineIndicatorTheme], for overriding theme in a widget subtree. +@themeGen +@immutable +class StreamOnlineIndicatorThemeData with _$StreamOnlineIndicatorThemeData { + /// Creates an online indicator theme with optional style overrides. + const StreamOnlineIndicatorThemeData({ + this.backgroundOnline, + this.backgroundOffline, + this.borderColor, + }); + + /// The background color for online presence indicators. + /// + /// Displayed when the user is currently online. + final Color? backgroundOnline; + + /// The background color for offline presence indicators. + /// + /// Displayed when the user is offline or away. + final Color? backgroundOffline; + + /// The border color for the indicator. + /// + /// A thin outline around the presence dot that matches the surface behind + /// the avatar. + final Color? borderColor; + + /// Linearly interpolate between two [StreamOnlineIndicatorThemeData] objects. + static StreamOnlineIndicatorThemeData? lerp( + StreamOnlineIndicatorThemeData? a, + StreamOnlineIndicatorThemeData? b, + double t, + ) => _$StreamOnlineIndicatorThemeData.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_online_indicator_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_online_indicator_theme.g.theme.dart new file mode 100644 index 0000000..b975cd8 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_online_indicator_theme.g.theme.dart @@ -0,0 +1,104 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'stream_online_indicator_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamOnlineIndicatorThemeData { + bool get canMerge => true; + + static StreamOnlineIndicatorThemeData? lerp( + StreamOnlineIndicatorThemeData? a, + StreamOnlineIndicatorThemeData? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamOnlineIndicatorThemeData( + backgroundOnline: Color.lerp(a.backgroundOnline, b.backgroundOnline, t), + backgroundOffline: Color.lerp( + a.backgroundOffline, + b.backgroundOffline, + t, + ), + borderColor: Color.lerp(a.borderColor, b.borderColor, t), + ); + } + + StreamOnlineIndicatorThemeData copyWith({ + Color? backgroundOnline, + Color? backgroundOffline, + Color? borderColor, + }) { + final _this = (this as StreamOnlineIndicatorThemeData); + + return StreamOnlineIndicatorThemeData( + backgroundOnline: backgroundOnline ?? _this.backgroundOnline, + backgroundOffline: backgroundOffline ?? _this.backgroundOffline, + borderColor: borderColor ?? _this.borderColor, + ); + } + + StreamOnlineIndicatorThemeData merge(StreamOnlineIndicatorThemeData? other) { + final _this = (this as StreamOnlineIndicatorThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + backgroundOnline: other.backgroundOnline, + backgroundOffline: other.backgroundOffline, + borderColor: other.borderColor, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamOnlineIndicatorThemeData); + final _other = (other as StreamOnlineIndicatorThemeData); + + return _other.backgroundOnline == _this.backgroundOnline && + _other.backgroundOffline == _this.backgroundOffline && + _other.borderColor == _this.borderColor; + } + + @override + int get hashCode { + final _this = (this as StreamOnlineIndicatorThemeData); + + return Object.hash( + runtimeType, + _this.backgroundOnline, + _this.backgroundOffline, + _this.borderColor, + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/primitives/stream_colors.dart b/packages/stream_core_flutter/lib/src/theme/primitives/stream_colors.dart new file mode 100644 index 0000000..5c73dd5 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/primitives/stream_colors.dart @@ -0,0 +1,261 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; + +/// [Color] and [ColorSwatch] constants for the Stream design system. +/// +/// Most swatches have colors from 50 to 950. The smaller the number, the more +/// pale the color. The greater the number, the darker the color. Shades 50-450 +/// are incremented by 50, and shades 500-950 are incremented by 100 or 50 for +/// the darkest shade (950). +/// +/// In addition, a series of blacks and whites with common opacities are +/// available. For example, [white70] is a pure white with 70% opacity. +/// +/// {@tool snippet} +/// +/// To select a specific color from one of the swatches, index into the swatch +/// using an integer for the specific color desired, as follows: +/// +/// ```dart +/// Color selection = StreamColors.blue[400]!; // Selects a mid-range blue. +/// ``` +/// {@end-tool} +/// {@tool snippet} +/// +/// Each [ColorSwatch] constant is a color and can used directly. For example: +/// +/// ```dart +/// Container( +/// // same as StreamColors.blue[500] or StreamColors.blue.shade500 +/// color: StreamColors.blue, +/// ) +/// ``` +/// {@end-tool} +abstract final class StreamColors { + const StreamColors._(); + + /// The fully transparent color. + static const transparent = Color(0x00000000); + + /// The pure white color. + static const white = Color(0xFFFFFFFF); + + /// The white color with 10% opacity. + static const white10 = Color(0x1AFFFFFF); + + /// The white color with 20% opacity. + static const white20 = Color(0x33FFFFFF); + + /// The white color with 50% opacity. + static const white50 = Color(0x80FFFFFF); + + /// The white color with 70% opacity. + static const white70 = Color(0xB3FFFFFF); + + /// The pure black color. + static const black = Color(0xFF000000); + + /// The black color with 5% opacity. + static const black5 = Color(0x0D000000); + + /// The black color with 10% opacity. + static const black10 = Color(0x1A000000); + + /// The black color with 50% opacity. + static const black50 = Color(0x80000000); + + /// The slate color swatch. + static const slate = StreamColorSwatch( + _slatePrimaryValue, + { + 50: Color(0xFFFAFBFC), + 100: Color(0xFFF2F4F6), + 200: Color(0xFFE2E6EA), + 300: Color(0xFFD0D5DA), + 400: Color(0xFFB8BEC4), + 500: Color(_slatePrimaryValue), + 600: Color(0xFF838990), + 700: Color(0xFF6A7077), + 800: Color(0xFF50565D), + 900: Color(0xFF384047), + 950: Color(0xFF1E252B), + }, + ); + static const _slatePrimaryValue = 0xFF9EA4AA; + + /// The neutral color swatch. + static const neutral = StreamColorSwatch( + _neutralPrimaryValue, + { + 50: Color(0xFFF7F7F7), + 100: Color(0xFFEDEDED), + 200: Color(0xFFD9D9D9), + 300: Color(0xFFC1C1C1), + 400: Color(0xFFA3A3A3), + 500: Color(_neutralPrimaryValue), + 600: Color(0xFF636363), + 700: Color(0xFF4A4A4A), + 800: Color(0xFF383838), + 900: Color(0xFF262626), + 950: Color(0xFF151515), + }, + ); + static const _neutralPrimaryValue = 0xFF7F7F7F; + + /// The blue color swatch. + static const blue = StreamColorSwatch( + _bluePrimaryValue, + { + 50: Color(0xFFEBF3FF), + 100: Color(0xFFD2E3FF), + 200: Color(0xFFA6C4FF), + 300: Color(0xFF7AA7FF), + 400: Color(0xFF4E8BFF), + 500: Color(_bluePrimaryValue), + 600: Color(0xFF0052CE), + 700: Color(0xFF0042A3), + 800: Color(0xFF003179), + 900: Color(0xFF001F4F), + 950: Color(0xFF001025), + }, + ); + static const _bluePrimaryValue = 0xFF005FFF; + + /// The cyan color swatch. + static const cyan = StreamColorSwatch( + _cyanPrimaryValue, + { + 50: Color(0xFFF0FCFE), + 100: Color(0xFFD7F7FB), + 200: Color(0xFFBDF1F8), + 300: Color(0xFFA3ECF4), + 400: Color(0xFF89E6F1), + 500: Color(_cyanPrimaryValue), + 600: Color(0xFF3EC9D9), + 700: Color(0xFF28A8B5), + 800: Color(0xFF1C8791), + 900: Color(0xFF125F66), + 950: Color(0xFF0B3D44), + }, + ); + static const _cyanPrimaryValue = 0xFF69E5F6; + + /// The green color swatch. + static const green = StreamColorSwatch( + _greenPrimaryValue, + { + 50: Color(0xFFE8FFF5), + 100: Color(0xFFC9FCE7), + 200: Color(0xFFA9F8D9), + 300: Color(0xFF88F2CA), + 400: Color(0xFF59E9B5), + 500: Color(_greenPrimaryValue), + 600: Color(0xFF00B681), + 700: Color(0xFF008D64), + 800: Color(0xFF006548), + 900: Color(0xFF003D2B), + 950: Color(0xFF002319), + }, + ); + static const _greenPrimaryValue = 0xFF00E2A1; + + /// The purple color swatch. + static const purple = StreamColorSwatch( + _purplePrimaryValue, + { + 50: Color(0xFFF5EFFE), + 100: Color(0xFFEBDEFD), + 200: Color(0xFFD8BFFC), + 300: Color(0xFFC79FFC), + 400: Color(0xFFB98AF9), + 500: Color(_purplePrimaryValue), + 600: Color(0xFF996CE3), + 700: Color(0xFF7F55C7), + 800: Color(0xFF6640AB), + 900: Color(0xFF4D2C8F), + 950: Color(0xFF351C6B), + }, + ); + static const _purplePrimaryValue = 0xFFB38AF8; + + /// The yellow color swatch. + static const yellow = StreamColorSwatch( + _yellowPrimaryValue, + { + 50: Color(0xFFFFF9E5), + 100: Color(0xFFFFF1C2), + 200: Color(0xFFFFE8A0), + 300: Color(0xFFFFDE7D), + 400: Color(0xFFFFD65A), + 500: Color(_yellowPrimaryValue), + 600: Color(0xFFE6B400), + 700: Color(0xFFC59600), + 800: Color(0xFF9F7700), + 900: Color(0xFF7A5A00), + 950: Color(0xFF4F3900), + }, + ); + static const _yellowPrimaryValue = 0xFFFFD233; + + /// The red color swatch. + static const red = StreamColorSwatch( + _redPrimaryValue, + { + 50: Color(0xFFFCEBEA), + 100: Color(0xFFF8CFCD), + 200: Color(0xFFF3B3B0), + 300: Color(0xFFED958F), + 400: Color(0xFFE6756C), + 500: Color(_redPrimaryValue), + 600: Color(0xFFB9261F), + 700: Color(0xFF98201A), + 800: Color(0xFF761915), + 900: Color(0xFF54120F), + 950: Color(0xFF360B09), + }, + ); + static const _redPrimaryValue = 0xFFD92F26; +} + +/// A color swatch with multiple shades of a single color. +/// +/// See also: +/// +/// * [StreamColors], which defines all of the standard swatch colors. +@immutable +class StreamColorSwatch extends ColorSwatch { + const StreamColorSwatch(super.primary, super._swatch); + + /// The lightest shade. + Color get shade50 => this[50]!; + + /// The second lightest shade. + Color get shade100 => this[100]!; + + /// The third lightest shade. + Color get shade200 => this[200]!; + + /// The fourth lightest shade. + Color get shade300 => this[300]!; + + /// The fifth lightest shade. + Color get shade400 => this[400]!; + + /// The default shade. + Color get shade500 => this[500]!; + + /// The fourth darkest shade. + Color get shade600 => this[600]!; + + /// The third darkest shade. + Color get shade700 => this[700]!; + + /// The second darkest shade. + Color get shade800 => this[800]!; + + /// The second shade. + Color get shade900 => this[900]!; + + /// The darkest shade. + Color get shade950 => this[950]!; +} diff --git a/packages/stream_core_flutter/lib/src/theme/primitives/stream_radius.dart b/packages/stream_core_flutter/lib/src/theme/primitives/stream_radius.dart new file mode 100644 index 0000000..e18adcc --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/primitives/stream_radius.dart @@ -0,0 +1,159 @@ +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +part 'stream_radius.g.theme.dart'; + +/// Border radius primitives for the Stream design system. +/// +/// Provides platform-aware border radius values for consistent rounded corners +/// across iOS, Android, and other platforms. iOS typically uses larger corner +/// radius values following iOS design guidelines, while Android uses Material +/// Design conventions. +/// +/// {@tool snippet} +/// +/// To use border radius values: +/// +/// ```dart +/// final radius = StreamRadius(); +/// Container( +/// decoration: BoxDecoration( +/// borderRadius: BorderRadius.all(radius.md), +/// ), +/// ); +/// ``` +/// {@end-tool} +@immutable +@ThemeGen(constructor: 'raw') +class StreamRadius with _$StreamRadius { + /// Creates a [StreamRadius] with platform-specific or custom values. + /// + /// If [platform] is null, uses [defaultTargetPlatform]. Individual radius + /// values can be overridden with custom values. + factory StreamRadius({ + TargetPlatform? platform, + Radius? none, + Radius? xxs, + Radius? xs, + Radius? sm, + Radius? md, + Radius? lg, + Radius? xl, + Radius? xxl, + Radius? xxxl, + Radius? xxxxl, + Radius? max, + }) { + platform ??= defaultTargetPlatform; + final defaultRadius = switch (platform) { + .iOS || .macOS => ios, + _ => android, + }; + + return .raw( + none: none ?? defaultRadius.none, + xxs: xxs ?? defaultRadius.xxs, + xs: xs ?? defaultRadius.xs, + sm: sm ?? defaultRadius.sm, + md: md ?? defaultRadius.md, + lg: lg ?? defaultRadius.lg, + xl: xl ?? defaultRadius.xl, + xxl: xxl ?? defaultRadius.xxl, + xxxl: xxxl ?? defaultRadius.xxxl, + xxxxl: xxxxl ?? defaultRadius.xxxxl, + max: max ?? defaultRadius.max, + ); + } + + const StreamRadius.raw({ + required this.none, + required this.xxs, + required this.xs, + required this.sm, + required this.md, + required this.lg, + required this.xl, + required this.xxl, + required this.xxxl, + required this.xxxxl, + required this.max, + }); + + /// The iOS/macOS border radius scale. + /// + /// Uses iOS design guidelines with generally larger radius values. + static const StreamRadius ios = .raw( + none: .zero, + xxs: .circular(2), + xs: .circular(4), + sm: .circular(6), + md: .circular(8), + lg: .circular(12), + xl: .circular(16), + xxl: .circular(20), + xxxl: .circular(24), + xxxxl: .circular(32), + max: .circular(9999), + ); + + /// The Android border radius scale. + /// + /// Uses Material Design guidelines with more conservative radius values. + static const StreamRadius android = .raw( + none: .zero, + xxs: .zero, + xs: .circular(2), + sm: .circular(4), + md: .circular(6), + lg: .circular(8), + xl: .circular(12), + xxl: .circular(16), + xxxl: .circular(20), + xxxxl: .circular(24), + max: .circular(9999), + ); + + /// No border radius. + final Radius none; + + /// The extra extra small border radius. + final Radius xxs; + + /// The extra small border radius. + final Radius xs; + + /// The small border radius. + final Radius sm; + + /// The medium border radius. + final Radius md; + + /// The large border radius. + final Radius lg; + + /// The extra large border radius. + final Radius xl; + + /// The extra extra large border radius. + final Radius xxl; + + /// The extra extra extra large border radius. + final Radius xxxl; + + /// The extra extra extra extra large border radius. + final Radius xxxxl; + + /// The maximum border radius. + /// + /// Use for fully rounded elements like pills or circular buttons. + final Radius max; + + /// Linearly interpolates between two [StreamRadius] instances. + static StreamRadius? lerp( + StreamRadius? a, + StreamRadius? b, + double t, + ) => _$StreamRadius.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/primitives/stream_radius.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/primitives/stream_radius.g.theme.dart new file mode 100644 index 0000000..0ab524c --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/primitives/stream_radius.g.theme.dart @@ -0,0 +1,144 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'stream_radius.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamRadius { + bool get canMerge => true; + + static StreamRadius? lerp(StreamRadius? a, StreamRadius? b, double t) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamRadius.raw( + none: Radius.lerp(a.none, b.none, t)!, + xxs: Radius.lerp(a.xxs, b.xxs, t)!, + xs: Radius.lerp(a.xs, b.xs, t)!, + sm: Radius.lerp(a.sm, b.sm, t)!, + md: Radius.lerp(a.md, b.md, t)!, + lg: Radius.lerp(a.lg, b.lg, t)!, + xl: Radius.lerp(a.xl, b.xl, t)!, + xxl: Radius.lerp(a.xxl, b.xxl, t)!, + xxxl: Radius.lerp(a.xxxl, b.xxxl, t)!, + xxxxl: Radius.lerp(a.xxxxl, b.xxxxl, t)!, + max: Radius.lerp(a.max, b.max, t)!, + ); + } + + StreamRadius copyWith({ + Radius? none, + Radius? xxs, + Radius? xs, + Radius? sm, + Radius? md, + Radius? lg, + Radius? xl, + Radius? xxl, + Radius? xxxl, + Radius? xxxxl, + Radius? max, + }) { + final _this = (this as StreamRadius); + + return StreamRadius.raw( + none: none ?? _this.none, + xxs: xxs ?? _this.xxs, + xs: xs ?? _this.xs, + sm: sm ?? _this.sm, + md: md ?? _this.md, + lg: lg ?? _this.lg, + xl: xl ?? _this.xl, + xxl: xxl ?? _this.xxl, + xxxl: xxxl ?? _this.xxxl, + xxxxl: xxxxl ?? _this.xxxxl, + max: max ?? _this.max, + ); + } + + StreamRadius merge(StreamRadius? other) { + final _this = (this as StreamRadius); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + none: other.none, + xxs: other.xxs, + xs: other.xs, + sm: other.sm, + md: other.md, + lg: other.lg, + xl: other.xl, + xxl: other.xxl, + xxxl: other.xxxl, + xxxxl: other.xxxxl, + max: other.max, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamRadius); + final _other = (other as StreamRadius); + + return _other.none == _this.none && + _other.xxs == _this.xxs && + _other.xs == _this.xs && + _other.sm == _this.sm && + _other.md == _this.md && + _other.lg == _this.lg && + _other.xl == _this.xl && + _other.xxl == _this.xxl && + _other.xxxl == _this.xxxl && + _other.xxxxl == _this.xxxxl && + _other.max == _this.max; + } + + @override + int get hashCode { + final _this = (this as StreamRadius); + + return Object.hash( + runtimeType, + _this.none, + _this.xxs, + _this.xs, + _this.sm, + _this.md, + _this.lg, + _this.xl, + _this.xxl, + _this.xxxl, + _this.xxxxl, + _this.max, + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/primitives/stream_spacing.dart b/packages/stream_core_flutter/lib/src/theme/primitives/stream_spacing.dart new file mode 100644 index 0000000..91aa48a --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/primitives/stream_spacing.dart @@ -0,0 +1,93 @@ +import 'package:flutter/foundation.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +part 'stream_spacing.g.theme.dart'; + +/// Spacing primitives for the Stream design system. +/// +/// Provides consistent spacing values for padding, margins, and gaps between +/// UI elements. Spacing values are the same across all platforms. +/// +/// {@tool snippet} +/// +/// To use spacing values: +/// +/// ```dart +/// const spacing = StreamSpacing(); +/// Container( +/// padding: EdgeInsets.all(spacing.md), +/// child: Column( +/// spacing: spacing.xs, +/// children: [...], +/// ), +/// ); +/// ``` +/// {@end-tool} +@immutable +@ThemeGen() +class StreamSpacing with _$StreamSpacing { + /// Creates a [StreamSpacing] with the default values. + const StreamSpacing({ + this.none = 0, + this.xxs = 4, + this.xs = 8, + this.sm = 12, + this.md = 16, + this.lg = 20, + this.xl = 24, + this.xxl = 32, + this.xxxl = 40, + }); + + /// No spacing. + /// + /// Used for tight component joins. + final double none; + + /// Base unit spacing. + /// + /// Used for minimal padding and tight gaps. + final double xxs; + + /// Extra small spacing. + /// + /// Used for small padding and default vertical gaps. + final double xs; + + /// Small spacing. + /// + /// Used for common internal spacing in inputs and buttons. + final double sm; + + /// Medium spacing. + /// + /// Used for default large padding for sections and cards. + final double md; + + /// Large spacing. + /// + /// Used for medium spacing for grouping elements and section breaks. + final double lg; + + /// Extra large spacing. + /// + /// Used for comfortable spacing for chat bubbles and list items. + final double xl; + + /// Extra extra large spacing. + /// + /// Used for larger spacing for panels, modals, and gutters. + final double xxl; + + /// Extra extra extra large spacing. + /// + /// Used for wide layout spacing and breathing room. + final double xxxl; + + /// Linearly interpolates between two [StreamSpacing] instances. + static StreamSpacing? lerp( + StreamSpacing? a, + StreamSpacing? b, + double t, + ) => _$StreamSpacing.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/primitives/stream_spacing.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/primitives/stream_spacing.g.theme.dart new file mode 100644 index 0000000..1c959a2 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/primitives/stream_spacing.g.theme.dart @@ -0,0 +1,132 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'stream_spacing.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamSpacing { + bool get canMerge => true; + + static StreamSpacing? lerp(StreamSpacing? a, StreamSpacing? b, double t) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamSpacing( + none: lerpDouble$(a.none, b.none, t)!, + xxs: lerpDouble$(a.xxs, b.xxs, t)!, + xs: lerpDouble$(a.xs, b.xs, t)!, + sm: lerpDouble$(a.sm, b.sm, t)!, + md: lerpDouble$(a.md, b.md, t)!, + lg: lerpDouble$(a.lg, b.lg, t)!, + xl: lerpDouble$(a.xl, b.xl, t)!, + xxl: lerpDouble$(a.xxl, b.xxl, t)!, + xxxl: lerpDouble$(a.xxxl, b.xxxl, t)!, + ); + } + + StreamSpacing copyWith({ + double? none, + double? xxs, + double? xs, + double? sm, + double? md, + double? lg, + double? xl, + double? xxl, + double? xxxl, + }) { + final _this = (this as StreamSpacing); + + return StreamSpacing( + none: none ?? _this.none, + xxs: xxs ?? _this.xxs, + xs: xs ?? _this.xs, + sm: sm ?? _this.sm, + md: md ?? _this.md, + lg: lg ?? _this.lg, + xl: xl ?? _this.xl, + xxl: xxl ?? _this.xxl, + xxxl: xxxl ?? _this.xxxl, + ); + } + + StreamSpacing merge(StreamSpacing? other) { + final _this = (this as StreamSpacing); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + none: other.none, + xxs: other.xxs, + xs: other.xs, + sm: other.sm, + md: other.md, + lg: other.lg, + xl: other.xl, + xxl: other.xxl, + xxxl: other.xxxl, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamSpacing); + final _other = (other as StreamSpacing); + + return _other.none == _this.none && + _other.xxs == _this.xxs && + _other.xs == _this.xs && + _other.sm == _this.sm && + _other.md == _this.md && + _other.lg == _this.lg && + _other.xl == _this.xl && + _other.xxl == _this.xxl && + _other.xxxl == _this.xxxl; + } + + @override + int get hashCode { + final _this = (this as StreamSpacing); + + return Object.hash( + runtimeType, + _this.none, + _this.xxs, + _this.xs, + _this.sm, + _this.md, + _this.lg, + _this.xl, + _this.xxl, + _this.xxxl, + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/primitives/stream_typography.dart b/packages/stream_core_flutter/lib/src/theme/primitives/stream_typography.dart new file mode 100644 index 0000000..4b44eec --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/primitives/stream_typography.dart @@ -0,0 +1,263 @@ +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +part 'stream_typography.g.theme.dart'; + +/// Typography primitives for the Stream design system. +/// +/// Provides platform-aware font sizes, line heights, and font weights for +/// consistent text styling across iOS, Android, and other platforms. +/// +/// {@tool snippet} +/// +/// To create a typography configuration: +/// +/// ```dart +/// final typography = StreamTypography(); +/// final fontSize = typography.fontSize.md; // 15.0 on iOS, 16.0 on Android +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamTextTheme], which provides semantic text styles using these primitives. +@immutable +@ThemeGen(constructor: 'raw') +class StreamTypography with _$StreamTypography { + /// Creates a [StreamTypography] with optional custom values. + /// + /// If [platform] is null, uses [defaultTargetPlatform]. Font sizes + /// automatically adjust based on the platform. + factory StreamTypography({ + TargetPlatform? platform, + StreamFontSize? fontSize, + StreamLineHeight? lineHeight, + StreamFontWeight? fontWeight, + }) { + platform ??= defaultTargetPlatform; + fontSize ??= StreamFontSize(platform: platform); + lineHeight ??= const StreamLineHeight(); + fontWeight ??= const StreamFontWeight(); + + return .raw( + fontSize: fontSize, + lineHeight: lineHeight, + fontWeight: fontWeight, + ); + } + + const StreamTypography.raw({ + required this.fontSize, + required this.lineHeight, + required this.fontWeight, + }); + + /// The font size scale. + final StreamFontSize fontSize; + + /// The line height values. + final StreamLineHeight lineHeight; + + /// The font weight values. + final StreamFontWeight fontWeight; + + /// Linearly interpolates between two [StreamTypography] instances. + static StreamTypography? lerp( + StreamTypography? a, + StreamTypography? b, + double t, + ) => _$StreamTypography.lerp(a, b, t); +} + +/// Line height values for text. +/// +/// Provides three standard line heights for different text densities. +@immutable +@ThemeGen() +class StreamLineHeight with _$StreamLineHeight { + /// Creates a [StreamLineHeight] with the given values. + const StreamLineHeight({ + this.tight = 16, + this.normal = 20, + this.relaxed = 24, + }); + + /// The tight line height. + /// + /// Use for compact text layouts. + final double tight; + + /// The normal line height. + /// + /// Use for standard body text. + final double normal; + + /// The relaxed line height. + /// + /// Use for improved readability in long-form content. + final double relaxed; + + /// Linearly interpolates between two [StreamLineHeight] instances. + static StreamLineHeight? lerp( + StreamLineHeight? a, + StreamLineHeight? b, + double t, + ) => _$StreamLineHeight.lerp(a, b, t); +} + +/// Platform-aware font size scale. +/// +/// Provides eight font sizes that automatically adjust based on the platform. +/// iOS uses the San Francisco font sizing, while Android uses Roboto sizing. +/// +/// {@tool snippet} +/// +/// To use platform-specific font sizes: +/// +/// ```dart +/// final fontSize = StreamFontSize(); +/// Text('Hello', style: TextStyle(fontSize: fontSize.md)); // 15.0 on iOS, 16.0 on Android +/// ``` +/// {@end-tool} +@immutable +@ThemeGen(constructor: 'raw') +class StreamFontSize with _$StreamFontSize { + /// Creates a [StreamFontSize] with platform-specific or custom values. + /// + /// If [platform] is null, uses [defaultTargetPlatform]. Individual sizes + /// can be overridden with custom values. + factory StreamFontSize({ + TargetPlatform? platform, + double? micro, + double? xxs, + double? xs, + double? sm, + double? md, + double? lg, + double? xl, + double? xxl, + }) { + platform ??= defaultTargetPlatform; + final defaultFontSize = switch (platform) { + .iOS || .macOS => ios, + _ => android, + }; + + return .raw( + micro: micro ?? defaultFontSize.micro, + xxs: xxs ?? defaultFontSize.xxs, + xs: xs ?? defaultFontSize.xs, + sm: sm ?? defaultFontSize.sm, + md: md ?? defaultFontSize.md, + lg: lg ?? defaultFontSize.lg, + xl: xl ?? defaultFontSize.xl, + xxl: xxl ?? defaultFontSize.xxl, + ); + } + + const StreamFontSize.raw({ + required this.micro, + required this.xxs, + required this.xs, + required this.sm, + required this.md, + required this.lg, + required this.xl, + required this.xxl, + }); + + /// The iOS/macOS font size scale. + /// + /// Uses San Francisco font sizing conventions. + static const StreamFontSize ios = .raw( + micro: 8, + xxs: 10, + xs: 12, + sm: 13, + md: 15, + lg: 17, + xl: 20, + xxl: 24, + ); + + /// The Android font size scale. + /// + /// Uses Roboto font sizing conventions. + static const StreamFontSize android = .raw( + micro: 8, + xxs: 10, + xs: 12, + sm: 14, + md: 16, + lg: 18, + xl: 20, + xxl: 24, + ); + + /// The micro font size. + final double micro; + + /// The extra extra small font size. + final double xxs; + + /// The extra small font size. + final double xs; + + /// The small font size. + final double sm; + + /// The medium font size. + final double md; + + /// The large font size. + final double lg; + + /// The extra large font size. + final double xl; + + /// The extra extra large font size. + final double xxl; + + /// Linearly interpolates between two [StreamFontSize] instances. + static StreamFontSize? lerp( + StreamFontSize? a, + StreamFontSize? b, + double t, + ) => _$StreamFontSize.lerp(a, b, t); +} + +/// Font weight values for text. +/// +/// Provides four standard font weights for text hierarchy. +@immutable +@ThemeGen() +class StreamFontWeight with _$StreamFontWeight { + /// Creates a [StreamFontWeight] with the given values. + const StreamFontWeight({ + this.regular = .w400, + this.medium = .w500, + this.semibold = .w600, + this.bold = .w700, + }); + + /// The regular font weight. + final FontWeight regular; + + /// The medium font weight. + final FontWeight medium; + + /// The semibold font weight. + final FontWeight semibold; + + /// The bold font weight. + final FontWeight bold; + + /// Linearly interpolates between two [StreamFontWeight] instances. + static StreamFontWeight? lerp( + StreamFontWeight? a, + StreamFontWeight? b, + double t, + ) => _$StreamFontWeight.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/primitives/stream_typography.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/primitives/stream_typography.g.theme.dart new file mode 100644 index 0000000..aec983a --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/primitives/stream_typography.g.theme.dart @@ -0,0 +1,393 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'stream_typography.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamTypography { + bool get canMerge => true; + + static StreamTypography? lerp( + StreamTypography? a, + StreamTypography? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamTypography.raw( + fontSize: StreamFontSize.lerp(a.fontSize, b.fontSize, t)!, + lineHeight: StreamLineHeight.lerp(a.lineHeight, b.lineHeight, t)!, + fontWeight: StreamFontWeight.lerp(a.fontWeight, b.fontWeight, t)!, + ); + } + + StreamTypography copyWith({ + StreamFontSize? fontSize, + StreamLineHeight? lineHeight, + StreamFontWeight? fontWeight, + }) { + final _this = (this as StreamTypography); + + return StreamTypography.raw( + fontSize: fontSize ?? _this.fontSize, + lineHeight: lineHeight ?? _this.lineHeight, + fontWeight: fontWeight ?? _this.fontWeight, + ); + } + + StreamTypography merge(StreamTypography? other) { + final _this = (this as StreamTypography); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + fontSize: _this.fontSize.merge(other.fontSize), + lineHeight: _this.lineHeight.merge(other.lineHeight), + fontWeight: _this.fontWeight.merge(other.fontWeight), + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamTypography); + final _other = (other as StreamTypography); + + return _other.fontSize == _this.fontSize && + _other.lineHeight == _this.lineHeight && + _other.fontWeight == _this.fontWeight; + } + + @override + int get hashCode { + final _this = (this as StreamTypography); + + return Object.hash( + runtimeType, + _this.fontSize, + _this.lineHeight, + _this.fontWeight, + ); + } +} + +mixin _$StreamLineHeight { + bool get canMerge => true; + + static StreamLineHeight? lerp( + StreamLineHeight? a, + StreamLineHeight? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamLineHeight( + tight: lerpDouble$(a.tight, b.tight, t)!, + normal: lerpDouble$(a.normal, b.normal, t)!, + relaxed: lerpDouble$(a.relaxed, b.relaxed, t)!, + ); + } + + StreamLineHeight copyWith({double? tight, double? normal, double? relaxed}) { + final _this = (this as StreamLineHeight); + + return StreamLineHeight( + tight: tight ?? _this.tight, + normal: normal ?? _this.normal, + relaxed: relaxed ?? _this.relaxed, + ); + } + + StreamLineHeight merge(StreamLineHeight? other) { + final _this = (this as StreamLineHeight); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + tight: other.tight, + normal: other.normal, + relaxed: other.relaxed, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamLineHeight); + final _other = (other as StreamLineHeight); + + return _other.tight == _this.tight && + _other.normal == _this.normal && + _other.relaxed == _this.relaxed; + } + + @override + int get hashCode { + final _this = (this as StreamLineHeight); + + return Object.hash(runtimeType, _this.tight, _this.normal, _this.relaxed); + } +} + +mixin _$StreamFontSize { + bool get canMerge => true; + + static StreamFontSize? lerp(StreamFontSize? a, StreamFontSize? b, double t) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamFontSize.raw( + micro: lerpDouble$(a.micro, b.micro, t)!, + xxs: lerpDouble$(a.xxs, b.xxs, t)!, + xs: lerpDouble$(a.xs, b.xs, t)!, + sm: lerpDouble$(a.sm, b.sm, t)!, + md: lerpDouble$(a.md, b.md, t)!, + lg: lerpDouble$(a.lg, b.lg, t)!, + xl: lerpDouble$(a.xl, b.xl, t)!, + xxl: lerpDouble$(a.xxl, b.xxl, t)!, + ); + } + + StreamFontSize copyWith({ + double? micro, + double? xxs, + double? xs, + double? sm, + double? md, + double? lg, + double? xl, + double? xxl, + }) { + final _this = (this as StreamFontSize); + + return StreamFontSize.raw( + micro: micro ?? _this.micro, + xxs: xxs ?? _this.xxs, + xs: xs ?? _this.xs, + sm: sm ?? _this.sm, + md: md ?? _this.md, + lg: lg ?? _this.lg, + xl: xl ?? _this.xl, + xxl: xxl ?? _this.xxl, + ); + } + + StreamFontSize merge(StreamFontSize? other) { + final _this = (this as StreamFontSize); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + micro: other.micro, + xxs: other.xxs, + xs: other.xs, + sm: other.sm, + md: other.md, + lg: other.lg, + xl: other.xl, + xxl: other.xxl, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamFontSize); + final _other = (other as StreamFontSize); + + return _other.micro == _this.micro && + _other.xxs == _this.xxs && + _other.xs == _this.xs && + _other.sm == _this.sm && + _other.md == _this.md && + _other.lg == _this.lg && + _other.xl == _this.xl && + _other.xxl == _this.xxl; + } + + @override + int get hashCode { + final _this = (this as StreamFontSize); + + return Object.hash( + runtimeType, + _this.micro, + _this.xxs, + _this.xs, + _this.sm, + _this.md, + _this.lg, + _this.xl, + _this.xxl, + ); + } +} + +mixin _$StreamFontWeight { + bool get canMerge => true; + + static StreamFontWeight? lerp( + StreamFontWeight? a, + StreamFontWeight? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamFontWeight( + regular: FontWeight.lerp(a.regular, b.regular, t)!, + medium: FontWeight.lerp(a.medium, b.medium, t)!, + semibold: FontWeight.lerp(a.semibold, b.semibold, t)!, + bold: FontWeight.lerp(a.bold, b.bold, t)!, + ); + } + + StreamFontWeight copyWith({ + FontWeight? regular, + FontWeight? medium, + FontWeight? semibold, + FontWeight? bold, + }) { + final _this = (this as StreamFontWeight); + + return StreamFontWeight( + regular: regular ?? _this.regular, + medium: medium ?? _this.medium, + semibold: semibold ?? _this.semibold, + bold: bold ?? _this.bold, + ); + } + + StreamFontWeight merge(StreamFontWeight? other) { + final _this = (this as StreamFontWeight); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + regular: other.regular, + medium: other.medium, + semibold: other.semibold, + bold: other.bold, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamFontWeight); + final _other = (other as StreamFontWeight); + + return _other.regular == _this.regular && + _other.medium == _this.medium && + _other.semibold == _this.semibold && + _other.bold == _this.bold; + } + + @override + int get hashCode { + final _this = (this as StreamFontWeight); + + return Object.hash( + runtimeType, + _this.regular, + _this.medium, + _this.semibold, + _this.bold, + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/semantics/stream_box_shadow.dart b/packages/stream_core_flutter/lib/src/theme/semantics/stream_box_shadow.dart new file mode 100644 index 0000000..a9ede64 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/semantics/stream_box_shadow.dart @@ -0,0 +1,222 @@ +// ignore_for_file: avoid_redundant_argument_values + +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +part 'stream_box_shadow.g.theme.dart'; + +/// Box shadow tokens for the Stream design system. +/// +/// [StreamBoxShadow] provides consistent box shadow values for creating +/// depth and visual hierarchy. Each elevation level represents a different +/// degree of surface separation. +/// +/// - [elevation1]: Low elevation for subtle separation +/// - [elevation2]: Medium-low elevation +/// - [elevation3]: Medium-high elevation +/// - [elevation4]: High elevation for prominent elements +/// +/// For no shadow (elevation 0), simply don't apply any box shadow. +/// +/// {@tool snippet} +/// +/// Create a light theme box shadow and apply to a container: +/// +/// ```dart +/// final boxShadow = StreamBoxShadow.light(); +/// Container( +/// decoration: BoxDecoration( +/// boxShadow: boxShadow.elevation2, +/// ), +/// ); +/// ``` +/// {@end-tool} +@immutable +@ThemeGen(constructor: 'raw') +class StreamBoxShadow with _$StreamBoxShadow { + /// Creates a light theme box shadow configuration. + /// + /// Light theme shadows use lower opacity values for a softer appearance + /// on light backgrounds. + factory StreamBoxShadow.light({ + List? elevation1, + List? elevation2, + List? elevation3, + List? elevation4, + }) { + elevation1 ??= [ + const BoxShadow( + offset: Offset(0, 1), + blurRadius: 2, + spreadRadius: 0, + color: Color.fromRGBO(0, 0, 0, 0.1), + ), + const BoxShadow( + offset: Offset(0, 4), + blurRadius: 8, + spreadRadius: 0, + color: Color.fromRGBO(0, 0, 0, 0.06), + ), + ]; + elevation2 ??= [ + const BoxShadow( + offset: Offset(0, 2), + blurRadius: 4, + spreadRadius: 0, + color: Color.fromRGBO(0, 0, 0, 0.12), + ), + const BoxShadow( + offset: Offset(0, 6), + blurRadius: 16, + spreadRadius: 0, + color: Color.fromRGBO(0, 0, 0, 0.06), + ), + ]; + elevation3 ??= [ + const BoxShadow( + offset: Offset(0, 4), + blurRadius: 8, + spreadRadius: 0, + color: Color.fromRGBO(0, 0, 0, 0.14), + ), + const BoxShadow( + offset: Offset(0, 12), + blurRadius: 24, + spreadRadius: 0, + color: Color.fromRGBO(0, 0, 0, 0.1), + ), + ]; + elevation4 ??= [ + const BoxShadow( + offset: Offset(0, 6), + blurRadius: 12, + spreadRadius: 0, + color: Color.fromRGBO(0, 0, 0, 0.16), + ), + const BoxShadow( + offset: Offset(0, 20), + blurRadius: 32, + spreadRadius: 0, + color: Color.fromRGBO(0, 0, 0, 0.12), + ), + ]; + + return .raw( + elevation1: elevation1, + elevation2: elevation2, + elevation3: elevation3, + elevation4: elevation4, + ); + } + + /// Creates a dark theme box shadow configuration. + /// + /// Dark theme shadows use higher opacity values for visibility + /// on dark backgrounds. + factory StreamBoxShadow.dark({ + List? elevation1, + List? elevation2, + List? elevation3, + List? elevation4, + }) { + elevation1 ??= [ + const BoxShadow( + offset: Offset(0, 1), + blurRadius: 2, + spreadRadius: 0, + color: Color.fromRGBO(0, 0, 0, 0.2), + ), + const BoxShadow( + offset: Offset(0, 4), + blurRadius: 8, + spreadRadius: 0, + color: Color.fromRGBO(0, 0, 0, 0.1), + ), + ]; + elevation2 ??= [ + const BoxShadow( + offset: Offset(0, 2), + blurRadius: 4, + spreadRadius: 0, + color: Color.fromRGBO(0, 0, 0, 0.22), + ), + const BoxShadow( + offset: Offset(0, 6), + blurRadius: 16, + spreadRadius: 0, + color: Color.fromRGBO(0, 0, 0, 0.12), + ), + ]; + elevation3 ??= [ + const BoxShadow( + offset: Offset(0, 4), + blurRadius: 8, + spreadRadius: 0, + color: Color.fromRGBO(0, 0, 0, 0.24), + ), + const BoxShadow( + offset: Offset(0, 12), + blurRadius: 24, + spreadRadius: 0, + color: Color.fromRGBO(0, 0, 0, 0.14), + ), + ]; + elevation4 ??= [ + const BoxShadow( + offset: Offset(0, 6), + blurRadius: 12, + spreadRadius: 0, + color: Color.fromRGBO(0, 0, 0, 0.28), + ), + const BoxShadow( + offset: Offset(0, 20), + blurRadius: 32, + spreadRadius: 0, + color: Color.fromRGBO(0, 0, 0, 0.16), + ), + ]; + + return .raw( + elevation1: elevation1, + elevation2: elevation2, + elevation3: elevation3, + elevation4: elevation4, + ); + } + + /// Creates a [StreamBoxShadow] with the given values. + const StreamBoxShadow.raw({ + required this.elevation1, + required this.elevation2, + required this.elevation3, + required this.elevation4, + }); + + /// Low elevation level for subtle separation. + /// + /// Used for cards, list items, and subtle surface distinctions. + final List elevation1; + + /// Medium-low elevation level. + /// + /// Used for raised buttons, search bars, and interactive elements. + final List elevation2; + + /// Medium-high elevation level. + /// + /// Used for navigation drawers, bottom sheets, and menus. + final List elevation3; + + /// High elevation level for prominent elements. + /// + /// Used for dialogs, modals, and floating action buttons. + final List elevation4; + + /// Linearly interpolates between two [StreamBoxShadow] instances. + static StreamBoxShadow? lerp( + StreamBoxShadow? a, + StreamBoxShadow? b, + double t, + ) => _$StreamBoxShadow.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/semantics/stream_box_shadow.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/semantics/stream_box_shadow.g.theme.dart new file mode 100644 index 0000000..b3f5a5a --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/semantics/stream_box_shadow.g.theme.dart @@ -0,0 +1,106 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'stream_box_shadow.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamBoxShadow { + bool get canMerge => true; + + static StreamBoxShadow? lerp( + StreamBoxShadow? a, + StreamBoxShadow? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamBoxShadow.raw( + elevation1: t < 0.5 ? a.elevation1 : b.elevation1, + elevation2: t < 0.5 ? a.elevation2 : b.elevation2, + elevation3: t < 0.5 ? a.elevation3 : b.elevation3, + elevation4: t < 0.5 ? a.elevation4 : b.elevation4, + ); + } + + StreamBoxShadow copyWith({ + List? elevation1, + List? elevation2, + List? elevation3, + List? elevation4, + }) { + final _this = (this as StreamBoxShadow); + + return StreamBoxShadow.raw( + elevation1: elevation1 ?? _this.elevation1, + elevation2: elevation2 ?? _this.elevation2, + elevation3: elevation3 ?? _this.elevation3, + elevation4: elevation4 ?? _this.elevation4, + ); + } + + StreamBoxShadow merge(StreamBoxShadow? other) { + final _this = (this as StreamBoxShadow); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + elevation1: other.elevation1, + elevation2: other.elevation2, + elevation3: other.elevation3, + elevation4: other.elevation4, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamBoxShadow); + final _other = (other as StreamBoxShadow); + + return _other.elevation1 == _this.elevation1 && + _other.elevation2 == _this.elevation2 && + _other.elevation3 == _this.elevation3 && + _other.elevation4 == _this.elevation4; + } + + @override + int get hashCode { + final _this = (this as StreamBoxShadow); + + return Object.hash( + runtimeType, + _this.elevation1, + _this.elevation2, + _this.elevation3, + _this.elevation4, + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart b/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart new file mode 100644 index 0000000..e1dc645 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart @@ -0,0 +1,693 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../../theme/primitives/stream_colors.dart'; + +part 'stream_color_scheme.g.theme.dart'; + +/// A color scheme for the Stream design system. +/// +/// [StreamColorScheme] defines the semantic color palette for the Stream design +/// system, including brand colors, accent colors, text colors, background colors, +/// border colors, and state colors. It supports light and dark themes. +/// +/// {@tool snippet} +/// +/// Create a light color scheme: +/// +/// ```dart +/// final colorScheme = StreamColorScheme.light(); +/// final primary = colorScheme.accentPrimary; +/// final surface = colorScheme.backgroundSurface; +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Create a dark color scheme with custom brand color: +/// +/// ```dart +/// final colorScheme = StreamColorScheme.dark( +/// brand: StreamBrandColor.dark(), +/// ); +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamBrandColor], which provides brand color shades. +/// * [StreamColors], which defines the primitive color palette. +@immutable +@ThemeGen(constructor: 'raw') +class StreamColorScheme with _$StreamColorScheme { + /// Creates a light color scheme. + factory StreamColorScheme.light({ + // Brand + StreamBrandColor? brand, + // Accent + Color? accentPrimary, + Color? accentSuccess, + Color? accentWarning, + Color? accentError, + Color? accentNeutral, + // Text + Color? textPrimary, + Color? textSecondary, + Color? textTertiary, + Color? textDisabled, + Color? textInverse, + Color? textLink, + Color? textOnAccent, + // Background + Color? backgroundApp, + Color? backgroundSurface, + Color? backgroundSurfaceSubtle, + Color? backgroundSurfaceStrong, + Color? backgroundOverlay, + // Border - Core + Color? borderSurface, + Color? borderSurfaceSubtle, + Color? borderSurfaceStrong, + Color? borderOnDark, + Color? borderOnAccent, + Color? borderSubtle, + Color? borderImage, + // Border - Utility + Color? borderFocus, + Color? borderDisabled, + Color? borderError, + Color? borderWarning, + Color? borderSuccess, + Color? borderSelected, + // State + Color? stateHover, + Color? statePressed, + Color? stateSelected, + Color? stateFocused, + Color? stateDisabled, + // System + Color? systemText, + Color? systemScrollbar, + // Avatar + List? avatarPalette, + }) { + // Brand + brand ??= StreamBrandColor.light(); + + // Accent + accentPrimary ??= brand.shade500; + accentSuccess ??= StreamColors.green.shade500; + accentWarning ??= StreamColors.yellow.shade500; + accentError ??= StreamColors.red.shade500; + accentNeutral ??= StreamColors.slate.shade500; + + // Text + textPrimary ??= StreamColors.slate.shade900; + textSecondary ??= StreamColors.slate.shade700; + textTertiary ??= StreamColors.slate.shade600; + textDisabled ??= StreamColors.slate.shade400; + textInverse ??= StreamColors.white; + textLink ??= accentPrimary; + textOnAccent ??= StreamColors.white; + + // Background + backgroundApp ??= StreamColors.white; + backgroundSurface ??= StreamColors.slate.shade50; + backgroundSurfaceSubtle ??= StreamColors.slate.shade100; + backgroundSurfaceStrong ??= StreamColors.slate.shade200; + backgroundOverlay ??= StreamColors.black10; + + // Border - Core + borderSurface ??= StreamColors.slate.shade400; + borderSurfaceSubtle ??= StreamColors.slate.shade200; + borderSurfaceStrong ??= StreamColors.slate.shade600; + borderOnDark ??= StreamColors.white; + borderOnAccent ??= StreamColors.white; + borderSubtle ??= StreamColors.slate.shade100; + borderImage ??= StreamColors.black10; + + // Border - Utility + borderFocus ??= brand.shade300; + borderDisabled ??= StreamColors.slate.shade100; + borderError ??= accentError; + borderWarning ??= accentWarning; + borderSuccess ??= accentSuccess; + borderSelected ??= accentPrimary; + + // State + stateHover ??= StreamColors.black5; + statePressed ??= StreamColors.black10; + stateSelected ??= StreamColors.black10; + stateFocused ??= brand.shade100; + stateDisabled ??= StreamColors.slate.shade200; + + // System + systemText ??= StreamColors.black; + systemScrollbar ??= StreamColors.black50; + + // Avatar + avatarPalette ??= [ + StreamAvatarColorPair( + backgroundColor: StreamColors.blue.shade100, + foregroundColor: StreamColors.blue.shade800, + ), + StreamAvatarColorPair( + backgroundColor: StreamColors.cyan.shade100, + foregroundColor: StreamColors.cyan.shade800, + ), + StreamAvatarColorPair( + backgroundColor: StreamColors.green.shade100, + foregroundColor: StreamColors.green.shade800, + ), + StreamAvatarColorPair( + backgroundColor: StreamColors.purple.shade100, + foregroundColor: StreamColors.purple.shade800, + ), + StreamAvatarColorPair( + backgroundColor: StreamColors.yellow.shade100, + foregroundColor: StreamColors.yellow.shade800, + ), + ]; + + return .raw( + brand: brand, + accentPrimary: accentPrimary, + accentSuccess: accentSuccess, + accentWarning: accentWarning, + accentError: accentError, + accentNeutral: accentNeutral, + textPrimary: textPrimary, + textSecondary: textSecondary, + textTertiary: textTertiary, + textDisabled: textDisabled, + textInverse: textInverse, + textLink: textLink, + textOnAccent: textOnAccent, + backgroundApp: backgroundApp, + backgroundSurface: backgroundSurface, + backgroundSurfaceSubtle: backgroundSurfaceSubtle, + backgroundSurfaceStrong: backgroundSurfaceStrong, + backgroundOverlay: backgroundOverlay, + borderSurface: borderSurface, + borderSurfaceSubtle: borderSurfaceSubtle, + borderSurfaceStrong: borderSurfaceStrong, + borderOnDark: borderOnDark, + borderOnAccent: borderOnAccent, + borderSubtle: borderSubtle, + borderImage: borderImage, + borderFocus: borderFocus, + borderDisabled: borderDisabled, + borderError: borderError, + borderWarning: borderWarning, + borderSuccess: borderSuccess, + borderSelected: borderSelected, + stateHover: stateHover, + statePressed: statePressed, + stateSelected: stateSelected, + stateFocused: stateFocused, + stateDisabled: stateDisabled, + systemText: systemText, + systemScrollbar: systemScrollbar, + avatarPalette: avatarPalette, + ); + } + + /// Creates a dark color scheme. + factory StreamColorScheme.dark({ + // Brand + StreamBrandColor? brand, + // Accent + Color? accentPrimary, + Color? accentSuccess, + Color? accentWarning, + Color? accentError, + Color? accentNeutral, + // Text + Color? textPrimary, + Color? textSecondary, + Color? textTertiary, + Color? textDisabled, + Color? textInverse, + Color? textLink, + Color? textOnAccent, + // Background + Color? backgroundApp, + Color? backgroundSurface, + Color? backgroundSurfaceSubtle, + Color? backgroundSurfaceStrong, + Color? backgroundOverlay, + // Border - Core + Color? borderSurface, + Color? borderSurfaceSubtle, + Color? borderSurfaceStrong, + Color? borderOnDark, + Color? borderOnAccent, + Color? borderSubtle, + Color? borderImage, + // Border - Utility + Color? borderFocus, + Color? borderDisabled, + Color? borderError, + Color? borderWarning, + Color? borderSuccess, + Color? borderSelected, + // State + Color? stateHover, + Color? statePressed, + Color? stateSelected, + Color? stateFocused, + Color? stateDisabled, + // System + Color? systemText, + Color? systemScrollbar, + // Avatar + List? avatarPalette, + }) { + // Brand + brand ??= StreamBrandColor.dark(); + + // Accent + accentPrimary ??= brand.shade500; + accentSuccess ??= StreamColors.green.shade400; + accentWarning ??= StreamColors.yellow.shade400; + accentError ??= StreamColors.red.shade400; + accentNeutral ??= StreamColors.neutral.shade500; + + // Text + textPrimary ??= StreamColors.neutral.shade50; + textSecondary ??= StreamColors.neutral.shade300; + textTertiary ??= StreamColors.neutral.shade400; + textDisabled ??= StreamColors.neutral.shade600; + textInverse ??= StreamColors.black; + textLink ??= accentPrimary; + textOnAccent ??= StreamColors.white; + + // Background + backgroundApp ??= StreamColors.black; + backgroundSurface ??= StreamColors.neutral.shade900; + backgroundSurfaceSubtle ??= StreamColors.neutral.shade800; + backgroundSurfaceStrong ??= StreamColors.neutral.shade700; + backgroundOverlay ??= StreamColors.black50; + + // Border - Core + borderSurface ??= StreamColors.neutral.shade500; + borderSurfaceSubtle ??= StreamColors.neutral.shade700; + borderSurfaceStrong ??= StreamColors.neutral.shade400; + borderOnDark ??= StreamColors.white; + borderOnAccent ??= StreamColors.white; + borderSubtle ??= StreamColors.neutral.shade800; + borderImage ??= StreamColors.white20; + + // Border - Utility + borderFocus ??= brand.shade300; + borderDisabled ??= StreamColors.neutral.shade800; + borderError ??= accentError; + borderWarning ??= accentWarning; + borderSuccess ??= accentSuccess; + borderSelected ??= StreamColors.white; + + // State + stateHover ??= StreamColors.black5; + statePressed ??= StreamColors.black10; + stateSelected ??= StreamColors.black10; + stateFocused ??= brand.shade100; + stateDisabled ??= StreamColors.neutral.shade800; + + // System + systemText ??= StreamColors.white; + systemScrollbar ??= StreamColors.white50; + + // Avatar + avatarPalette ??= [ + StreamAvatarColorPair( + backgroundColor: StreamColors.blue.shade800, + foregroundColor: StreamColors.blue.shade100, + ), + StreamAvatarColorPair( + backgroundColor: StreamColors.cyan.shade800, + foregroundColor: StreamColors.cyan.shade100, + ), + StreamAvatarColorPair( + backgroundColor: StreamColors.green.shade800, + foregroundColor: StreamColors.green.shade100, + ), + StreamAvatarColorPair( + backgroundColor: StreamColors.purple.shade800, + foregroundColor: StreamColors.purple.shade100, + ), + StreamAvatarColorPair( + backgroundColor: StreamColors.yellow.shade800, + foregroundColor: StreamColors.yellow.shade100, + ), + ]; + + return .raw( + brand: brand, + accentPrimary: accentPrimary, + accentSuccess: accentSuccess, + accentWarning: accentWarning, + accentError: accentError, + accentNeutral: accentNeutral, + textPrimary: textPrimary, + textSecondary: textSecondary, + textTertiary: textTertiary, + textDisabled: textDisabled, + textInverse: textInverse, + textLink: textLink, + textOnAccent: textOnAccent, + backgroundApp: backgroundApp, + backgroundSurface: backgroundSurface, + backgroundSurfaceSubtle: backgroundSurfaceSubtle, + backgroundSurfaceStrong: backgroundSurfaceStrong, + backgroundOverlay: backgroundOverlay, + borderSurface: borderSurface, + borderSurfaceSubtle: borderSurfaceSubtle, + borderSurfaceStrong: borderSurfaceStrong, + borderOnDark: borderOnDark, + borderOnAccent: borderOnAccent, + borderSubtle: borderSubtle, + borderImage: borderImage, + borderFocus: borderFocus, + borderDisabled: borderDisabled, + borderError: borderError, + borderWarning: borderWarning, + borderSuccess: borderSuccess, + borderSelected: borderSelected, + stateHover: stateHover, + statePressed: statePressed, + stateSelected: stateSelected, + stateFocused: stateFocused, + stateDisabled: stateDisabled, + systemText: systemText, + systemScrollbar: systemScrollbar, + avatarPalette: avatarPalette, + ); + } + + const StreamColorScheme.raw({ + required this.brand, + // Accent + required this.accentPrimary, + required this.accentSuccess, + required this.accentWarning, + required this.accentError, + required this.accentNeutral, + // Text + required this.textPrimary, + required this.textSecondary, + required this.textTertiary, + required this.textDisabled, + required this.textInverse, + required this.textLink, + required this.textOnAccent, + // Background + required this.backgroundApp, + required this.backgroundSurface, + required this.backgroundSurfaceSubtle, + required this.backgroundSurfaceStrong, + required this.backgroundOverlay, + // Border - Core + required this.borderSurface, + required this.borderSurfaceSubtle, + required this.borderSurfaceStrong, + required this.borderOnDark, + required this.borderOnAccent, + required this.borderSubtle, + required this.borderImage, + // Border - Utility + required this.borderFocus, + required this.borderDisabled, + required this.borderError, + required this.borderWarning, + required this.borderSuccess, + required this.borderSelected, + // State + required this.stateHover, + required this.statePressed, + required this.stateSelected, + required this.stateFocused, + required this.stateDisabled, + // System + required this.systemText, + required this.systemScrollbar, + // Avatar + required this.avatarPalette, + }); + + // ---- Brand ---- + + /// The brand color swatch with shades from 50 to 950. + final StreamBrandColor brand; + + // ---- Accent colors ---- + + /// The primary accent color. + final Color accentPrimary; + + /// The success accent color. + final Color accentSuccess; + + /// The warning accent color. + final Color accentWarning; + + /// The error accent color. + final Color accentError; + + /// The neutral accent color. + final Color accentNeutral; + + // ---- Text colors ---- + + /// The primary text color. + final Color textPrimary; + + /// The secondary text color. + final Color textSecondary; + + /// The tertiary text color. + final Color textTertiary; + + /// The disabled text color. + final Color textDisabled; + + /// The inverse text color. + final Color textInverse; + + /// The link text color. + final Color textLink; + + /// The text color on accent backgrounds. + final Color textOnAccent; + + // ---- Background colors ---- + + /// The main app background color. + final Color backgroundApp; + + /// The surface background color. + final Color backgroundSurface; + + /// The subtle surface background color. + final Color backgroundSurfaceSubtle; + + /// The strong surface background color. + final Color backgroundSurfaceStrong; + + /// The overlay background color. + final Color backgroundOverlay; + + // ---- Border colors - Core ---- + + /// The standard surface border color. + final Color borderSurface; + + /// The subtle surface border color for separators. + final Color borderSurfaceSubtle; + + /// The strong surface border color. + final Color borderSurfaceStrong; + + /// The border color on dark backgrounds. + final Color borderOnDark; + + /// The border color on accent backgrounds. + final Color borderOnAccent; + + /// The subtle border color for light outlines. + final Color borderSubtle; + + /// The image frame border color. + final Color borderImage; + + // ---- Border colors - Utility ---- + + /// The focus ring border color. + final Color borderFocus; + + /// The disabled state border color. + final Color borderDisabled; + + /// The error state border color. + final Color borderError; + + /// The warning state border color. + final Color borderWarning; + + /// The success state border color. + final Color borderSuccess; + + /// The selected state border color. + final Color borderSelected; + + // ---- State colors ---- + + /// The hover state overlay color. + final Color stateHover; + + /// The pressed state overlay color. + final Color statePressed; + + /// The selected state overlay color. + final Color stateSelected; + + /// The focused state overlay color. + final Color stateFocused; + + /// The disabled state color. + final Color stateDisabled; + + // ---- System colors ---- + + /// The system text color. + final Color systemText; + + /// The system scrollbar color. + final Color systemScrollbar; + + // ---- Avatar colors ---- + + /// The color palette for generating avatar colors based on user identity. + /// + /// Used by domain widgets like `UserAvatar` to deterministically + /// select colors based on user name or ID. + final List avatarPalette; + + /// Linearly interpolates between this and another [StreamColorScheme]. + StreamColorScheme lerp(StreamColorScheme? other, double t) { + return _$StreamColorScheme.lerp(this, other, t)!; + } +} + +/// The brand color swatch for the Stream design system. +/// +/// [StreamBrandColor] extends [StreamColorSwatch] and provides the primary +/// brand color palette with shades from 50 to 950. The default brand color +/// is blue, but it can be customized to match your brand identity. +/// +/// Note: This class extends [ColorSwatch] and cannot implement [ThemeExtension]. +/// Color interpolation is handled via [Color.lerp] on the primary value. +/// +/// {@tool snippet} +/// +/// Use brand colors from a color scheme: +/// +/// ```dart +/// final colorScheme = StreamColorScheme.light(); +/// final brandColor = colorScheme.brand; // Uses shade500 by default +/// final lightBrand = colorScheme.brand.shade300; +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamColorScheme], which contains the brand color. +/// * [StreamColorSwatch], the base class for color swatches. +@immutable +class StreamBrandColor extends StreamColorSwatch { + const StreamBrandColor._(super.primary, super._swatch); + + /// Creates a light theme brand color swatch. + /// + /// Defaults to blue with shade500 as the primary color. + factory StreamBrandColor.light() { + final primaryColorValue = StreamColors.blue.shade500.toARGB32(); + return ._( + primaryColorValue, + { + 50: StreamColors.blue.shade50, + 100: StreamColors.blue.shade100, + 200: StreamColors.blue.shade200, + 300: StreamColors.blue.shade300, + 400: StreamColors.blue.shade400, + 500: Color(primaryColorValue), + 600: StreamColors.blue.shade600, + 700: StreamColors.blue.shade700, + 800: StreamColors.blue.shade800, + 900: StreamColors.blue.shade900, + 950: StreamColors.blue.shade950, + }, + ); + } + + /// Creates a dark theme brand color swatch. + /// + /// Defaults to blue with shade400 as the primary color. The shade values + /// are inverted for dark mode, with lighter shades becoming darker and + /// vice versa. + factory StreamBrandColor.dark() { + final primaryColorValue = StreamColors.blue.shade400.toARGB32(); + return ._( + primaryColorValue, + { + 50: StreamColors.blue.shade900, + 100: StreamColors.blue.shade800, + 200: StreamColors.blue.shade700, + 300: StreamColors.blue.shade600, + 400: StreamColors.blue.shade500, + 500: Color(primaryColorValue), + 600: StreamColors.blue.shade300, + 700: StreamColors.blue.shade200, + 800: StreamColors.blue.shade100, + 900: StreamColors.blue.shade50, + 950: StreamColors.white, + }, + ); + } + + /// Linearly interpolates between two colors. + static StreamBrandColor? lerp(Color? x, Color? y, double t) => Color.lerp(x, y, t) as StreamBrandColor; +} + +/// A background/foreground color pair for avatars. +/// +/// Used for deterministic color selection based on user identity. +/// The palette is part of [StreamColorScheme] to support light/dark themes. +@immutable +class StreamAvatarColorPair { + /// Creates an avatar color pair with the given values. + const StreamAvatarColorPair({ + required this.backgroundColor, + required this.foregroundColor, + }); + + /// The background color for the avatar. + final Color backgroundColor; + + /// The foreground color for the avatar initials. + final Color foregroundColor; + + /// Linearly interpolates between two [StreamAvatarColorPair] instances. + static StreamAvatarColorPair? lerp( + StreamAvatarColorPair? a, + StreamAvatarColorPair? b, + double t, + ) { + if (identical(a, b)) return a; + if (a == null && b == null) return null; + return StreamAvatarColorPair( + backgroundColor: Color.lerp(a?.backgroundColor, b?.backgroundColor, t)!, + foregroundColor: Color.lerp(a?.foregroundColor, b?.foregroundColor, t)!, + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.g.theme.dart new file mode 100644 index 0000000..7871433 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.g.theme.dart @@ -0,0 +1,342 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'stream_color_scheme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamColorScheme { + bool get canMerge => true; + + static StreamColorScheme? lerp( + StreamColorScheme? a, + StreamColorScheme? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamColorScheme.raw( + brand: StreamBrandColor.lerp(a.brand, b.brand, t)!, + accentPrimary: Color.lerp(a.accentPrimary, b.accentPrimary, t)!, + accentSuccess: Color.lerp(a.accentSuccess, b.accentSuccess, t)!, + accentWarning: Color.lerp(a.accentWarning, b.accentWarning, t)!, + accentError: Color.lerp(a.accentError, b.accentError, t)!, + accentNeutral: Color.lerp(a.accentNeutral, b.accentNeutral, t)!, + textPrimary: Color.lerp(a.textPrimary, b.textPrimary, t)!, + textSecondary: Color.lerp(a.textSecondary, b.textSecondary, t)!, + textTertiary: Color.lerp(a.textTertiary, b.textTertiary, t)!, + textDisabled: Color.lerp(a.textDisabled, b.textDisabled, t)!, + textInverse: Color.lerp(a.textInverse, b.textInverse, t)!, + textLink: Color.lerp(a.textLink, b.textLink, t)!, + textOnAccent: Color.lerp(a.textOnAccent, b.textOnAccent, t)!, + backgroundApp: Color.lerp(a.backgroundApp, b.backgroundApp, t)!, + backgroundSurface: Color.lerp( + a.backgroundSurface, + b.backgroundSurface, + t, + )!, + backgroundSurfaceSubtle: Color.lerp( + a.backgroundSurfaceSubtle, + b.backgroundSurfaceSubtle, + t, + )!, + backgroundSurfaceStrong: Color.lerp( + a.backgroundSurfaceStrong, + b.backgroundSurfaceStrong, + t, + )!, + backgroundOverlay: Color.lerp( + a.backgroundOverlay, + b.backgroundOverlay, + t, + )!, + borderSurface: Color.lerp(a.borderSurface, b.borderSurface, t)!, + borderSurfaceSubtle: Color.lerp( + a.borderSurfaceSubtle, + b.borderSurfaceSubtle, + t, + )!, + borderSurfaceStrong: Color.lerp( + a.borderSurfaceStrong, + b.borderSurfaceStrong, + t, + )!, + borderOnDark: Color.lerp(a.borderOnDark, b.borderOnDark, t)!, + borderOnAccent: Color.lerp(a.borderOnAccent, b.borderOnAccent, t)!, + borderSubtle: Color.lerp(a.borderSubtle, b.borderSubtle, t)!, + borderImage: Color.lerp(a.borderImage, b.borderImage, t)!, + borderFocus: Color.lerp(a.borderFocus, b.borderFocus, t)!, + borderDisabled: Color.lerp(a.borderDisabled, b.borderDisabled, t)!, + borderError: Color.lerp(a.borderError, b.borderError, t)!, + borderWarning: Color.lerp(a.borderWarning, b.borderWarning, t)!, + borderSuccess: Color.lerp(a.borderSuccess, b.borderSuccess, t)!, + borderSelected: Color.lerp(a.borderSelected, b.borderSelected, t)!, + stateHover: Color.lerp(a.stateHover, b.stateHover, t)!, + statePressed: Color.lerp(a.statePressed, b.statePressed, t)!, + stateSelected: Color.lerp(a.stateSelected, b.stateSelected, t)!, + stateFocused: Color.lerp(a.stateFocused, b.stateFocused, t)!, + stateDisabled: Color.lerp(a.stateDisabled, b.stateDisabled, t)!, + systemText: Color.lerp(a.systemText, b.systemText, t)!, + systemScrollbar: Color.lerp(a.systemScrollbar, b.systemScrollbar, t)!, + avatarPalette: t < 0.5 ? a.avatarPalette : b.avatarPalette, + ); + } + + StreamColorScheme copyWith({ + StreamBrandColor? brand, + Color? accentPrimary, + Color? accentSuccess, + Color? accentWarning, + Color? accentError, + Color? accentNeutral, + Color? textPrimary, + Color? textSecondary, + Color? textTertiary, + Color? textDisabled, + Color? textInverse, + Color? textLink, + Color? textOnAccent, + Color? backgroundApp, + Color? backgroundSurface, + Color? backgroundSurfaceSubtle, + Color? backgroundSurfaceStrong, + Color? backgroundOverlay, + Color? borderSurface, + Color? borderSurfaceSubtle, + Color? borderSurfaceStrong, + Color? borderOnDark, + Color? borderOnAccent, + Color? borderSubtle, + Color? borderImage, + Color? borderFocus, + Color? borderDisabled, + Color? borderError, + Color? borderWarning, + Color? borderSuccess, + Color? borderSelected, + Color? stateHover, + Color? statePressed, + Color? stateSelected, + Color? stateFocused, + Color? stateDisabled, + Color? systemText, + Color? systemScrollbar, + List? avatarPalette, + }) { + final _this = (this as StreamColorScheme); + + return StreamColorScheme.raw( + brand: brand ?? _this.brand, + accentPrimary: accentPrimary ?? _this.accentPrimary, + accentSuccess: accentSuccess ?? _this.accentSuccess, + accentWarning: accentWarning ?? _this.accentWarning, + accentError: accentError ?? _this.accentError, + accentNeutral: accentNeutral ?? _this.accentNeutral, + textPrimary: textPrimary ?? _this.textPrimary, + textSecondary: textSecondary ?? _this.textSecondary, + textTertiary: textTertiary ?? _this.textTertiary, + textDisabled: textDisabled ?? _this.textDisabled, + textInverse: textInverse ?? _this.textInverse, + textLink: textLink ?? _this.textLink, + textOnAccent: textOnAccent ?? _this.textOnAccent, + backgroundApp: backgroundApp ?? _this.backgroundApp, + backgroundSurface: backgroundSurface ?? _this.backgroundSurface, + backgroundSurfaceSubtle: + backgroundSurfaceSubtle ?? _this.backgroundSurfaceSubtle, + backgroundSurfaceStrong: + backgroundSurfaceStrong ?? _this.backgroundSurfaceStrong, + backgroundOverlay: backgroundOverlay ?? _this.backgroundOverlay, + borderSurface: borderSurface ?? _this.borderSurface, + borderSurfaceSubtle: borderSurfaceSubtle ?? _this.borderSurfaceSubtle, + borderSurfaceStrong: borderSurfaceStrong ?? _this.borderSurfaceStrong, + borderOnDark: borderOnDark ?? _this.borderOnDark, + borderOnAccent: borderOnAccent ?? _this.borderOnAccent, + borderSubtle: borderSubtle ?? _this.borderSubtle, + borderImage: borderImage ?? _this.borderImage, + borderFocus: borderFocus ?? _this.borderFocus, + borderDisabled: borderDisabled ?? _this.borderDisabled, + borderError: borderError ?? _this.borderError, + borderWarning: borderWarning ?? _this.borderWarning, + borderSuccess: borderSuccess ?? _this.borderSuccess, + borderSelected: borderSelected ?? _this.borderSelected, + stateHover: stateHover ?? _this.stateHover, + statePressed: statePressed ?? _this.statePressed, + stateSelected: stateSelected ?? _this.stateSelected, + stateFocused: stateFocused ?? _this.stateFocused, + stateDisabled: stateDisabled ?? _this.stateDisabled, + systemText: systemText ?? _this.systemText, + systemScrollbar: systemScrollbar ?? _this.systemScrollbar, + avatarPalette: avatarPalette ?? _this.avatarPalette, + ); + } + + StreamColorScheme merge(StreamColorScheme? other) { + final _this = (this as StreamColorScheme); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + brand: other.brand, + accentPrimary: other.accentPrimary, + accentSuccess: other.accentSuccess, + accentWarning: other.accentWarning, + accentError: other.accentError, + accentNeutral: other.accentNeutral, + textPrimary: other.textPrimary, + textSecondary: other.textSecondary, + textTertiary: other.textTertiary, + textDisabled: other.textDisabled, + textInverse: other.textInverse, + textLink: other.textLink, + textOnAccent: other.textOnAccent, + backgroundApp: other.backgroundApp, + backgroundSurface: other.backgroundSurface, + backgroundSurfaceSubtle: other.backgroundSurfaceSubtle, + backgroundSurfaceStrong: other.backgroundSurfaceStrong, + backgroundOverlay: other.backgroundOverlay, + borderSurface: other.borderSurface, + borderSurfaceSubtle: other.borderSurfaceSubtle, + borderSurfaceStrong: other.borderSurfaceStrong, + borderOnDark: other.borderOnDark, + borderOnAccent: other.borderOnAccent, + borderSubtle: other.borderSubtle, + borderImage: other.borderImage, + borderFocus: other.borderFocus, + borderDisabled: other.borderDisabled, + borderError: other.borderError, + borderWarning: other.borderWarning, + borderSuccess: other.borderSuccess, + borderSelected: other.borderSelected, + stateHover: other.stateHover, + statePressed: other.statePressed, + stateSelected: other.stateSelected, + stateFocused: other.stateFocused, + stateDisabled: other.stateDisabled, + systemText: other.systemText, + systemScrollbar: other.systemScrollbar, + avatarPalette: other.avatarPalette, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamColorScheme); + final _other = (other as StreamColorScheme); + + return _other.brand == _this.brand && + _other.accentPrimary == _this.accentPrimary && + _other.accentSuccess == _this.accentSuccess && + _other.accentWarning == _this.accentWarning && + _other.accentError == _this.accentError && + _other.accentNeutral == _this.accentNeutral && + _other.textPrimary == _this.textPrimary && + _other.textSecondary == _this.textSecondary && + _other.textTertiary == _this.textTertiary && + _other.textDisabled == _this.textDisabled && + _other.textInverse == _this.textInverse && + _other.textLink == _this.textLink && + _other.textOnAccent == _this.textOnAccent && + _other.backgroundApp == _this.backgroundApp && + _other.backgroundSurface == _this.backgroundSurface && + _other.backgroundSurfaceSubtle == _this.backgroundSurfaceSubtle && + _other.backgroundSurfaceStrong == _this.backgroundSurfaceStrong && + _other.backgroundOverlay == _this.backgroundOverlay && + _other.borderSurface == _this.borderSurface && + _other.borderSurfaceSubtle == _this.borderSurfaceSubtle && + _other.borderSurfaceStrong == _this.borderSurfaceStrong && + _other.borderOnDark == _this.borderOnDark && + _other.borderOnAccent == _this.borderOnAccent && + _other.borderSubtle == _this.borderSubtle && + _other.borderImage == _this.borderImage && + _other.borderFocus == _this.borderFocus && + _other.borderDisabled == _this.borderDisabled && + _other.borderError == _this.borderError && + _other.borderWarning == _this.borderWarning && + _other.borderSuccess == _this.borderSuccess && + _other.borderSelected == _this.borderSelected && + _other.stateHover == _this.stateHover && + _other.statePressed == _this.statePressed && + _other.stateSelected == _this.stateSelected && + _other.stateFocused == _this.stateFocused && + _other.stateDisabled == _this.stateDisabled && + _other.systemText == _this.systemText && + _other.systemScrollbar == _this.systemScrollbar && + _other.avatarPalette == _this.avatarPalette; + } + + @override + int get hashCode { + final _this = (this as StreamColorScheme); + + return Object.hashAll([ + runtimeType, + _this.brand, + _this.accentPrimary, + _this.accentSuccess, + _this.accentWarning, + _this.accentError, + _this.accentNeutral, + _this.textPrimary, + _this.textSecondary, + _this.textTertiary, + _this.textDisabled, + _this.textInverse, + _this.textLink, + _this.textOnAccent, + _this.backgroundApp, + _this.backgroundSurface, + _this.backgroundSurfaceSubtle, + _this.backgroundSurfaceStrong, + _this.backgroundOverlay, + _this.borderSurface, + _this.borderSurfaceSubtle, + _this.borderSurfaceStrong, + _this.borderOnDark, + _this.borderOnAccent, + _this.borderSubtle, + _this.borderImage, + _this.borderFocus, + _this.borderDisabled, + _this.borderError, + _this.borderWarning, + _this.borderSuccess, + _this.borderSelected, + _this.stateHover, + _this.statePressed, + _this.stateSelected, + _this.stateFocused, + _this.stateDisabled, + _this.systemText, + _this.systemScrollbar, + _this.avatarPalette, + ]); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/semantics/stream_text_theme.dart b/packages/stream_core_flutter/lib/src/theme/semantics/stream_text_theme.dart new file mode 100644 index 0000000..e5ccf52 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/semantics/stream_text_theme.dart @@ -0,0 +1,568 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../primitives/stream_typography.dart'; + +part 'stream_text_theme.g.theme.dart'; + +/// Semantic text theme for the Stream design system. +/// +/// [StreamTextTheme] provides semantic text styles built from [StreamTypography] +/// primitives. It includes styles for headings, body text, captions, metadata, +/// and numeric displays. +/// +/// {@tool snippet} +/// +/// Create a text theme: +/// +/// ```dart +/// final textTheme = StreamTextTheme(); +/// Text('Hello', style: textTheme.headingLg); +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamTypography], which provides the primitive building blocks. +@immutable +@ThemeGen(constructor: 'raw') +class StreamTextTheme with _$StreamTextTheme { + /// Creates a [StreamTextTheme] with platform-specific values. + /// + /// If [platform] is null, uses [defaultTargetPlatform]. + /// Individual text styles can be overridden by providing them directly. + factory StreamTextTheme({ + TargetPlatform? platform, + StreamTypography? typography, + TextStyle? headingLg, + TextStyle? headingMd, + TextStyle? headingSm, + TextStyle? bodyDefault, + TextStyle? bodyEmphasis, + TextStyle? bodyLink, + TextStyle? bodyLinkEmphasis, + TextStyle? captionDefault, + TextStyle? captionEmphasis, + TextStyle? captionLink, + TextStyle? captionLinkEmphasis, + TextStyle? metadataDefault, + TextStyle? metadataEmphasis, + TextStyle? metadataLink, + TextStyle? metadataLinkEmphasis, + TextStyle? numericLg, + TextStyle? numericMd, + TextStyle? numericSm, + }) { + platform ??= defaultTargetPlatform; + typography ??= StreamTypography(platform: platform); + + final fontSize = typography.fontSize; + final lineHeight = typography.lineHeight; + final fontWeight = typography.fontWeight; + + // Heading styles + headingLg ??= TextStyle( + fontSize: fontSize.xl, + fontWeight: fontWeight.semibold, + height: lineHeight.relaxed / fontSize.xl, + fontStyle: FontStyle.normal, + decoration: TextDecoration.none, + ); + headingMd ??= TextStyle( + fontSize: fontSize.lg, + fontWeight: fontWeight.semibold, + height: lineHeight.normal / fontSize.lg, + fontStyle: FontStyle.normal, + decoration: TextDecoration.none, + ); + headingSm ??= TextStyle( + fontSize: fontSize.md, + fontWeight: fontWeight.semibold, + height: lineHeight.tight / fontSize.md, + fontStyle: FontStyle.normal, + decoration: TextDecoration.none, + ); + + // Body styles + bodyDefault ??= TextStyle( + fontSize: fontSize.md, + fontWeight: fontWeight.regular, + height: lineHeight.normal / fontSize.md, + fontStyle: FontStyle.normal, + decoration: TextDecoration.none, + ); + bodyEmphasis ??= TextStyle( + fontSize: fontSize.md, + fontWeight: fontWeight.semibold, + height: lineHeight.normal / fontSize.md, + fontStyle: FontStyle.normal, + decoration: TextDecoration.none, + ); + bodyLink ??= TextStyle( + fontSize: fontSize.md, + fontWeight: fontWeight.regular, + height: lineHeight.normal / fontSize.md, + fontStyle: FontStyle.normal, + decoration: TextDecoration.none, + ); + bodyLinkEmphasis ??= TextStyle( + fontSize: fontSize.md, + fontWeight: fontWeight.semibold, + height: lineHeight.normal / fontSize.md, + fontStyle: FontStyle.normal, + decoration: TextDecoration.none, + ); + + // Caption styles + captionDefault ??= TextStyle( + fontSize: fontSize.sm, + fontWeight: fontWeight.regular, + height: lineHeight.tight / fontSize.sm, + fontStyle: FontStyle.normal, + decoration: TextDecoration.none, + ); + captionEmphasis ??= TextStyle( + fontSize: fontSize.sm, + fontWeight: fontWeight.semibold, + height: lineHeight.tight / fontSize.sm, + fontStyle: FontStyle.normal, + decoration: TextDecoration.none, + ); + captionLink ??= TextStyle( + fontSize: fontSize.sm, + fontWeight: fontWeight.regular, + height: lineHeight.tight / fontSize.sm, + fontStyle: FontStyle.normal, + decoration: TextDecoration.none, + ); + captionLinkEmphasis ??= TextStyle( + fontSize: fontSize.sm, + fontWeight: fontWeight.semibold, + height: lineHeight.tight / fontSize.sm, + fontStyle: FontStyle.normal, + decoration: TextDecoration.none, + ); + + // Metadata styles + metadataDefault ??= TextStyle( + fontSize: fontSize.xs, + fontWeight: fontWeight.regular, + height: lineHeight.tight / fontSize.xs, + fontStyle: FontStyle.normal, + decoration: TextDecoration.none, + ); + metadataEmphasis ??= TextStyle( + fontSize: fontSize.xs, + fontWeight: fontWeight.semibold, + height: lineHeight.tight / fontSize.xs, + fontStyle: FontStyle.normal, + decoration: TextDecoration.none, + ); + metadataLink ??= TextStyle( + fontSize: fontSize.xs, + fontWeight: fontWeight.regular, + height: lineHeight.tight / fontSize.xs, + fontStyle: FontStyle.normal, + decoration: TextDecoration.none, + ); + metadataLinkEmphasis ??= TextStyle( + fontSize: fontSize.xs, + fontWeight: fontWeight.semibold, + height: lineHeight.tight / fontSize.xs, + fontStyle: FontStyle.normal, + decoration: TextDecoration.none, + ); + + // Numeric styles + numericLg ??= TextStyle( + fontSize: fontSize.xs, + fontWeight: fontWeight.bold, + height: 12 / fontSize.xs, + fontStyle: FontStyle.normal, + decoration: TextDecoration.none, + ); + numericMd ??= TextStyle( + fontSize: fontSize.xxs, + fontWeight: fontWeight.bold, + height: 10 / fontSize.xxs, + fontStyle: FontStyle.normal, + decoration: TextDecoration.none, + ); + numericSm ??= TextStyle( + fontSize: fontSize.micro, + fontWeight: fontWeight.bold, + height: 8 / fontSize.micro, + fontStyle: FontStyle.normal, + decoration: TextDecoration.none, + ); + + return .raw( + headingLg: headingLg, + headingMd: headingMd, + headingSm: headingSm, + bodyDefault: bodyDefault, + bodyEmphasis: bodyEmphasis, + bodyLink: bodyLink, + bodyLinkEmphasis: bodyLinkEmphasis, + captionDefault: captionDefault, + captionEmphasis: captionEmphasis, + captionLink: captionLink, + captionLinkEmphasis: captionLinkEmphasis, + metadataDefault: metadataDefault, + metadataEmphasis: metadataEmphasis, + metadataLink: metadataLink, + metadataLinkEmphasis: metadataLinkEmphasis, + numericLg: numericLg, + numericMd: numericMd, + numericSm: numericSm, + ); + } + + const StreamTextTheme.raw({ + required this.headingLg, + required this.headingMd, + required this.headingSm, + required this.bodyDefault, + required this.bodyEmphasis, + required this.bodyLink, + required this.bodyLinkEmphasis, + required this.captionDefault, + required this.captionEmphasis, + required this.captionLink, + required this.captionLinkEmphasis, + required this.metadataDefault, + required this.metadataEmphasis, + required this.metadataLink, + required this.metadataLinkEmphasis, + required this.numericLg, + required this.numericMd, + required this.numericSm, + }); + + /// Large heading text style. + /// + /// Uses semibold weight, xl font size, and relaxed line height. + final TextStyle headingLg; + + /// Medium heading text style. + /// + /// Uses semibold weight, lg font size, and normal line height. + final TextStyle headingMd; + + /// Small heading text style. + /// + /// Uses semibold weight, md font size, and tight line height. + final TextStyle headingSm; + + /// Default body text style. + /// + /// Uses regular weight, md font size, and normal line height. + final TextStyle bodyDefault; + + /// Emphasized body text style. + /// + /// Uses semibold weight, md font size, and normal line height. + final TextStyle bodyEmphasis; + + /// Body link text style. + /// + /// Uses regular weight, md font size, and normal line height. + final TextStyle bodyLink; + + /// Emphasized body link text style. + /// + /// Uses semibold weight, md font size, and normal line height. + final TextStyle bodyLinkEmphasis; + + /// Default caption text style. + /// + /// Uses regular weight, sm font size, and tight line height. + final TextStyle captionDefault; + + /// Emphasized caption text style. + /// + /// Uses semibold weight, sm font size, and tight line height. + final TextStyle captionEmphasis; + + /// Caption link text style. + /// + /// Uses regular weight, sm font size, and tight line height. + final TextStyle captionLink; + + /// Emphasized caption link text style. + /// + /// Uses semibold weight, sm font size, and tight line height. + final TextStyle captionLinkEmphasis; + + /// Default metadata text style. + /// + /// Uses regular weight, xs font size, and tight line height. + final TextStyle metadataDefault; + + /// Emphasized metadata text style. + /// + /// Uses semibold weight, xs font size, and tight line height. + final TextStyle metadataEmphasis; + + /// Metadata link text style. + /// + /// Uses regular weight, xs font size, and tight line height. + final TextStyle metadataLink; + + /// Emphasized metadata link text style. + /// + /// Uses semibold weight, xs font size, and tight line height. + final TextStyle metadataLinkEmphasis; + + /// Large numeric text style. + /// + /// Uses bold weight and xs font size. Optimized for displaying numbers. + final TextStyle numericLg; + + /// Medium numeric text style. + /// + /// Uses bold weight and xxs font size. Optimized for displaying numbers. + final TextStyle numericMd; + + /// Small numeric text style. + /// + /// Uses bold weight and micro font size. Optimized for displaying numbers. + final TextStyle numericSm; + + /// Linearly interpolates between this and another [StreamTextTheme]. + StreamTextTheme lerp(StreamTextTheme? other, double t) { + return _$StreamTextTheme.lerp(this, other, t)!; + } + + /// Creates a copy of this text theme but with the given fields replaced with + /// the new values. + /// + /// The [heightFactor] applies a multiplier to all line heights. + /// The [heightDelta] adds a fixed amount to all line heights after the factor. + /// The [fontSizeFactor] applies a multiplier to all font sizes. + /// The [fontSizeDelta] adds a fixed amount to all font sizes after the factor. + /// + /// {@tool snippet} + /// + /// Apply a color and font family to all text styles: + /// + /// ```dart + /// final textTheme = StreamTextTheme(); + /// final themed = textTheme.apply( + /// color: Colors.blue, + /// fontFamily: 'Roboto', + /// ); + /// ``` + /// {@end-tool} + StreamTextTheme apply({ + Color? color, + String? package, + String? fontFamily, + List? fontFamilyFallback, + double heightFactor = 1.0, + double heightDelta = 0.0, + double fontSizeFactor = 1.0, + double fontSizeDelta = 0.0, + TextDecoration? decoration, + }) => StreamTextTheme.raw( + headingLg: headingLg.apply( + color: color, + package: package, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + heightFactor: heightFactor, + heightDelta: heightDelta, + fontSizeFactor: fontSizeFactor, + fontSizeDelta: fontSizeDelta, + decoration: decoration, + ), + headingMd: headingMd.apply( + color: color, + package: package, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + heightFactor: heightFactor, + heightDelta: heightDelta, + fontSizeFactor: fontSizeFactor, + fontSizeDelta: fontSizeDelta, + decoration: decoration, + ), + headingSm: headingSm.apply( + color: color, + package: package, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + heightFactor: heightFactor, + heightDelta: heightDelta, + fontSizeFactor: fontSizeFactor, + fontSizeDelta: fontSizeDelta, + decoration: decoration, + ), + bodyDefault: bodyDefault.apply( + color: color, + package: package, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + heightFactor: heightFactor, + heightDelta: heightDelta, + fontSizeFactor: fontSizeFactor, + fontSizeDelta: fontSizeDelta, + decoration: decoration, + ), + bodyEmphasis: bodyEmphasis.apply( + color: color, + package: package, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + heightFactor: heightFactor, + heightDelta: heightDelta, + fontSizeFactor: fontSizeFactor, + fontSizeDelta: fontSizeDelta, + decoration: decoration, + ), + bodyLink: bodyLink.apply( + color: color, + package: package, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + heightFactor: heightFactor, + heightDelta: heightDelta, + fontSizeFactor: fontSizeFactor, + fontSizeDelta: fontSizeDelta, + decoration: decoration, + ), + bodyLinkEmphasis: bodyLinkEmphasis.apply( + color: color, + package: package, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + heightFactor: heightFactor, + heightDelta: heightDelta, + fontSizeFactor: fontSizeFactor, + fontSizeDelta: fontSizeDelta, + decoration: decoration, + ), + captionDefault: captionDefault.apply( + color: color, + package: package, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + heightFactor: heightFactor, + heightDelta: heightDelta, + fontSizeFactor: fontSizeFactor, + fontSizeDelta: fontSizeDelta, + decoration: decoration, + ), + captionEmphasis: captionEmphasis.apply( + color: color, + package: package, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + heightFactor: heightFactor, + heightDelta: heightDelta, + fontSizeFactor: fontSizeFactor, + fontSizeDelta: fontSizeDelta, + decoration: decoration, + ), + captionLink: captionLink.apply( + color: color, + package: package, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + heightFactor: heightFactor, + heightDelta: heightDelta, + fontSizeFactor: fontSizeFactor, + fontSizeDelta: fontSizeDelta, + decoration: decoration, + ), + captionLinkEmphasis: captionLinkEmphasis.apply( + color: color, + package: package, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + heightFactor: heightFactor, + heightDelta: heightDelta, + fontSizeFactor: fontSizeFactor, + fontSizeDelta: fontSizeDelta, + decoration: decoration, + ), + metadataDefault: metadataDefault.apply( + color: color, + package: package, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + heightFactor: heightFactor, + heightDelta: heightDelta, + fontSizeFactor: fontSizeFactor, + fontSizeDelta: fontSizeDelta, + decoration: decoration, + ), + metadataEmphasis: metadataEmphasis.apply( + color: color, + package: package, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + heightFactor: heightFactor, + heightDelta: heightDelta, + fontSizeFactor: fontSizeFactor, + fontSizeDelta: fontSizeDelta, + decoration: decoration, + ), + metadataLink: metadataLink.apply( + color: color, + package: package, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + heightFactor: heightFactor, + heightDelta: heightDelta, + fontSizeFactor: fontSizeFactor, + fontSizeDelta: fontSizeDelta, + decoration: decoration, + ), + metadataLinkEmphasis: metadataLinkEmphasis.apply( + color: color, + package: package, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + heightFactor: heightFactor, + heightDelta: heightDelta, + fontSizeFactor: fontSizeFactor, + fontSizeDelta: fontSizeDelta, + decoration: decoration, + ), + numericLg: numericLg.apply( + color: color, + package: package, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + heightFactor: heightFactor, + heightDelta: heightDelta, + fontSizeFactor: fontSizeFactor, + fontSizeDelta: fontSizeDelta, + decoration: decoration, + ), + numericMd: numericMd.apply( + color: color, + package: package, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + heightFactor: heightFactor, + heightDelta: heightDelta, + fontSizeFactor: fontSizeFactor, + fontSizeDelta: fontSizeDelta, + decoration: decoration, + ), + numericSm: numericSm.apply( + color: color, + package: package, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + heightFactor: heightFactor, + heightDelta: heightDelta, + fontSizeFactor: fontSizeFactor, + fontSizeDelta: fontSizeDelta, + decoration: decoration, + ), + ); +} diff --git a/packages/stream_core_flutter/lib/src/theme/semantics/stream_text_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/semantics/stream_text_theme.g.theme.dart new file mode 100644 index 0000000..8bfb022 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/semantics/stream_text_theme.g.theme.dart @@ -0,0 +1,210 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'stream_text_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamTextTheme { + bool get canMerge => true; + + static StreamTextTheme? lerp( + StreamTextTheme? a, + StreamTextTheme? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamTextTheme.raw( + headingLg: TextStyle.lerp(a.headingLg, b.headingLg, t)!, + headingMd: TextStyle.lerp(a.headingMd, b.headingMd, t)!, + headingSm: TextStyle.lerp(a.headingSm, b.headingSm, t)!, + bodyDefault: TextStyle.lerp(a.bodyDefault, b.bodyDefault, t)!, + bodyEmphasis: TextStyle.lerp(a.bodyEmphasis, b.bodyEmphasis, t)!, + bodyLink: TextStyle.lerp(a.bodyLink, b.bodyLink, t)!, + bodyLinkEmphasis: TextStyle.lerp( + a.bodyLinkEmphasis, + b.bodyLinkEmphasis, + t, + )!, + captionDefault: TextStyle.lerp(a.captionDefault, b.captionDefault, t)!, + captionEmphasis: TextStyle.lerp(a.captionEmphasis, b.captionEmphasis, t)!, + captionLink: TextStyle.lerp(a.captionLink, b.captionLink, t)!, + captionLinkEmphasis: TextStyle.lerp( + a.captionLinkEmphasis, + b.captionLinkEmphasis, + t, + )!, + metadataDefault: TextStyle.lerp(a.metadataDefault, b.metadataDefault, t)!, + metadataEmphasis: TextStyle.lerp( + a.metadataEmphasis, + b.metadataEmphasis, + t, + )!, + metadataLink: TextStyle.lerp(a.metadataLink, b.metadataLink, t)!, + metadataLinkEmphasis: TextStyle.lerp( + a.metadataLinkEmphasis, + b.metadataLinkEmphasis, + t, + )!, + numericLg: TextStyle.lerp(a.numericLg, b.numericLg, t)!, + numericMd: TextStyle.lerp(a.numericMd, b.numericMd, t)!, + numericSm: TextStyle.lerp(a.numericSm, b.numericSm, t)!, + ); + } + + StreamTextTheme copyWith({ + TextStyle? headingLg, + TextStyle? headingMd, + TextStyle? headingSm, + TextStyle? bodyDefault, + TextStyle? bodyEmphasis, + TextStyle? bodyLink, + TextStyle? bodyLinkEmphasis, + TextStyle? captionDefault, + TextStyle? captionEmphasis, + TextStyle? captionLink, + TextStyle? captionLinkEmphasis, + TextStyle? metadataDefault, + TextStyle? metadataEmphasis, + TextStyle? metadataLink, + TextStyle? metadataLinkEmphasis, + TextStyle? numericLg, + TextStyle? numericMd, + TextStyle? numericSm, + }) { + final _this = (this as StreamTextTheme); + + return StreamTextTheme.raw( + headingLg: headingLg ?? _this.headingLg, + headingMd: headingMd ?? _this.headingMd, + headingSm: headingSm ?? _this.headingSm, + bodyDefault: bodyDefault ?? _this.bodyDefault, + bodyEmphasis: bodyEmphasis ?? _this.bodyEmphasis, + bodyLink: bodyLink ?? _this.bodyLink, + bodyLinkEmphasis: bodyLinkEmphasis ?? _this.bodyLinkEmphasis, + captionDefault: captionDefault ?? _this.captionDefault, + captionEmphasis: captionEmphasis ?? _this.captionEmphasis, + captionLink: captionLink ?? _this.captionLink, + captionLinkEmphasis: captionLinkEmphasis ?? _this.captionLinkEmphasis, + metadataDefault: metadataDefault ?? _this.metadataDefault, + metadataEmphasis: metadataEmphasis ?? _this.metadataEmphasis, + metadataLink: metadataLink ?? _this.metadataLink, + metadataLinkEmphasis: metadataLinkEmphasis ?? _this.metadataLinkEmphasis, + numericLg: numericLg ?? _this.numericLg, + numericMd: numericMd ?? _this.numericMd, + numericSm: numericSm ?? _this.numericSm, + ); + } + + StreamTextTheme merge(StreamTextTheme? other) { + final _this = (this as StreamTextTheme); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + headingLg: _this.headingLg.merge(other.headingLg), + headingMd: _this.headingMd.merge(other.headingMd), + headingSm: _this.headingSm.merge(other.headingSm), + bodyDefault: _this.bodyDefault.merge(other.bodyDefault), + bodyEmphasis: _this.bodyEmphasis.merge(other.bodyEmphasis), + bodyLink: _this.bodyLink.merge(other.bodyLink), + bodyLinkEmphasis: _this.bodyLinkEmphasis.merge(other.bodyLinkEmphasis), + captionDefault: _this.captionDefault.merge(other.captionDefault), + captionEmphasis: _this.captionEmphasis.merge(other.captionEmphasis), + captionLink: _this.captionLink.merge(other.captionLink), + captionLinkEmphasis: _this.captionLinkEmphasis.merge( + other.captionLinkEmphasis, + ), + metadataDefault: _this.metadataDefault.merge(other.metadataDefault), + metadataEmphasis: _this.metadataEmphasis.merge(other.metadataEmphasis), + metadataLink: _this.metadataLink.merge(other.metadataLink), + metadataLinkEmphasis: _this.metadataLinkEmphasis.merge( + other.metadataLinkEmphasis, + ), + numericLg: _this.numericLg.merge(other.numericLg), + numericMd: _this.numericMd.merge(other.numericMd), + numericSm: _this.numericSm.merge(other.numericSm), + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamTextTheme); + final _other = (other as StreamTextTheme); + + return _other.headingLg == _this.headingLg && + _other.headingMd == _this.headingMd && + _other.headingSm == _this.headingSm && + _other.bodyDefault == _this.bodyDefault && + _other.bodyEmphasis == _this.bodyEmphasis && + _other.bodyLink == _this.bodyLink && + _other.bodyLinkEmphasis == _this.bodyLinkEmphasis && + _other.captionDefault == _this.captionDefault && + _other.captionEmphasis == _this.captionEmphasis && + _other.captionLink == _this.captionLink && + _other.captionLinkEmphasis == _this.captionLinkEmphasis && + _other.metadataDefault == _this.metadataDefault && + _other.metadataEmphasis == _this.metadataEmphasis && + _other.metadataLink == _this.metadataLink && + _other.metadataLinkEmphasis == _this.metadataLinkEmphasis && + _other.numericLg == _this.numericLg && + _other.numericMd == _this.numericMd && + _other.numericSm == _this.numericSm; + } + + @override + int get hashCode { + final _this = (this as StreamTextTheme); + + return Object.hash( + runtimeType, + _this.headingLg, + _this.headingMd, + _this.headingSm, + _this.bodyDefault, + _this.bodyEmphasis, + _this.bodyLink, + _this.bodyLinkEmphasis, + _this.captionDefault, + _this.captionEmphasis, + _this.captionLink, + _this.captionLinkEmphasis, + _this.metadataDefault, + _this.metadataEmphasis, + _this.metadataLink, + _this.metadataLinkEmphasis, + _this.numericLg, + _this.numericMd, + _this.numericSm, + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart index fc53183..4638eb5 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart @@ -1,43 +1,206 @@ +// ignore_for_file: avoid_redundant_argument_values + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import '../../stream_core_flutter.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; -import 'stream_component_factory.dart'; +import 'components/stream_avatar_theme.dart'; +import 'components/stream_button_theme.dart'; +import 'components/stream_online_indicator_theme.dart'; +import 'primitives/stream_radius.dart'; +import 'primitives/stream_spacing.dart'; +import 'primitives/stream_typography.dart'; +import 'semantics/stream_box_shadow.dart'; +import 'semantics/stream_color_scheme.dart'; +import 'semantics/stream_text_theme.dart'; -class StreamTheme extends ThemeExtension { - StreamTheme({ - StreamComponentFactory? componentFactory, - this.primaryColor, - StreamButtonTheme? buttonTheme, - }) : componentFactory = componentFactory ?? StreamComponentFactory(), - buttonTheme = buttonTheme ?? StreamButtonTheme(); +part 'stream_theme.g.theme.dart'; - final StreamComponentFactory componentFactory; +/// The main theme configuration for Stream design system. +/// +/// [StreamTheme] aggregates all design tokens and component themes into a +/// single theme object. It supports light, dark, and high contrast modes +/// with platform-aware defaults. +/// +/// {@tool snippet} +/// +/// Create a light theme: +/// +/// ```dart +/// final theme = StreamTheme.light(); +/// final primaryColor = theme.colorScheme.brand.shade500; +/// final spacing = theme.spacing.md; +/// ``` +/// {@end-tool} +/// {@tool snippet} +/// +/// Create a theme based on brightness: +/// +/// ```dart +/// final theme = StreamTheme(brightness: Brightness.dark); +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamColorScheme], which defines semantic colors. +/// * [StreamTypography], which defines typography primitives. +/// * [StreamTextTheme], which defines semantic text styles. +/// * [StreamRadius], which defines border radius values. +/// * [StreamSpacing], which defines spacing values. +/// * [StreamBoxShadow], which defines elevation shadows. +/// * [StreamButtonThemeData], which defines button styles. +/// * [StreamAvatarThemeData], which defines avatar styles. +@immutable +@ThemeExtensions(constructor: 'raw', buildContextExtension: false) +class StreamTheme extends ThemeExtension with _$StreamTheme { + /// Creates a theme configuration. + /// + /// The theme is configured based on the given [brightness]. If [brightness] + /// is not provided, it defaults to [Brightness.light]. + /// + /// The [platform] parameter affects platform-specific defaults for + /// typography and radius. If not provided, it defaults to + /// [defaultTargetPlatform]. + /// + /// If [typography] is provided, it will be used to build [textTheme]. + /// + /// See also: + /// + /// * [StreamTheme.light], which creates a light theme. + /// * [StreamTheme.dark], which creates a dark theme. + factory StreamTheme({ + Brightness brightness = .light, + TargetPlatform? platform, + StreamRadius? radius, + StreamSpacing? spacing, + StreamTypography? typography, + StreamColorScheme? colorScheme, + StreamTextTheme? textTheme, + StreamBoxShadow? boxShadow, + // Components themes + StreamButtonThemeData? buttonTheme, + StreamAvatarThemeData? avatarTheme, + StreamOnlineIndicatorThemeData? onlineIndicatorTheme, + }) { + platform ??= defaultTargetPlatform; + final isDark = brightness == Brightness.dark; - final Color? primaryColor; + // Primitives + radius ??= StreamRadius(platform: platform); + spacing ??= const StreamSpacing(); + typography ??= StreamTypography(platform: platform); - final StreamButtonTheme buttonTheme; + // Semantics + colorScheme ??= isDark ? StreamColorScheme.dark() : StreamColorScheme.light(); + textTheme ??= StreamTextTheme(typography: typography).apply(color: colorScheme.systemText); + boxShadow ??= isDark ? StreamBoxShadow.dark() : StreamBoxShadow.light(); - static StreamTheme of(BuildContext context) { - return Theme.of(context).extension() ?? StreamTheme(); - } + // Components + buttonTheme ??= const StreamButtonThemeData(); + avatarTheme ??= const StreamAvatarThemeData(); + onlineIndicatorTheme ??= const StreamOnlineIndicatorThemeData(); - @override - StreamTheme copyWith({Color? primaryColor, StreamButtonTheme? buttonTheme}) { - return StreamTheme( - componentFactory: componentFactory, - primaryColor: primaryColor ?? this.primaryColor, - buttonTheme: buttonTheme ?? this.buttonTheme, + return .raw( + brightness: brightness, + radius: radius, + spacing: spacing, + typography: typography, + colorScheme: colorScheme, + textTheme: textTheme, + boxShadow: boxShadow, + buttonTheme: buttonTheme, + avatarTheme: avatarTheme, + onlineIndicatorTheme: onlineIndicatorTheme, ); } - @override - ThemeExtension lerp( - covariant ThemeExtension? other, - double t, - ) { - if (other is! StreamTheme) { - return this; - } - return StreamTheme(); + /// Creates a dark theme configuration. + /// + /// This is a convenience factory that calls [StreamTheme] with + /// [Brightness.dark]. + factory StreamTheme.dark() => StreamTheme(brightness: .dark); + + /// Creates a light theme configuration. + /// + /// This is a convenience factory that calls [StreamTheme] with + /// [Brightness.light]. + factory StreamTheme.light() => StreamTheme(brightness: .light); + + const StreamTheme.raw({ + required this.brightness, + required this.radius, + required this.spacing, + required this.typography, + required this.colorScheme, + required this.textTheme, + required this.boxShadow, + required this.buttonTheme, + required this.avatarTheme, + required this.onlineIndicatorTheme, + }); + + /// Returns the [StreamTheme] from the closest [Theme] ancestor. + /// + /// If no [StreamTheme] is found in the widget tree, a default theme is + /// returned based on the current [Theme]'s brightness (light or dark). + /// + /// {@tool snippet} + /// + /// Access the theme in a widget: + /// + /// ```dart + /// @override + /// Widget build(BuildContext context) { + /// final theme = StreamTheme.of(context); + /// return Container( + /// color: theme.colorScheme.backgroundPrimary, + /// padding: EdgeInsets.all(theme.spacing.md), + /// child: Text('Hello', style: theme.textTheme.bodyDefault), + /// ); + /// } + /// ``` + /// {@end-tool} + static StreamTheme of(BuildContext context) { + final theme = Theme.of(context); + final streamTheme = theme.extension(); + if (streamTheme != null) return streamTheme; + + return StreamTheme(brightness: theme.brightness); } + + /// The brightness of this theme. + final Brightness brightness; + + /// The border radius values for this theme. + final StreamRadius radius; + + /// The spacing values for this theme. + final StreamSpacing spacing; + + /// The typography primitives for this theme. + /// + /// Contains font sizes, line heights, and font weights. + /// Used to build [textTheme]. + final StreamTypography typography; + + /// The color scheme for this theme. + final StreamColorScheme colorScheme; + + /// The semantic text theme for this theme. + /// + /// Built from [typography] primitives. + final StreamTextTheme textTheme; + + /// The box shadow (elevation) values for this theme. + final StreamBoxShadow boxShadow; + + /// The button theme for this theme. + final StreamButtonThemeData buttonTheme; + + /// The avatar theme for this theme. + final StreamAvatarThemeData avatarTheme; + + /// The online indicator theme for this theme. + final StreamOnlineIndicatorThemeData onlineIndicatorTheme; } diff --git a/packages/stream_core_flutter/lib/src/theme/stream_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/stream_theme.g.theme.dart new file mode 100644 index 0000000..bea7b95 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme.g.theme.dart @@ -0,0 +1,123 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'stream_theme.dart'; + +// ************************************************************************** +// ThemeExtensionsGenerator +// ************************************************************************** + +mixin _$StreamTheme on ThemeExtension { + @override + ThemeExtension copyWith({ + Brightness? brightness, + StreamRadius? radius, + StreamSpacing? spacing, + StreamTypography? typography, + StreamColorScheme? colorScheme, + StreamTextTheme? textTheme, + StreamBoxShadow? boxShadow, + StreamButtonThemeData? buttonTheme, + StreamAvatarThemeData? avatarTheme, + StreamOnlineIndicatorThemeData? onlineIndicatorTheme, + }) { + final _this = (this as StreamTheme); + + return StreamTheme.raw( + brightness: brightness ?? _this.brightness, + radius: radius ?? _this.radius, + spacing: spacing ?? _this.spacing, + typography: typography ?? _this.typography, + colorScheme: colorScheme ?? _this.colorScheme, + textTheme: textTheme ?? _this.textTheme, + boxShadow: boxShadow ?? _this.boxShadow, + buttonTheme: buttonTheme ?? _this.buttonTheme, + avatarTheme: avatarTheme ?? _this.avatarTheme, + onlineIndicatorTheme: onlineIndicatorTheme ?? _this.onlineIndicatorTheme, + ); + } + + @override + ThemeExtension lerp( + ThemeExtension? other, + double t, + ) { + if (other is! StreamTheme) { + return this; + } + + final _this = (this as StreamTheme); + + return StreamTheme.raw( + brightness: t < 0.5 ? _this.brightness : other.brightness, + radius: StreamRadius.lerp(_this.radius, other.radius, t)!, + spacing: StreamSpacing.lerp(_this.spacing, other.spacing, t)!, + typography: StreamTypography.lerp(_this.typography, other.typography, t)!, + colorScheme: + (_this.colorScheme.lerp(other.colorScheme, t) as StreamColorScheme), + textTheme: (_this.textTheme.lerp(other.textTheme, t) as StreamTextTheme), + boxShadow: StreamBoxShadow.lerp(_this.boxShadow, other.boxShadow, t)!, + buttonTheme: StreamButtonThemeData.lerp( + _this.buttonTheme, + other.buttonTheme, + t, + )!, + avatarTheme: StreamAvatarThemeData.lerp( + _this.avatarTheme, + other.avatarTheme, + t, + )!, + onlineIndicatorTheme: StreamOnlineIndicatorThemeData.lerp( + _this.onlineIndicatorTheme, + other.onlineIndicatorTheme, + t, + )!, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamTheme); + final _other = (other as StreamTheme); + + return _other.brightness == _this.brightness && + _other.radius == _this.radius && + _other.spacing == _this.spacing && + _other.typography == _this.typography && + _other.colorScheme == _this.colorScheme && + _other.textTheme == _this.textTheme && + _other.boxShadow == _this.boxShadow && + _other.buttonTheme == _this.buttonTheme && + _other.avatarTheme == _this.avatarTheme && + _other.onlineIndicatorTheme == _this.onlineIndicatorTheme; + } + + @override + int get hashCode { + final _this = (this as StreamTheme); + + return Object.hash( + runtimeType, + _this.brightness, + _this.radius, + _this.spacing, + _this.typography, + _this.colorScheme, + _this.textTheme, + _this.boxShadow, + _this.buttonTheme, + _this.avatarTheme, + _this.onlineIndicatorTheme, + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/stream_theme_extensions.dart b/packages/stream_core_flutter/lib/src/theme/stream_theme_extensions.dart new file mode 100644 index 0000000..3c7d04b --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme_extensions.dart @@ -0,0 +1,64 @@ +import 'package:flutter/widgets.dart'; + +import 'components/stream_avatar_theme.dart'; +import 'components/stream_button_theme.dart'; +import 'components/stream_online_indicator_theme.dart'; +import 'primitives/stream_radius.dart'; +import 'primitives/stream_spacing.dart'; +import 'primitives/stream_typography.dart'; +import 'semantics/stream_box_shadow.dart'; +import 'semantics/stream_color_scheme.dart'; +import 'semantics/stream_text_theme.dart'; +import 'stream_theme.dart'; + +/// Extension on [BuildContext] for convenient access to [StreamTheme]. +/// +/// {@tool snippet} +/// +/// Access theme properties directly from context: +/// +/// ```dart +/// @override +/// Widget build(BuildContext context) { +/// return Container( +/// color: context.streamColorScheme.backgroundPrimary, +/// padding: EdgeInsets.all(context.streamSpacing.md), +/// child: Text('Hello', style: context.streamTextTheme.bodyDefault), +/// ); +/// } +/// ``` +/// {@end-tool} +extension StreamThemeExtension on BuildContext { + /// Returns the [StreamTheme] from the closest ancestor. + /// + /// If no [StreamTheme] is found, returns a default theme based on + /// the current [Theme]'s brightness. + StreamTheme get streamTheme => StreamTheme.of(this); + + /// Returns the [StreamColorScheme] from the current theme. + StreamColorScheme get streamColorScheme => streamTheme.colorScheme; + + /// Returns the [StreamTextTheme] from the current theme. + StreamTextTheme get streamTextTheme => streamTheme.textTheme; + + /// Returns the [StreamTypography] from the current theme. + StreamTypography get streamTypography => streamTheme.typography; + + /// Returns the [StreamRadius] from the current theme. + StreamRadius get streamRadius => streamTheme.radius; + + /// Returns the [StreamSpacing] from the current theme. + StreamSpacing get streamSpacing => streamTheme.spacing; + + /// Returns the [StreamBoxShadow] from the current theme. + StreamBoxShadow get streamBoxShadow => streamTheme.boxShadow; + + /// Returns the [StreamButtonThemeData] from the nearest ancestor. + StreamButtonThemeData get streamButtonTheme => StreamButtonTheme.of(this); + + /// Returns the [StreamAvatarThemeData] from the nearest ancestor. + StreamAvatarThemeData get streamAvatarTheme => StreamAvatarTheme.of(this); + + /// Returns the [StreamOnlineIndicatorThemeData] from the nearest ancestor. + StreamOnlineIndicatorThemeData get streamOnlineIndicatorTheme => StreamOnlineIndicatorTheme.of(this); +} diff --git a/packages/stream_core_flutter/lib/stream_core_flutter.dart b/packages/stream_core_flutter/lib/stream_core_flutter.dart index 274a80a..49b2112 100644 --- a/packages/stream_core_flutter/lib/stream_core_flutter.dart +++ b/packages/stream_core_flutter/lib/stream_core_flutter.dart @@ -1,3 +1,2 @@ export 'src/components.dart'; -export 'src/theme/components.dart'; -export 'src/theme/stream_theme.dart'; +export 'src/theme.dart'; diff --git a/packages/stream_core_flutter/pubspec.yaml b/packages/stream_core_flutter/pubspec.yaml index ded9734..fda1e3c 100644 --- a/packages/stream_core_flutter/pubspec.yaml +++ b/packages/stream_core_flutter/pubspec.yaml @@ -8,46 +8,14 @@ environment: flutter: ">=3.38.1" dependencies: + cached_network_image: ^3.4.1 flutter: sdk: flutter + stream_core: ^0.4.0 + theme_extensions_builder_annotation: ^7.1.0 dev_dependencies: + build_runner: ^2.10.5 flutter_test: sdk: flutter - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. -flutter: - - # To add assets to your package, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - # - # For details regarding assets in packages, see - # https://flutter.dev/to/asset-from-package - # - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/to/resolution-aware-images - - # To add custom fonts to your package, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts in packages, see - # https://flutter.dev/to/font-from-package + theme_extensions_builder: ^7.1.0 From 455a8a108a7cbc494cea3c13f7e308586e98403e Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 21 Jan 2026 16:08:04 +0100 Subject: [PATCH 2/5] chore: format code --- .../src/api/interceptors/logging_interceptor.dart | 3 +-- .../stream_core/lib/src/logger/impl/file_logger.dart | 6 ++---- .../stream_core/lib/src/query/filter/filter.dart | 12 ++++-------- .../query/filter/location/location_coordinate.dart | 3 +-- .../stream_core/lib/src/utils/event_emitter.dart | 3 +-- .../stream_core/lib/src/utils/state_emitter.dart | 3 +-- .../src/factory/components/stream_button_theme.dart | 1 - 7 files changed, 10 insertions(+), 21 deletions(-) diff --git a/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart index 0c507a0..f74b784 100644 --- a/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart @@ -124,8 +124,7 @@ class LoggingInterceptor extends Interceptor { final uri = err.response?.requestOptions.uri; _printBoxed( _logPrintError, - header: - 'DioException ║ Status: ${err.response?.statusCode} ${err.response?.statusMessage}', + header: 'DioException ║ Status: ${err.response?.statusCode} ${err.response?.statusMessage}', text: uri.toString(), ); if (err.response != null && err.response?.data != null) { diff --git a/packages/stream_core/lib/src/logger/impl/file_logger.dart b/packages/stream_core/lib/src/logger/impl/file_logger.dart index 27f66db..880142e 100644 --- a/packages/stream_core/lib/src/logger/impl/file_logger.dart +++ b/packages/stream_core/lib/src/logger/impl/file_logger.dart @@ -65,10 +65,8 @@ class FileStreamLogger extends StreamLogger { _logD(() => '[initIfNeeded] no args'); _filesDir = await config.filesDir; _tempsDir = await config.tempsDir; - _file0 = File('${_filesDir.path}$pathSeparator$_internalFile0') - ..createSync(recursive: true); - _file1 = File('${_filesDir.path}$pathSeparator$_internalFile1') - ..createSync(recursive: true); + _file0 = File('${_filesDir.path}$pathSeparator$_internalFile0')..createSync(recursive: true); + _file1 = File('${_filesDir.path}$pathSeparator$_internalFile1')..createSync(recursive: true); final File currentFile; if (!_file0.existsSync() || !_file1.existsSync()) { currentFile = _file0; diff --git a/packages/stream_core/lib/src/query/filter/filter.dart b/packages/stream_core/lib/src/query/filter/filter.dart index fd9bef3..2c6ee41 100644 --- a/packages/stream_core/lib/src/query/filter/filter.dart +++ b/packages/stream_core/lib/src/query/filter/filter.dart @@ -295,8 +295,7 @@ final class GreaterOperator extends ComparisonOperator { /// Primarily used with numeric values and dates. final class GreaterOrEqualOperator extends ComparisonOperator { /// Creates a greater-than-or-equal filter for the specified [field] and [value]. - const GreaterOrEqualOperator(super.field, super.value) - : super._(operator: FilterOperator.greaterOrEqual); + const GreaterOrEqualOperator(super.field, super.value) : super._(operator: FilterOperator.greaterOrEqual); @override bool matches(T other) { @@ -338,8 +337,7 @@ final class LessOperator extends ComparisonOperator { /// Primarily used with numeric values and dates. final class LessOrEqualOperator extends ComparisonOperator { /// Creates a less-than-or-equal filter for the specified [field] and [value]. - const LessOrEqualOperator(super.field, super.value) - : super._(operator: FilterOperator.lessOrEqual); + const LessOrEqualOperator(super.field, super.value) : super._(operator: FilterOperator.lessOrEqual); @override bool matches(T other) { @@ -394,8 +392,7 @@ sealed class ListOperator extends Filter { /// **Supported with**: `.in_` factory method final class InOperator extends ListOperator { /// Creates an 'in' filter for the specified [field] and [values] iterable. - const InOperator(super.field, Iterable super.values) - : super._(operator: FilterOperator.in_); + const InOperator(super.field, Iterable super.values) : super._(operator: FilterOperator.in_); @override bool matches(T other) { @@ -526,8 +523,7 @@ final class QueryOperator extends EvaluationOperator { /// Matches field values where any word starts with the provided prefix. final class AutoCompleteOperator extends EvaluationOperator { /// Creates an autocomplete filter for the specified [field] and prefix [query]. - const AutoCompleteOperator(super.field, super.query) - : super._(operator: FilterOperator.autoComplete); + const AutoCompleteOperator(super.field, super.query) : super._(operator: FilterOperator.autoComplete); @override bool matches(T other) { diff --git a/packages/stream_core/lib/src/query/filter/location/location_coordinate.dart b/packages/stream_core/lib/src/query/filter/location/location_coordinate.dart index cb67d04..fd46f16 100644 --- a/packages/stream_core/lib/src/query/filter/location/location_coordinate.dart +++ b/packages/stream_core/lib/src/query/filter/location/location_coordinate.dart @@ -79,8 +79,7 @@ class LocationCoordinate { final latComponent = _square(sinDLat); // Calculate longitude component: sin²(Δlon/2) * cos(lat1) * cos(lat2) - final lonComponent = - _square(sinDLon) * math.cos(_degToRad(latitude)) * math.cos(_degToRad(other.latitude)); + final lonComponent = _square(sinDLon) * math.cos(_degToRad(latitude)) * math.cos(_degToRad(other.latitude)); // Combine components final a = latComponent + lonComponent; diff --git a/packages/stream_core/lib/src/utils/event_emitter.dart b/packages/stream_core/lib/src/utils/event_emitter.dart index 11d20b7..2e1e0e0 100644 --- a/packages/stream_core/lib/src/utils/event_emitter.dart +++ b/packages/stream_core/lib/src/utils/event_emitter.dart @@ -57,8 +57,7 @@ typedef EventEmitter = SharedEmitter; /// See also: /// - [EventEmitter] for the read-only interface. /// - [EventResolver] for the resolver function signature. -final class MutableEventEmitter extends SharedEmitterImpl - implements MutableSharedEmitter { +final class MutableEventEmitter extends SharedEmitterImpl implements MutableSharedEmitter { /// Creates a [MutableEventEmitter] with optional event [resolvers]. /// /// Resolvers are applied in order to each emitted event until one returns diff --git a/packages/stream_core/lib/src/utils/state_emitter.dart b/packages/stream_core/lib/src/utils/state_emitter.dart index 8494142..3d570a3 100644 --- a/packages/stream_core/lib/src/utils/state_emitter.dart +++ b/packages/stream_core/lib/src/utils/state_emitter.dart @@ -37,8 +37,7 @@ abstract interface class StateEmitter implements SharedEmitter { /// See also: /// - [StateEmitter] for the read-only interface. /// - [MutableStateEmitterExtension] for convenience methods. -abstract interface class MutableStateEmitter extends StateEmitter - implements MutableSharedEmitter { +abstract interface class MutableStateEmitter extends StateEmitter implements MutableSharedEmitter { /// Creates a [MutableStateEmitter] with the given [initialValue]. /// /// Supports synchronous or asynchronous state emission via [sync]. diff --git a/packages/stream_core_flutter/lib/src/factory/components/stream_button_theme.dart b/packages/stream_core_flutter/lib/src/factory/components/stream_button_theme.dart index 17d1053..9455795 100644 --- a/packages/stream_core_flutter/lib/src/factory/components/stream_button_theme.dart +++ b/packages/stream_core_flutter/lib/src/factory/components/stream_button_theme.dart @@ -1,4 +1,3 @@ - import 'package:flutter/widgets.dart'; import '../stream_theme.dart'; From 6a80a706780300b0d2d314b47e7747fe01e2f905 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 21 Jan 2026 16:20:28 +0100 Subject: [PATCH 3/5] refactor: Remove StreamButtonTheme The `StreamButtonTheme` and its related data classes have been removed from the core theme structure. This simplifies the theme by removing the button-specific theming. Additionally, the `lerp` function for `StreamBrandColor` has been adjusted, and `StreamAvatarColorPair` is now generated using `themeGen`. --- .../theme/semantics/stream_color_scheme.dart | 18 +--- .../stream_color_scheme.g.theme.dart | 86 ++++++++++++++++++- .../lib/src/theme/stream_theme.dart | 8 -- .../lib/src/theme/stream_theme.g.theme.dart | 9 -- .../src/theme/stream_theme_extensions.dart | 4 - 5 files changed, 89 insertions(+), 36 deletions(-) diff --git a/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart b/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart index e1dc645..537e364 100644 --- a/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart +++ b/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart @@ -1,5 +1,4 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/painting.dart'; +import 'package:flutter/material.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; import '../../theme/primitives/stream_colors.dart'; @@ -654,17 +653,15 @@ class StreamBrandColor extends StreamColorSwatch { }, ); } - - /// Linearly interpolates between two colors. - static StreamBrandColor? lerp(Color? x, Color? y, double t) => Color.lerp(x, y, t) as StreamBrandColor; } /// A background/foreground color pair for avatars. /// /// Used for deterministic color selection based on user identity. /// The palette is part of [StreamColorScheme] to support light/dark themes. +@themeGen @immutable -class StreamAvatarColorPair { +class StreamAvatarColorPair with _$StreamAvatarColorPair { /// Creates an avatar color pair with the given values. const StreamAvatarColorPair({ required this.backgroundColor, @@ -682,12 +679,5 @@ class StreamAvatarColorPair { StreamAvatarColorPair? a, StreamAvatarColorPair? b, double t, - ) { - if (identical(a, b)) return a; - if (a == null && b == null) return null; - return StreamAvatarColorPair( - backgroundColor: Color.lerp(a?.backgroundColor, b?.backgroundColor, t)!, - foregroundColor: Color.lerp(a?.foregroundColor, b?.foregroundColor, t)!, - ); - } + ) => _$StreamAvatarColorPair.lerp(a, b, t); } diff --git a/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.g.theme.dart index 7871433..6193d24 100644 --- a/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.g.theme.dart @@ -30,7 +30,7 @@ mixin _$StreamColorScheme { } return StreamColorScheme.raw( - brand: StreamBrandColor.lerp(a.brand, b.brand, t)!, + brand: t < 0.5 ? a.brand : b.brand, accentPrimary: Color.lerp(a.accentPrimary, b.accentPrimary, t)!, accentSuccess: Color.lerp(a.accentSuccess, b.accentSuccess, t)!, accentWarning: Color.lerp(a.accentWarning, b.accentWarning, t)!, @@ -340,3 +340,87 @@ mixin _$StreamColorScheme { ]); } } + +mixin _$StreamAvatarColorPair { + bool get canMerge => true; + + static StreamAvatarColorPair? lerp( + StreamAvatarColorPair? a, + StreamAvatarColorPair? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamAvatarColorPair( + backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t)!, + foregroundColor: Color.lerp(a.foregroundColor, b.foregroundColor, t)!, + ); + } + + StreamAvatarColorPair copyWith({ + Color? backgroundColor, + Color? foregroundColor, + }) { + final _this = (this as StreamAvatarColorPair); + + return StreamAvatarColorPair( + backgroundColor: backgroundColor ?? _this.backgroundColor, + foregroundColor: foregroundColor ?? _this.foregroundColor, + ); + } + + StreamAvatarColorPair merge(StreamAvatarColorPair? other) { + final _this = (this as StreamAvatarColorPair); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + backgroundColor: other.backgroundColor, + foregroundColor: other.foregroundColor, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamAvatarColorPair); + final _other = (other as StreamAvatarColorPair); + + return _other.backgroundColor == _this.backgroundColor && + _other.foregroundColor == _this.foregroundColor; + } + + @override + int get hashCode { + final _this = (this as StreamAvatarColorPair); + + return Object.hash( + runtimeType, + _this.backgroundColor, + _this.foregroundColor, + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart index 4638eb5..581d0d4 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; import 'components/stream_avatar_theme.dart'; -import 'components/stream_button_theme.dart'; import 'components/stream_online_indicator_theme.dart'; import 'primitives/stream_radius.dart'; import 'primitives/stream_spacing.dart'; @@ -79,7 +78,6 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { StreamTextTheme? textTheme, StreamBoxShadow? boxShadow, // Components themes - StreamButtonThemeData? buttonTheme, StreamAvatarThemeData? avatarTheme, StreamOnlineIndicatorThemeData? onlineIndicatorTheme, }) { @@ -97,7 +95,6 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { boxShadow ??= isDark ? StreamBoxShadow.dark() : StreamBoxShadow.light(); // Components - buttonTheme ??= const StreamButtonThemeData(); avatarTheme ??= const StreamAvatarThemeData(); onlineIndicatorTheme ??= const StreamOnlineIndicatorThemeData(); @@ -109,7 +106,6 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { colorScheme: colorScheme, textTheme: textTheme, boxShadow: boxShadow, - buttonTheme: buttonTheme, avatarTheme: avatarTheme, onlineIndicatorTheme: onlineIndicatorTheme, ); @@ -135,7 +131,6 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { required this.colorScheme, required this.textTheme, required this.boxShadow, - required this.buttonTheme, required this.avatarTheme, required this.onlineIndicatorTheme, }); @@ -195,9 +190,6 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { /// The box shadow (elevation) values for this theme. final StreamBoxShadow boxShadow; - /// The button theme for this theme. - final StreamButtonThemeData buttonTheme; - /// The avatar theme for this theme. final StreamAvatarThemeData avatarTheme; diff --git a/packages/stream_core_flutter/lib/src/theme/stream_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/stream_theme.g.theme.dart index bea7b95..59918ca 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme.g.theme.dart @@ -19,7 +19,6 @@ mixin _$StreamTheme on ThemeExtension { StreamColorScheme? colorScheme, StreamTextTheme? textTheme, StreamBoxShadow? boxShadow, - StreamButtonThemeData? buttonTheme, StreamAvatarThemeData? avatarTheme, StreamOnlineIndicatorThemeData? onlineIndicatorTheme, }) { @@ -33,7 +32,6 @@ mixin _$StreamTheme on ThemeExtension { colorScheme: colorScheme ?? _this.colorScheme, textTheme: textTheme ?? _this.textTheme, boxShadow: boxShadow ?? _this.boxShadow, - buttonTheme: buttonTheme ?? _this.buttonTheme, avatarTheme: avatarTheme ?? _this.avatarTheme, onlineIndicatorTheme: onlineIndicatorTheme ?? _this.onlineIndicatorTheme, ); @@ -59,11 +57,6 @@ mixin _$StreamTheme on ThemeExtension { (_this.colorScheme.lerp(other.colorScheme, t) as StreamColorScheme), textTheme: (_this.textTheme.lerp(other.textTheme, t) as StreamTextTheme), boxShadow: StreamBoxShadow.lerp(_this.boxShadow, other.boxShadow, t)!, - buttonTheme: StreamButtonThemeData.lerp( - _this.buttonTheme, - other.buttonTheme, - t, - )!, avatarTheme: StreamAvatarThemeData.lerp( _this.avatarTheme, other.avatarTheme, @@ -97,7 +90,6 @@ mixin _$StreamTheme on ThemeExtension { _other.colorScheme == _this.colorScheme && _other.textTheme == _this.textTheme && _other.boxShadow == _this.boxShadow && - _other.buttonTheme == _this.buttonTheme && _other.avatarTheme == _this.avatarTheme && _other.onlineIndicatorTheme == _this.onlineIndicatorTheme; } @@ -115,7 +107,6 @@ mixin _$StreamTheme on ThemeExtension { _this.colorScheme, _this.textTheme, _this.boxShadow, - _this.buttonTheme, _this.avatarTheme, _this.onlineIndicatorTheme, ); diff --git a/packages/stream_core_flutter/lib/src/theme/stream_theme_extensions.dart b/packages/stream_core_flutter/lib/src/theme/stream_theme_extensions.dart index 3c7d04b..ce24083 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme_extensions.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme_extensions.dart @@ -1,7 +1,6 @@ import 'package:flutter/widgets.dart'; import 'components/stream_avatar_theme.dart'; -import 'components/stream_button_theme.dart'; import 'components/stream_online_indicator_theme.dart'; import 'primitives/stream_radius.dart'; import 'primitives/stream_spacing.dart'; @@ -53,9 +52,6 @@ extension StreamThemeExtension on BuildContext { /// Returns the [StreamBoxShadow] from the current theme. StreamBoxShadow get streamBoxShadow => streamTheme.boxShadow; - /// Returns the [StreamButtonThemeData] from the nearest ancestor. - StreamButtonThemeData get streamButtonTheme => StreamButtonTheme.of(this); - /// Returns the [StreamAvatarThemeData] from the nearest ancestor. StreamAvatarThemeData get streamAvatarTheme => StreamAvatarTheme.of(this); From 3295b180bc5d68c011f6dbb1e9fe695078800fd0 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 21 Jan 2026 16:24:01 +0100 Subject: [PATCH 4/5] refactor: Remove StreamButtonTheme The `StreamButtonTheme` and its related data classes have been removed from the core theme structure. This simplifies the theme by removing the button-specific theming. Additionally, the `lerp` function for `StreamBrandColor` has been adjusted, and `StreamAvatarColorPair` is now generated using `themeGen`. --- .../src/theme/components/stream_button_theme.dart | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_button_theme.dart diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_button_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_button_theme.dart deleted file mode 100644 index 12e8337..0000000 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_button_theme.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:flutter/widgets.dart'; -import '../../../stream_core_flutter.dart'; - -class StreamButtonTheme { - StreamButtonTheme({this.primaryColor}); - - final WidgetStateProperty? primaryColor; - - static StreamButtonTheme of(BuildContext context) { - return StreamTheme.of(context).buttonTheme; - } -} From ca798343959445fbd0c2f9606a3eb18b986e8aab Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 21 Jan 2026 16:30:28 +0100 Subject: [PATCH 5/5] chore: update code to use Dart 3 shorthand syntax --- .../lib/src/components/avatar/stream_avatar_stack.dart | 2 +- .../src/components/indicator/stream_online_indicator.dart | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_stack.dart b/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_stack.dart index 4348965..9394164 100644 --- a/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_stack.dart +++ b/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_stack.dart @@ -167,7 +167,7 @@ class StreamAvatarStack extends StatelessWidget { width: totalWidth, height: diameter, child: Stack( - alignment: AlignmentDirectional.center, + alignment: .center, children: [ for (var i = 0; i < displayChildren.length; i++) Positioned( diff --git a/packages/stream_core_flutter/lib/src/components/indicator/stream_online_indicator.dart b/packages/stream_core_flutter/lib/src/components/indicator/stream_online_indicator.dart index cb1a29b..b75d14f 100644 --- a/packages/stream_core_flutter/lib/src/components/indicator/stream_online_indicator.dart +++ b/packages/stream_core_flutter/lib/src/components/indicator/stream_online_indicator.dart @@ -114,8 +114,8 @@ class StreamOnlineIndicator extends StatelessWidget { width: effectiveSize.value, height: effectiveSize.value, duration: kThemeChangeDuration, - decoration: BoxDecoration(shape: BoxShape.circle, color: color), - foregroundDecoration: BoxDecoration(shape: BoxShape.circle, border: border), + decoration: BoxDecoration(shape: .circle, color: color), + foregroundDecoration: BoxDecoration(shape: .circle, border: border), ); } @@ -131,7 +131,9 @@ class StreamOnlineIndicator extends StatelessWidget { // Provides default values for [StreamOnlineIndicatorThemeData] based on // the current [StreamColorScheme]. class _StreamOnlineIndicatorThemeDefaults extends StreamOnlineIndicatorThemeData { - _StreamOnlineIndicatorThemeDefaults(this.context) : _colorScheme = context.streamColorScheme; + _StreamOnlineIndicatorThemeDefaults( + this.context, + ) : _colorScheme = context.streamColorScheme; final BuildContext context; final StreamColorScheme _colorScheme;