feat: implement dynamic theme palette selection

- Introduced a new feature allowing users to select from multiple accent color palettes for buttons, cards, and chat bubbles.
- Added `AppThemePalette` provider to manage the current theme palette and persist user selections.
- Updated the `AppTheme` class to utilize the selected palette for light and dark themes, enhancing visual customization.
- Enhanced the `AppCustomizationPage` to include a palette selector, improving user experience and personalization options.
- Updated localization files to support new palette selection UI elements in multiple languages.
This commit is contained in:
cogwheel0
2025-10-02 01:58:12 +05:30
parent 5eb23b01de
commit 1fa8412e0a
23 changed files with 1011 additions and 577 deletions

View File

@@ -18,6 +18,7 @@ final class PreferenceKeys {
static const String rememberCredentials = 'remember_credentials'; static const String rememberCredentials = 'remember_credentials';
static const String activeServerId = 'active_server_id'; static const String activeServerId = 'active_server_id';
static const String themeMode = 'theme_mode'; static const String themeMode = 'theme_mode';
static const String themePalette = 'theme_palette_v1';
static const String localeCode = 'locale_code_v1'; static const String localeCode = 'locale_code_v1';
static const String onboardingSeen = 'onboarding_seen_v1'; static const String onboardingSeen = 'onboarding_seen_v1';
static const String reviewerMode = 'reviewer_mode_v1'; static const String reviewerMode = 'reviewer_mode_v1';

View File

@@ -89,6 +89,7 @@ class PersistenceMigrator {
copyBool(PreferenceKeys.rememberCredentials); copyBool(PreferenceKeys.rememberCredentials);
copyString(PreferenceKeys.activeServerId); copyString(PreferenceKeys.activeServerId);
copyString(PreferenceKeys.themeMode); copyString(PreferenceKeys.themeMode);
copyString(PreferenceKeys.themePalette);
copyString(PreferenceKeys.localeCode); copyString(PreferenceKeys.localeCode);
copyBool(PreferenceKeys.onboardingSeen); copyBool(PreferenceKeys.onboardingSeen);
copyBool(PreferenceKeys.reviewerMode); copyBool(PreferenceKeys.reviewerMode);
@@ -194,6 +195,7 @@ class PersistenceMigrator {
PreferenceKeys.rememberCredentials, PreferenceKeys.rememberCredentials,
PreferenceKeys.activeServerId, PreferenceKeys.activeServerId,
PreferenceKeys.themeMode, PreferenceKeys.themeMode,
PreferenceKeys.themePalette,
PreferenceKeys.localeCode, PreferenceKeys.localeCode,
PreferenceKeys.onboardingSeen, PreferenceKeys.onboardingSeen,
PreferenceKeys.reviewerMode, PreferenceKeys.reviewerMode,

View File

@@ -23,6 +23,8 @@ import '../services/optimized_storage_service.dart';
import '../services/socket_service.dart'; import '../services/socket_service.dart';
import '../utils/debug_logger.dart'; import '../utils/debug_logger.dart';
import '../models/socket_event.dart'; import '../models/socket_event.dart';
import '../../shared/theme/color_palettes.dart';
import '../../shared/theme/app_theme.dart';
part 'app_providers.g.dart'; part 'app_providers.g.dart';
@@ -78,6 +80,42 @@ class AppThemeMode extends _$AppThemeMode {
} }
} }
@Riverpod(keepAlive: true)
class AppThemePalette extends _$AppThemePalette {
late final OptimizedStorageService _storage;
@override
AppColorPalette build() {
_storage = ref.watch(optimizedStorageServiceProvider);
final storedId = _storage.getThemePaletteId();
return AppColorPalettes.byId(storedId);
}
Future<void> setPalette(String paletteId) async {
final palette = AppColorPalettes.byId(paletteId);
state = palette;
await _storage.setThemePaletteId(palette.id);
}
}
@Riverpod(keepAlive: true)
class AppLightTheme extends _$AppLightTheme {
@override
ThemeData build() {
final palette = ref.watch(appThemePaletteProvider);
return AppTheme.light(palette);
}
}
@Riverpod(keepAlive: true)
class AppDarkTheme extends _$AppDarkTheme {
@override
ThemeData build() {
final palette = ref.watch(appThemePaletteProvider);
return AppTheme.dark(palette);
}
}
// Locale provider // Locale provider
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true)
class AppLocale extends _$AppLocale { class AppLocale extends _$AppLocale {

View File

@@ -2,7 +2,7 @@ import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/semantics.dart'; import 'package:flutter/semantics.dart';
import '../../shared/theme/app_theme.dart'; import '../../shared/theme/color_palettes.dart';
import '../../shared/theme/theme_extensions.dart'; import '../../shared/theme/theme_extensions.dart';
/// Enhanced accessibility service for WCAG 2.2 AA compliance /// Enhanced accessibility service for WCAG 2.2 AA compliance
@@ -349,9 +349,7 @@ class EnhancedAccessibilityService {
return BoxDecoration( return BoxDecoration(
border: hasFocus border: hasFocus
? Border.all( ? Border.all(
color: color: focusColor ?? AppColorPalettes.auroraViolet.light.primary,
focusColor ??
AppTheme.brandPrimary, // Brand primary as fallback
width: borderWidth, width: borderWidth,
) )
: null, : null,

View File

@@ -35,6 +35,7 @@ class OptimizedStorageService {
static const String _rememberCredentialsKey = static const String _rememberCredentialsKey =
PreferenceKeys.rememberCredentials; PreferenceKeys.rememberCredentials;
static const String _themeModeKey = PreferenceKeys.themeMode; static const String _themeModeKey = PreferenceKeys.themeMode;
static const String _themePaletteKey = PreferenceKeys.themePalette;
static const String _localeCodeKey = PreferenceKeys.localeCode; static const String _localeCodeKey = PreferenceKeys.localeCode;
static const String _localConversationsKey = HiveStoreKeys.localConversations; static const String _localConversationsKey = HiveStoreKeys.localConversations;
static const String _onboardingSeenKey = PreferenceKeys.onboardingSeen; static const String _onboardingSeenKey = PreferenceKeys.onboardingSeen;
@@ -261,6 +262,14 @@ class OptimizedStorageService {
await _preferencesBox.put(_themeModeKey, mode); await _preferencesBox.put(_themeModeKey, mode);
} }
String? getThemePaletteId() {
return _preferencesBox.get(_themePaletteKey) as String?;
}
Future<void> setThemePaletteId(String paletteId) async {
await _preferencesBox.put(_themePaletteKey, paletteId);
}
String? getLocaleCode() { String? getLocaleCode() {
return _preferencesBox.get(_localeCodeKey) as String?; return _preferencesBox.get(_localeCodeKey) as String?;
} }

View File

@@ -946,8 +946,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
final greetingName = deriveUserDisplayName(user); final greetingName = deriveUserDisplayName(user);
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
return SingleChildScrollView( return Padding(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(Spacing.lg), padding: const EdgeInsets.all(Spacing.lg),
child: ConstrainedBox( child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight), constraints: BoxConstraints(minHeight: constraints.maxHeight),

View File

@@ -115,6 +115,15 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
_updateTypingIndicatorGate(); _updateTypingIndicatorGate();
} }
// Update typing indicator gate when message properties that affect emptiness change
if (oldWidget.message.statusHistory != widget.message.statusHistory ||
oldWidget.message.files != widget.message.files ||
oldWidget.message.attachmentIds != widget.message.attachmentIds ||
oldWidget.message.followUps != widget.message.followUps ||
oldWidget.message.codeExecutions != widget.message.codeExecutions) {
_updateTypingIndicatorGate();
}
// Rebuild cached avatar if model name or icon changes // Rebuild cached avatar if model name or icon changes
if (oldWidget.modelName != widget.modelName || if (oldWidget.modelName != widget.modelName ||
oldWidget.modelIconUrl != widget.modelIconUrl) { oldWidget.modelIconUrl != widget.modelIconUrl) {
@@ -505,7 +514,17 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
} }
final hasCodeExecutions = widget.message.codeExecutions.isNotEmpty; final hasCodeExecutions = widget.message.codeExecutions.isNotEmpty;
return !hasCodeExecutions; if (hasCodeExecutions) {
return false;
}
// Check for tool calls in the content using ToolCallsParser
final hasToolCalls =
ToolCallsParser.segments(
content,
)?.any((segment) => segment.isToolCall) ??
false;
return !hasToolCalls;
} }
void _buildCachedAvatar() { void _buildCachedAvatar() {

View File

@@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/services/settings_service.dart'; import '../../../core/services/settings_service.dart';
import '../../../shared/theme/theme_extensions.dart'; import '../../../shared/theme/theme_extensions.dart';
import '../../../shared/theme/color_palettes.dart';
import '../../tools/providers/tools_providers.dart'; import '../../tools/providers/tools_providers.dart';
import '../../../core/models/tool.dart'; import '../../../core/models/tool.dart';
import '../../../shared/widgets/conduit_components.dart'; import '../../../shared/widgets/conduit_components.dart';
@@ -36,6 +37,7 @@ class AppCustomizationPage extends ConsumerWidget {
final locale = ref.watch(appLocaleProvider); final locale = ref.watch(appLocaleProvider);
final currentLanguageCode = locale?.languageCode ?? 'system'; final currentLanguageCode = locale?.languageCode ?? 'system';
final languageLabel = _resolveLanguageLabel(context, currentLanguageCode); final languageLabel = _resolveLanguageLabel(context, currentLanguageCode);
final activePalette = ref.watch(appThemePaletteProvider);
return Scaffold( return Scaffold(
backgroundColor: context.conduitTheme.surfaceBackground, backgroundColor: context.conduitTheme.surfaceBackground,
@@ -58,6 +60,7 @@ class AppCustomizationPage extends ConsumerWidget {
currentLanguageCode, currentLanguageCode,
languageLabel, languageLabel,
settings, settings,
activePalette,
), ),
const SizedBox(height: Spacing.sectionGap), const SizedBox(height: Spacing.sectionGap),
_buildQuickPillsSection(context, ref, settings), _buildQuickPillsSection(context, ref, settings),
@@ -110,6 +113,7 @@ class AppCustomizationPage extends ConsumerWidget {
String currentLanguageCode, String currentLanguageCode,
String languageLabel, String languageLabel,
AppSettings settings, AppSettings settings,
AppColorPalette palette,
) { ) {
final theme = context.conduitTheme; final theme = context.conduitTheme;
@@ -125,6 +129,8 @@ class AppCustomizationPage extends ConsumerWidget {
const SizedBox(height: Spacing.sm), const SizedBox(height: Spacing.sm),
_buildThemeSelector(context, ref, themeMode, themeDescription), _buildThemeSelector(context, ref, themeMode, themeDescription),
const SizedBox(height: Spacing.md), const SizedBox(height: Spacing.md),
_buildPaletteSelector(context, ref, palette),
const SizedBox(height: Spacing.md),
_CustomizationTile( _CustomizationTile(
leading: _buildIconBadge( leading: _buildIconBadge(
context, context,
@@ -277,6 +283,53 @@ class AppCustomizationPage extends ConsumerWidget {
); );
} }
Widget _buildPaletteSelector(
BuildContext context,
WidgetRef ref,
AppColorPalette activePalette,
) {
final theme = context.conduitTheme;
final palettes = AppColorPalettes.all;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context)!.themePalette,
style:
theme.bodyLarge?.copyWith(
color: theme.textPrimary,
fontWeight: FontWeight.w600,
) ??
TextStyle(color: theme.textPrimary, fontWeight: FontWeight.w600),
),
const SizedBox(height: Spacing.xs),
Text(
AppLocalizations.of(context)!.themePaletteDescription,
style:
theme.bodySmall?.copyWith(color: theme.textSecondary) ??
TextStyle(color: theme.textSecondary),
),
const SizedBox(height: Spacing.sm),
ConduitCard(
padding: const EdgeInsets.all(Spacing.cardPadding),
child: Column(
children: [
for (final palette in palettes)
_PaletteOption(
palette: palette,
activeId: activePalette.id,
onSelect: () => ref
.read(appThemePaletteProvider.notifier)
.setPalette(palette.id),
),
],
),
),
],
);
}
Widget _buildThemeChip( Widget _buildThemeChip(
BuildContext context, BuildContext context,
WidgetRef ref, { WidgetRef ref, {
@@ -551,6 +604,128 @@ class AppCustomizationPage extends ConsumerWidget {
} }
} }
class _PaletteOption extends StatelessWidget {
const _PaletteOption({
required this.palette,
required this.activeId,
required this.onSelect,
});
final AppColorPalette palette;
final String activeId;
final VoidCallback onSelect;
@override
Widget build(BuildContext context) {
final theme = context.conduitTheme;
final isSelected = palette.id == activeId;
final previewColors =
palette.preview ??
<Color>[
palette.light.primary,
palette.light.secondary,
palette.dark.primary,
];
return InkWell(
onTap: onSelect,
borderRadius: BorderRadius.circular(AppBorderRadius.md),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: Spacing.sm),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
isSelected ? Icons.radio_button_checked : Icons.radio_button_off,
color: isSelected ? theme.buttonPrimary : theme.iconSecondary,
size: IconSize.md,
),
const SizedBox(width: Spacing.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Text(
palette.label,
style:
theme.bodyLarge?.copyWith(
color: theme.textPrimary,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.w500,
) ??
TextStyle(
color: theme.textPrimary,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
if (isSelected)
Padding(
padding: const EdgeInsets.only(left: Spacing.xs),
child: Icon(
Icons.check_circle,
color: theme.buttonPrimary,
size: IconSize.sm,
),
),
],
),
const SizedBox(height: Spacing.xxs),
Text(
palette.description,
style:
theme.bodySmall?.copyWith(color: theme.textSecondary) ??
TextStyle(color: theme.textSecondary),
),
const SizedBox(height: Spacing.xs),
Row(
children: [
for (final color in previewColors)
_PaletteColorDot(color: color),
],
),
],
),
),
],
),
),
);
}
}
class _PaletteColorDot extends StatelessWidget {
const _PaletteColorDot({required this.color});
final Color color;
@override
Widget build(BuildContext context) {
final theme = context.conduitTheme;
return Container(
margin: const EdgeInsets.only(right: Spacing.xs),
width: 20,
height: 20,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(
color: theme.dividerColor.withValues(alpha: 0.4),
width: 1.2,
),
),
);
}
}
class _CustomizationTile extends StatelessWidget { class _CustomizationTile extends StatelessWidget {
const _CustomizationTile({ const _CustomizationTile({
required this.leading, required this.leading,

View File

@@ -260,6 +260,10 @@
"followingSystem": "Dem System folgen: {theme}", "followingSystem": "Dem System folgen: {theme}",
"@followingSystem": {"placeholders": {"theme": {"type": "String"}}}, "@followingSystem": {"placeholders": {"theme": {"type": "String"}}},
"themeDark": "Dunkel", "themeDark": "Dunkel",
"themePalette": "Farbpalette",
"@themePalette": {"description": "Titel für die Auswahl der App-Farbpalette."},
"themePaletteDescription": "Wählen Sie die Akzentfarben für Schaltflächen, Karten und Chatblasen.",
"@themePaletteDescription": {"description": "Hilfetext zur Erklärung der Palettenauswahl."},
"themeLight": "Hell", "themeLight": "Hell",
"currentlyUsingDarkTheme": "Aktuell dunkles Thema", "currentlyUsingDarkTheme": "Aktuell dunkles Thema",
"currentlyUsingLightTheme": "Aktuell helles Thema", "currentlyUsingLightTheme": "Aktuell helles Thema",

View File

@@ -519,6 +519,10 @@
}, },
"themeDark": "Dark", "themeDark": "Dark",
"@themeDark": {"description": "Theme label for dark appearance."}, "@themeDark": {"description": "Theme label for dark appearance."},
"themePalette": "Accent palette",
"@themePalette": {"description": "Title for selecting the app color palette."},
"themePaletteDescription": "Choose the accent colors used for buttons, cards, and chat bubbles.",
"@themePaletteDescription": {"description": "Helper text explaining palette selection."},
"themeLight": "Light", "themeLight": "Light",
"@themeLight": {"description": "Theme label for light appearance."}, "@themeLight": {"description": "Theme label for light appearance."},
"currentlyUsingDarkTheme": "Currently using Dark theme", "currentlyUsingDarkTheme": "Currently using Dark theme",

View File

@@ -260,6 +260,10 @@
"followingSystem": "Selon le système : {theme}", "followingSystem": "Selon le système : {theme}",
"@followingSystem": {"placeholders": {"theme": {"type": "String"}}}, "@followingSystem": {"placeholders": {"theme": {"type": "String"}}},
"themeDark": "Sombre", "themeDark": "Sombre",
"themePalette": "Palette de couleurs",
"@themePalette": {"description": "Titre pour choisir la palette de couleurs de l'application."},
"themePaletteDescription": "Choisissez les couleurs d'accent utilisées pour les boutons, les cartes et les bulles de discussion.",
"@themePaletteDescription": {"description": "Texte d'aide expliquant la sélection de la palette."},
"themeLight": "Clair", "themeLight": "Clair",
"currentlyUsingDarkTheme": "Thème sombre actuellement utilisé", "currentlyUsingDarkTheme": "Thème sombre actuellement utilisé",
"currentlyUsingLightTheme": "Thème clair actuellement utilisé", "currentlyUsingLightTheme": "Thème clair actuellement utilisé",

View File

@@ -260,6 +260,10 @@
"followingSystem": "Segue il sistema: {theme}", "followingSystem": "Segue il sistema: {theme}",
"@followingSystem": {"placeholders": {"theme": {"type": "String"}}}, "@followingSystem": {"placeholders": {"theme": {"type": "String"}}},
"themeDark": "Scuro", "themeDark": "Scuro",
"themePalette": "Palette di colori",
"@themePalette": {"description": "Titolo per scegliere la palette di colori dell'app."},
"themePaletteDescription": "Scegli i colori di accento usati per pulsanti, schede e bolle di chat.",
"@themePaletteDescription": {"description": "Testo di supporto che spiega la scelta della palette."},
"themeLight": "Chiaro", "themeLight": "Chiaro",
"currentlyUsingDarkTheme": "Attualmente tema scuro", "currentlyUsingDarkTheme": "Attualmente tema scuro",
"currentlyUsingLightTheme": "Attualmente tema chiaro", "currentlyUsingLightTheme": "Attualmente tema chiaro",

View File

@@ -1458,6 +1458,18 @@ abstract class AppLocalizations {
/// **'Dark'** /// **'Dark'**
String get themeDark; String get themeDark;
/// Title for selecting the app color palette.
///
/// In en, this message translates to:
/// **'Accent palette'**
String get themePalette;
/// Helper text explaining palette selection.
///
/// In en, this message translates to:
/// **'Choose the accent colors used for buttons, cards, and chat bubbles.'**
String get themePaletteDescription;
/// Theme label for light appearance. /// Theme label for light appearance.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View File

@@ -755,6 +755,13 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get themeDark => 'Dunkel'; String get themeDark => 'Dunkel';
@override
String get themePalette => 'Farbpalette';
@override
String get themePaletteDescription =>
'Wählen Sie die Akzentfarben für Schaltflächen, Karten und Chatblasen.';
@override @override
String get themeLight => 'Hell'; String get themeLight => 'Hell';

View File

@@ -751,6 +751,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get themeDark => 'Dark'; String get themeDark => 'Dark';
@override
String get themePalette => 'Accent palette';
@override
String get themePaletteDescription =>
'Choose the accent colors used for buttons, cards, and chat bubbles.';
@override @override
String get themeLight => 'Light'; String get themeLight => 'Light';

View File

@@ -763,6 +763,13 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get themeDark => 'Sombre'; String get themeDark => 'Sombre';
@override
String get themePalette => 'Palette de couleurs';
@override
String get themePaletteDescription =>
'Choisissez les couleurs d\'accent utilisées pour les boutons, les cartes et les bulles de discussion.';
@override @override
String get themeLight => 'Clair'; String get themeLight => 'Clair';

View File

@@ -752,6 +752,13 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get themeDark => 'Scuro'; String get themeDark => 'Scuro';
@override
String get themePalette => 'Palette di colori';
@override
String get themePaletteDescription =>
'Scegli i colori di accento usati per pulsanti, schede e bolle di chat.';
@override @override
String get themeLight => 'Chiaro'; String get themeLight => 'Chiaro';

View File

@@ -11,7 +11,6 @@ import 'core/persistence/hive_bootstrap.dart';
import 'core/persistence/persistence_migrator.dart'; import 'core/persistence/persistence_migrator.dart';
import 'core/persistence/persistence_providers.dart'; import 'core/persistence/persistence_providers.dart';
import 'core/router/app_router.dart'; import 'core/router/app_router.dart';
import 'shared/theme/app_theme.dart';
import 'shared/widgets/offline_indicator.dart'; import 'shared/widgets/offline_indicator.dart';
import 'features/auth/providers/unified_auth_providers.dart'; import 'features/auth/providers/unified_auth_providers.dart';
import 'core/auth/auth_state_manager.dart'; import 'core/auth/auth_state_manager.dart';
@@ -151,13 +150,15 @@ class _ConduitAppState extends ConsumerState<ConduitApp> {
final themeMode = ref.watch(appThemeModeProvider.select((mode) => mode)); final themeMode = ref.watch(appThemeModeProvider.select((mode) => mode));
final router = ref.watch(goRouterProvider); final router = ref.watch(goRouterProvider);
final locale = ref.watch(appLocaleProvider); final locale = ref.watch(appLocaleProvider);
final lightTheme = ref.watch(appLightThemeProvider);
final darkTheme = ref.watch(appDarkThemeProvider);
return ErrorBoundary( return ErrorBoundary(
child: MaterialApp.router( child: MaterialApp.router(
routerConfig: router, routerConfig: router,
onGenerateTitle: (context) => AppLocalizations.of(context)!.appTitle, onGenerateTitle: (context) => AppLocalizations.of(context)!.appTitle,
theme: AppTheme.conduitLightTheme, theme: lightTheme,
darkTheme: AppTheme.conduitDarkTheme, darkTheme: darkTheme,
themeMode: themeMode, themeMode: themeMode,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
locale: locale, locale: locale,

View File

@@ -3,6 +3,7 @@ import '../theme/theme_extensions.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'dart:io' show Platform; import 'dart:io' show Platform;
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
import '../theme/color_palettes.dart';
/// Centralized service for consistent brand identity throughout the app /// Centralized service for consistent brand identity throughout the app
/// Uses the hub icon as the primary brand element /// Uses the hub icon as the primary brand element
@@ -20,9 +21,32 @@ class BrandService {
Platform.isIOS ? CupertinoIcons.globe : Icons.public; Platform.isIOS ? CupertinoIcons.globe : Icons.public;
/// Brand colors - these should be accessed through context.conduitTheme in UI components /// Brand colors - these should be accessed through context.conduitTheme in UI components
static Color get primaryBrandColor => AppTheme.brandPrimary; static Color primaryBrandColor({
static Color get secondaryBrandColor => AppTheme.brandPrimaryLight; BuildContext? context,
static Color get accentBrandColor => AppTheme.brandPrimaryDark; Brightness? brightness,
}) {
final palette = _resolvePalette(context);
final resolvedBrightness = brightness ?? _resolveBrightness(context);
return palette.primaryFor(resolvedBrightness);
}
static Color secondaryBrandColor({
BuildContext? context,
Brightness? brightness,
}) {
final palette = _resolvePalette(context);
final resolvedBrightness = brightness ?? _resolveBrightness(context);
return palette.secondaryFor(resolvedBrightness);
}
static Color accentBrandColor({
BuildContext? context,
Brightness? brightness,
}) {
final palette = _resolvePalette(context);
final resolvedBrightness = brightness ?? _resolveBrightness(context);
return palette.accentFor(resolvedBrightness);
}
/// Creates a branded icon with consistent styling /// Creates a branded icon with consistent styling
static Widget createBrandIcon({ static Widget createBrandIcon({
@@ -31,21 +55,25 @@ class BrandService {
IconData? icon, IconData? icon,
bool useGradient = false, bool useGradient = false,
bool addShadow = false, bool addShadow = false,
BuildContext? context,
}) { }) {
final iconData = icon ?? primaryIcon; final iconData = icon ?? primaryIcon;
final iconColor = color ?? primaryBrandColor; final resolvedColor = color ?? primaryBrandColor(context: context);
Widget iconWidget = Icon( Widget iconWidget = Icon(
iconData, iconData,
size: size, size: size,
color: useGradient ? null : iconColor, color: useGradient ? null : resolvedColor,
); );
if (useGradient) { if (useGradient) {
iconWidget = ShaderMask( iconWidget = ShaderMask(
blendMode: BlendMode.srcIn, blendMode: BlendMode.srcIn,
shaderCallback: (bounds) => LinearGradient( shaderCallback: (bounds) => LinearGradient(
colors: [primaryBrandColor, secondaryBrandColor], colors: [
primaryBrandColor(context: context),
secondaryBrandColor(context: context),
],
).createShader(bounds), ).createShader(bounds),
child: Icon(iconData, size: size), child: Icon(iconData, size: size),
); );
@@ -56,7 +84,7 @@ class BrandService {
decoration: BoxDecoration( decoration: BoxDecoration(
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: primaryBrandColor.withValues(alpha: 0.3), color: primaryBrandColor(context: context).withValues(alpha: 0.3),
blurRadius: size * 0.3, blurRadius: size * 0.3,
offset: Offset(0, size * 0.1), offset: Offset(0, size * 0.1),
), ),
@@ -78,7 +106,7 @@ class BrandService {
String? fallbackText, String? fallbackText,
BuildContext? context, BuildContext? context,
}) { }) {
final bgColor = backgroundColor ?? primaryBrandColor; final bgColor = backgroundColor ?? primaryBrandColor(context: context);
final iColor = final iColor =
iconColor ?? (context?.conduitTheme.textInverse ?? AppTheme.neutral50); iconColor ?? (context?.conduitTheme.textInverse ?? AppTheme.neutral50);
@@ -90,14 +118,17 @@ class BrandService {
? LinearGradient( ? LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: [primaryBrandColor, secondaryBrandColor], colors: [
primaryBrandColor(context: context),
secondaryBrandColor(context: context),
],
) )
: null, : null,
color: useGradient ? null : bgColor, color: useGradient ? null : bgColor,
borderRadius: BorderRadius.circular(size / 2), borderRadius: BorderRadius.circular(size / 2),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: primaryBrandColor.withValues(alpha: 0.3), color: primaryBrandColor(context: context).withValues(alpha: 0.3),
blurRadius: size * 0.2, blurRadius: size * 0.2,
offset: Offset(0, size * 0.1), offset: Offset(0, size * 0.1),
), ),
@@ -123,13 +154,16 @@ class BrandService {
double size = 24, double size = 24,
double strokeWidth = 2, double strokeWidth = 2,
Color? color, Color? color,
BuildContext? context,
}) { }) {
return SizedBox( return SizedBox(
width: size, width: size,
height: size, height: size,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: strokeWidth, strokeWidth: strokeWidth,
valueColor: AlwaysStoppedAnimation<Color>(color ?? primaryBrandColor), valueColor: AlwaysStoppedAnimation<Color>(
color ?? primaryBrandColor(context: context),
),
), ),
); );
} }
@@ -149,6 +183,7 @@ class BrandService {
size: size, size: size,
color: iconColor, color: iconColor,
icon: primaryIconOutlined, icon: primaryIconOutlined,
context: context,
); );
} }
@@ -167,6 +202,7 @@ class BrandService {
size: size * 0.5, size: size * 0.5,
color: iconColor, color: iconColor,
icon: primaryIconOutlined, icon: primaryIconOutlined,
context: context,
), ),
); );
} }
@@ -181,27 +217,27 @@ class BrandService {
bool isSecondary = false, bool isSecondary = false,
BuildContext? context, BuildContext? context,
}) { }) {
final theme = context?.conduitTheme;
return SizedBox( return SizedBox(
width: width, width: width,
height: 48, height: 48,
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: isLoading ? null : onPressed, onPressed: isLoading ? null : onPressed,
icon: isLoading icon: isLoading
? createBrandLoadingIndicator(size: IconSize.sm) ? createBrandLoadingIndicator(size: IconSize.sm, context: context)
: createBrandIcon( : createBrandIcon(
size: IconSize.md, size: IconSize.md,
icon: icon ?? primaryIcon, icon: icon ?? primaryIcon,
color: context?.conduitTheme.textInverse ?? AppTheme.neutral50, color: theme?.textInverse ?? AppTheme.neutral50,
context: context,
), ),
label: Text(text), label: Text(text),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: isSecondary backgroundColor: isSecondary
? (context?.conduitTheme.buttonSecondary ?? AppTheme.neutral700) ? (theme?.buttonSecondary ?? AppTheme.neutral700)
: (context?.conduitTheme.buttonPrimary ?? primaryBrandColor), : (theme?.buttonPrimary ?? primaryBrandColor(context: context)),
foregroundColor: foregroundColor: theme?.buttonPrimaryText ?? AppTheme.neutral50,
context?.conduitTheme.buttonPrimaryText ?? AppTheme.neutral50, disabledBackgroundColor: theme?.buttonDisabled ?? AppTheme.neutral500,
disabledBackgroundColor:
context?.conduitTheme.buttonDisabled ?? AppTheme.neutral500,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md), borderRadius: BorderRadius.circular(AppBorderRadius.md),
), ),
@@ -255,6 +291,14 @@ class BrandService {
bool animate = true, bool animate = true,
BuildContext? context, BuildContext? context,
}) { }) {
final theme = context?.conduitTheme;
final baseColor =
theme?.buttonPrimary ??
primaryBrandColor(context: context, brightness: Brightness.dark);
final accentColor =
theme?.buttonPrimary.withValues(alpha: 0.8) ??
secondaryBrandColor(context: context, brightness: Brightness.dark);
return Container( return Container(
width: size, width: size,
height: size, height: size,
@@ -262,11 +306,7 @@ class BrandService {
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: [ colors: [baseColor, accentColor],
context?.conduitTheme.buttonPrimary ?? primaryBrandColor,
context?.conduitTheme.buttonPrimary.withValues(alpha: 0.8) ??
secondaryBrandColor,
],
), ),
borderRadius: BorderRadius.circular(size / 2), borderRadius: BorderRadius.circular(size / 2),
boxShadow: ConduitShadows.glow, boxShadow: ConduitShadows.glow,
@@ -274,8 +314,20 @@ class BrandService {
child: Icon( child: Icon(
primaryIcon, primaryIcon,
size: size * 0.5, size: size * 0.5,
color: context?.conduitTheme.textInverse ?? AppTheme.neutral50, color: theme?.textInverse ?? AppTheme.neutral50,
), ),
); );
} }
static AppColorPalette _resolvePalette(BuildContext? context) {
if (context == null) {
return AppColorPalettes.auroraViolet;
}
final extension = Theme.of(context).extension<AppPaletteThemeExtension>();
return extension?.palette ?? AppColorPalettes.auroraViolet;
}
static Brightness _resolveBrightness(BuildContext? context) {
return context != null ? Theme.of(context).brightness : Brightness.light;
}
} }

View File

@@ -2,13 +2,9 @@ import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_animate/flutter_animate.dart';
import 'theme_extensions.dart'; import 'theme_extensions.dart';
import 'color_palettes.dart';
class AppTheme { class AppTheme {
// Brand accents tuned for WCAG contrast in light/dark modes
static const Color brandPrimary = Color(0xFFA420FF); // Light mode primary
static const Color brandPrimaryLight = Color(0xFFC773FF); // Light accents
static const Color brandPrimaryDark = Color(0xFF9500FF); // Dark mode primary
// Enhanced neutral palette for better contrast (WCAG AA compliant) // Enhanced neutral palette for better contrast (WCAG AA compliant)
static const Color neutral900 = Color(0xFF000000); // Pure black static const Color neutral900 = Color(0xFF000000); // Pure black
static const Color neutral800 = Color( static const Color neutral800 = Color(
@@ -35,38 +31,25 @@ class AppTheme {
static const Color info = Color(0xFF0284C7); // Better blue contrast static const Color info = Color(0xFF0284C7); // Better blue contrast
static const Color infoDark = Color(0xFF0369A1); // Dark theme blue static const Color infoDark = Color(0xFF0369A1); // Dark theme blue
// Brand aliases static ThemeData light(AppColorPalette palette) {
static const Color primaryColor = brandPrimary; final lightTone = palette.light;
static const Color secondaryColor = brandPrimaryLight;
static const Color surfaceColor = neutral50;
static const Color errorColor = error;
static const Color successColor = success;
// Base Light Theme return ThemeData(
static ThemeData lightTheme = ThemeData(
useMaterial3: true, useMaterial3: true,
brightness: Brightness.light, brightness: Brightness.light,
colorScheme: const ColorScheme.light( colorScheme: ColorScheme.light(
primary: brandPrimary, primary: lightTone.primary,
secondary: brandPrimaryLight, secondary: lightTone.secondary,
surface: surfaceColor, surface: neutral50,
error: errorColor, error: error,
), ).copyWith(surfaceContainerHighest: const Color(0xFFF0F1F1)),
pageTransitionsTheme: const PageTransitionsTheme( pageTransitionsTheme: _pageTransitionsTheme,
builders: <TargetPlatform, PageTransitionsBuilder>{
TargetPlatform.android: ZoomPageTransitionsBuilder(),
TargetPlatform.iOS: ZoomPageTransitionsBuilder(),
TargetPlatform.linux: ZoomPageTransitionsBuilder(),
TargetPlatform.macOS: ZoomPageTransitionsBuilder(),
TargetPlatform.windows: ZoomPageTransitionsBuilder(),
},
),
splashFactory: NoSplash.splashFactory, splashFactory: NoSplash.splashFactory,
appBarTheme: const AppBarTheme( appBarTheme: const AppBarTheme(
centerTitle: true, centerTitle: true,
elevation: Elevation.none, elevation: Elevation.none,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
foregroundColor: neutral900, foregroundColor: neutral800,
), ),
bottomSheetTheme: BottomSheetThemeData( bottomSheetTheme: BottomSheetThemeData(
backgroundColor: neutral50, backgroundColor: neutral50,
@@ -119,46 +102,43 @@ class AppTheme {
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md), borderRadius: BorderRadius.circular(AppBorderRadius.md),
borderSide: const BorderSide(color: primaryColor, width: 2), borderSide: BorderSide(color: lightTone.primary, width: 2),
), ),
errorBorder: OutlineInputBorder( errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md), borderRadius: BorderRadius.circular(AppBorderRadius.md),
borderSide: const BorderSide(color: errorColor, width: 1), borderSide: const BorderSide(color: error, width: 1),
), ),
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
horizontal: Spacing.md, horizontal: Spacing.md,
vertical: Spacing.sm, vertical: Spacing.sm,
), ),
), ),
// Use platform default system font text theme
textTheme: ThemeData.light().textTheme, textTheme: ThemeData.light().textTheme,
extensions: const [ConduitThemeExtension.light], extensions: <ThemeExtension<dynamic>>[
ConduitThemeExtension.lightPalette(palette),
AppPaletteThemeExtension(palette: palette),
],
); );
}
// Base Dark Theme static ThemeData dark(AppColorPalette palette) {
static ThemeData darkTheme = ThemeData( final darkTone = palette.dark;
return ThemeData(
useMaterial3: true, useMaterial3: true,
brightness: Brightness.dark, brightness: Brightness.dark,
scaffoldBackgroundColor: Color(0xFF0A0D0C), scaffoldBackgroundColor: const Color(0xFF0A0D0C),
colorScheme: const ColorScheme.dark( colorScheme: ColorScheme.dark(
primary: brandPrimaryDark, primary: darkTone.primary,
secondary: brandPrimary, secondary: darkTone.secondary,
surface: Color(0xFF0A0D0C), surface: const Color(0xFF0A0D0C),
surfaceContainerHighest: neutral700, surfaceContainerHighest: neutral700,
onSurface: neutral50, onSurface: neutral50,
onSurfaceVariant: neutral300, onSurfaceVariant: neutral300,
outline: neutral600, outline: neutral600,
error: error, error: error,
), ),
pageTransitionsTheme: const PageTransitionsTheme( pageTransitionsTheme: _pageTransitionsTheme,
builders: <TargetPlatform, PageTransitionsBuilder>{
TargetPlatform.android: ZoomPageTransitionsBuilder(),
TargetPlatform.iOS: ZoomPageTransitionsBuilder(),
TargetPlatform.linux: ZoomPageTransitionsBuilder(),
TargetPlatform.macOS: ZoomPageTransitionsBuilder(),
TargetPlatform.windows: ZoomPageTransitionsBuilder(),
},
),
splashFactory: NoSplash.splashFactory, splashFactory: NoSplash.splashFactory,
appBarTheme: const AppBarTheme( appBarTheme: const AppBarTheme(
centerTitle: true, centerTitle: true,
@@ -217,7 +197,7 @@ class AppTheme {
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md), borderRadius: BorderRadius.circular(AppBorderRadius.md),
borderSide: const BorderSide(color: brandPrimaryDark, width: 2), borderSide: BorderSide(color: darkTone.primary, width: 2),
), ),
errorBorder: OutlineInputBorder( errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md), borderRadius: BorderRadius.circular(AppBorderRadius.md),
@@ -228,105 +208,42 @@ class AppTheme {
vertical: Spacing.sm, vertical: Spacing.sm,
), ),
), ),
// Use platform default system font text theme
textTheme: ThemeData.dark().textTheme, textTheme: ThemeData.dark().textTheme,
extensions: const [ConduitThemeExtension.dark], extensions: <ThemeExtension<dynamic>>[
); ConduitThemeExtension.darkPalette(palette),
AppPaletteThemeExtension(palette: palette),
// Conduit variants using brand colors ],
static ThemeData conduitLightTheme = lightTheme.copyWith(
colorScheme: lightTheme.colorScheme.copyWith(
primary: brandPrimary,
secondary: brandPrimaryLight,
surface: neutral50,
),
extensions: const [ConduitThemeExtension.light],
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: Elevation.none,
backgroundColor: Colors.transparent,
foregroundColor: neutral800,
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: neutral50,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
borderSide: const BorderSide(color: brandPrimary, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
borderSide: const BorderSide(color: error, width: 1),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: Spacing.md,
vertical: Spacing.sm,
),
),
);
static ThemeData conduitDarkTheme = darkTheme.copyWith(
scaffoldBackgroundColor: const Color(0xFF0A0D0C),
colorScheme: darkTheme.colorScheme.copyWith(
primary: brandPrimaryDark,
secondary: brandPrimary,
surface: const Color(0xFF0A0D0C),
surfaceContainerHighest: neutral700,
),
extensions: const [ConduitThemeExtension.dark],
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: Elevation.none,
backgroundColor: Colors.transparent,
foregroundColor: neutral50,
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: neutral700,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
borderSide: const BorderSide(color: neutral600, width: 1),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
borderSide: const BorderSide(color: neutral600, width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
borderSide: const BorderSide(color: brandPrimaryDark, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
borderSide: const BorderSide(color: error, width: 1),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: Spacing.md,
vertical: Spacing.sm,
),
),
);
// Classic Conduit variants for runtime switching
// Removed classic Conduit variants from public API to keep Aurora only
// Platform-specific theming helpers
static CupertinoThemeData cupertinoTheme(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return CupertinoThemeData(
brightness: isDark ? Brightness.dark : Brightness.light,
primaryColor: isDark ? brandPrimaryDark : brandPrimary,
scaffoldBackgroundColor: isDark ? neutral900 : neutral50,
barBackgroundColor: isDark ? neutral900 : neutral50,
); );
} }
static CupertinoThemeData cupertinoTheme(
BuildContext context,
AppColorPalette palette,
) {
final brightness = Theme.of(context).brightness;
final tone = palette.toneFor(brightness);
return CupertinoThemeData(
brightness: brightness,
primaryColor: tone.primary,
scaffoldBackgroundColor: brightness == Brightness.dark
? neutral900
: neutral50,
barBackgroundColor: brightness == Brightness.dark
? neutral900
: neutral50,
);
}
static const PageTransitionsTheme _pageTransitionsTheme =
PageTransitionsTheme(
builders: <TargetPlatform, PageTransitionsBuilder>{
TargetPlatform.android: ZoomPageTransitionsBuilder(),
TargetPlatform.iOS: ZoomPageTransitionsBuilder(),
TargetPlatform.linux: ZoomPageTransitionsBuilder(),
TargetPlatform.macOS: ZoomPageTransitionsBuilder(),
TargetPlatform.windows: ZoomPageTransitionsBuilder(),
},
);
} }
/// Animated theme wrapper for smooth theme transitions /// Animated theme wrapper for smooth theme transitions

View File

@@ -0,0 +1,159 @@
import 'package:flutter/material.dart';
@immutable
class AppPaletteTone {
const AppPaletteTone({
required this.primary,
required this.secondary,
required this.accent,
});
final Color primary;
final Color secondary;
final Color accent;
}
@immutable
class AppColorPalette {
const AppColorPalette({
required this.id,
required this.label,
required this.description,
required this.light,
required this.dark,
this.preview,
});
final String id;
final String label;
final String description;
final AppPaletteTone light;
final AppPaletteTone dark;
final List<Color>? preview;
}
@immutable
class AppPaletteThemeExtension
extends ThemeExtension<AppPaletteThemeExtension> {
const AppPaletteThemeExtension({required this.palette});
final AppColorPalette palette;
@override
AppPaletteThemeExtension copyWith({AppColorPalette? palette}) {
return AppPaletteThemeExtension(palette: palette ?? this.palette);
}
@override
AppPaletteThemeExtension lerp(
covariant ThemeExtension<AppPaletteThemeExtension>? other,
double t,
) {
if (other is! AppPaletteThemeExtension) return this;
return t < 0.5 ? this : other;
}
}
class AppColorPalettes {
static const String defaultPaletteId = 'aurora_violet';
static const AppColorPalette auroraViolet = AppColorPalette(
id: defaultPaletteId,
label: 'Aurora Violet',
description: 'Bold purples inspired by aurora skies.',
light: AppPaletteTone(
primary: Color(0xFFA420FF),
secondary: Color(0xFFB058FF),
accent: Color(0xFFD9A5FF),
),
dark: AppPaletteTone(
primary: Color(0xFF9500FF),
secondary: Color(0xFFC773FF),
accent: Color(0xFFE3BDFF),
),
preview: [Color(0xFF9500FF), Color(0xFFA420FF), Color(0xFFB058FF)],
);
static const AppColorPalette emeraldRush = AppColorPalette(
id: 'emerald_rush',
label: 'Emerald Rush',
description: 'High-contrast greens with calm highlights.',
light: AppPaletteTone(
primary: Color(0xFF0C7F48),
secondary: Color(0xFF26A164),
accent: Color(0xFF6DE0A4),
),
dark: AppPaletteTone(
primary: Color(0xFF40DD7F),
secondary: Color(0xFF26A164),
accent: Color(0xFF6DE0A4),
),
preview: [Color(0xFF0C7F48), Color(0xFF26A164), Color(0xFF40DD7F)],
);
static const AppColorPalette azurePulse = AppColorPalette(
id: 'azure_pulse',
label: 'Azure Pulse',
description: 'Electric blues with crisp highlights.',
light: AppPaletteTone(
primary: Color(0xFF1B64DA),
secondary: Color(0xFF2E7AF0),
accent: Color(0xFF6DA6FF),
),
dark: AppPaletteTone(
primary: Color(0xFF37C7FF),
secondary: Color(0xFF2E7AF0),
accent: Color(0xFF6DA6FF),
),
preview: [Color(0xFF1B64DA), Color(0xFF2E7AF0), Color(0xFF37C7FF)],
);
static const AppColorPalette sunsetGlow = AppColorPalette(
id: 'sunset_glow',
label: 'Sunset Glow',
description: 'Warm oranges for energetic interfaces.',
light: AppPaletteTone(
primary: Color(0xFFB83200),
secondary: Color(0xFFE65100),
accent: Color(0xFFFFA05B),
),
dark: AppPaletteTone(
primary: Color(0xFFFF8A00),
secondary: Color(0xFFE65100),
accent: Color(0xFFFFA05B),
),
preview: [Color(0xFFB83200), Color(0xFFE65100), Color(0xFFFF8A00)],
);
static const List<AppColorPalette> all = [
auroraViolet,
emeraldRush,
azurePulse,
sunsetGlow,
];
static AppColorPalette byId(String? id) {
return all.firstWhere(
(palette) => palette.id == id,
orElse: () => auroraViolet,
);
}
}
extension AppColorPaletteX on AppColorPalette {
AppPaletteTone toneFor(Brightness brightness) {
return brightness == Brightness.dark ? dark : light;
}
Color primaryFor(Brightness brightness) {
return toneFor(brightness).primary;
}
Color secondaryFor(Brightness brightness) {
return toneFor(brightness).secondary;
}
Color accentFor(Brightness brightness) {
return toneFor(brightness).accent;
}
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
// Using system fonts; no GoogleFonts dependency required // Using system fonts; no GoogleFonts dependency required
import 'app_theme.dart'; import 'app_theme.dart';
import 'color_palettes.dart';
/// Extended theme data for consistent styling across the app /// Extended theme data for consistent styling across the app
@immutable @immutable
@@ -501,74 +502,63 @@ class ConduitThemeExtension extends ThemeExtension<ConduitThemeExtension> {
); );
} }
/// Dark theme extension /// Dark theme extension derived from the active color palette.
static const ConduitThemeExtension dark = ConduitThemeExtension( static ConduitThemeExtension darkPalette(AppColorPalette palette) {
// Chat-specific colors - Enhanced for production-grade look final darkTone = palette.dark;
chatBubbleUser: AppTheme.brandPrimaryDark, return ConduitThemeExtension(
chatBubbleAssistant: Color(0xFF0E1010), chatBubbleUser: darkTone.primary,
chatBubbleAssistant: const Color(0xFF0E1010),
chatBubbleUserText: AppTheme.neutral50, chatBubbleUserText: AppTheme.neutral50,
chatBubbleAssistantText: AppTheme.neutral50, chatBubbleAssistantText: AppTheme.neutral50,
chatBubbleUserBorder: AppTheme.brandPrimaryDark, chatBubbleUserBorder: darkTone.secondary,
chatBubbleAssistantBorder: Color(0xFF1A1D1C), chatBubbleAssistantBorder: const Color(0xFF1A1D1C),
// Input and form colors inputBackground: const Color(0xFF141615),
inputBackground: Color(0xFF141615),
inputBorder: AppTheme.neutral600, inputBorder: AppTheme.neutral600,
inputBorderFocused: AppTheme.brandPrimaryDark, inputBorderFocused: darkTone.primary,
inputText: AppTheme.neutral50, inputText: AppTheme.neutral50,
inputPlaceholder: AppTheme.neutral300, inputPlaceholder: AppTheme.neutral300,
inputError: AppTheme.error, inputError: AppTheme.error,
cardBackground: const Color(0xFF0C0F0E),
// Card and surface colors - Enhanced depth and hierarchy cardBorder: const Color(0xFF151918),
cardBackground: Color(0xFF0C0F0E),
cardBorder: Color(0xFF151918),
cardShadow: AppTheme.neutral900, cardShadow: AppTheme.neutral900,
surfaceBackground: Color(0xFF0A0D0C), surfaceBackground: const Color(0xFF0A0D0C),
surfaceContainer: Color(0xFF0C0F0E), surfaceContainer: const Color(0xFF0C0F0E),
surfaceContainerHighest: Color(0xFF121514), surfaceContainerHighest: const Color(0xFF121514),
buttonPrimary: darkTone.primary,
// Interactive element colors - More vibrant and accessible
buttonPrimary: AppTheme.brandPrimaryDark,
buttonPrimaryText: AppTheme.neutral50, buttonPrimaryText: AppTheme.neutral50,
buttonSecondary: Color(0xFF151918), buttonSecondary: const Color(0xFF151918),
buttonSecondaryText: AppTheme.neutral50, buttonSecondaryText: AppTheme.neutral50,
buttonDisabled: AppTheme.neutral600, buttonDisabled: AppTheme.neutral600,
buttonDisabledText: AppTheme.neutral400, buttonDisabledText: AppTheme.neutral400,
success: const Color(0xFF34D399),
// Status and feedback colors - Enhanced visibility successBackground: const Color(0xFF14532D),
success: Color(0xFF34D399), error: const Color(0xFFFCA5A5),
successBackground: Color(0xFF14532D), errorBackground: const Color(0xFF7F1D1D),
error: Color(0xFFFCA5A5), warning: const Color(0xFFFBBF24),
errorBackground: Color(0xFF7F1D1D), warningBackground: const Color(0xFF451A03),
warning: Color(0xFFFBBF24), info: const Color(0xFF93C5FD),
warningBackground: Color(0xFF451A03), infoBackground: const Color(0xFF0C4A6E),
info: Color(0xFF93C5FD),
infoBackground: Color(0xFF0C4A6E),
// Navigation and UI element colors - Enhanced contrast
dividerColor: AppTheme.neutral600, dividerColor: AppTheme.neutral600,
navigationBackground: Color(0xFF0A0D0C), navigationBackground: const Color(0xFF0A0D0C),
navigationSelected: AppTheme.brandPrimaryDark, navigationSelected: darkTone.primary,
navigationUnselected: AppTheme.neutral300, navigationUnselected: AppTheme.neutral300,
navigationSelectedBackground: Color(0xFF312E81), navigationSelectedBackground: _surfaceTint(
darkTone.primary,
// Loading and animation colors - Enhanced visibility const Color(0xFF0A0D0C),
shimmerBase: Color(0xFF121514), 0.24,
shimmerHighlight: Color(0xFF1A1D1C), ),
loadingIndicator: AppTheme.brandPrimaryDark, shimmerBase: const Color(0xFF121514),
// Text colors - Enhanced hierarchy shimmerHighlight: const Color(0xFF1A1D1C),
loadingIndicator: darkTone.primary,
textPrimary: AppTheme.neutral50, textPrimary: AppTheme.neutral50,
textSecondary: Color(0xFFBAC2C0), textSecondary: const Color(0xFFBAC2C0),
textTertiary: AppTheme.neutral400, textTertiary: AppTheme.neutral400,
textInverse: AppTheme.neutral900, textInverse: AppTheme.neutral900,
textDisabled: AppTheme.neutral600, textDisabled: AppTheme.neutral600,
// Icon colors - Enhanced visibility
iconPrimary: AppTheme.neutral50, iconPrimary: AppTheme.neutral50,
iconSecondary: Color(0xFFA0A8A5), iconSecondary: const Color(0xFFA0A8A5),
iconDisabled: AppTheme.neutral600, iconDisabled: AppTheme.neutral600,
iconInverse: AppTheme.neutral900, iconInverse: AppTheme.neutral900,
// Typography styles
headingLarge: TextStyle( headingLarge: TextStyle(
fontSize: AppTypography.displaySmall, fontSize: AppTypography.displaySmall,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
@@ -602,7 +592,7 @@ class ConduitThemeExtension extends ThemeExtension<ConduitThemeExtension> {
bodySmall: TextStyle( bodySmall: TextStyle(
fontSize: AppTypography.bodySmall, fontSize: AppTypography.bodySmall,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
color: Color(0xFFD1D5DB), // Enhanced contrast color: const Color(0xFFD1D5DB),
height: 1.4, height: 1.4,
), ),
caption: TextStyle( caption: TextStyle(
@@ -615,149 +605,160 @@ class ConduitThemeExtension extends ThemeExtension<ConduitThemeExtension> {
label: TextStyle( label: TextStyle(
fontSize: AppTypography.labelLarge, fontSize: AppTypography.labelLarge,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: Color(0xFFD1D5DB), // Enhanced contrast color: const Color(0xFFD1D5DB),
height: 1.3, height: 1.3,
), ),
code: TextStyle( code: TextStyle(
fontSize: AppTypography.bodySmall, fontSize: AppTypography.bodySmall,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
color: Color(0xFFD1D5DB), // Enhanced contrast color: const Color(0xFFD1D5DB),
height: 1.4, height: 1.4,
fontFamily: AppTypography.monospaceFontFamily, fontFamily: AppTypography.monospaceFontFamily,
), ),
); );
}
/// Light theme extension /// Light theme extension derived from the active color palette.
static const ConduitThemeExtension light = ConduitThemeExtension( static ConduitThemeExtension lightPalette(AppColorPalette palette) {
// Chat-specific colors - Enhanced for production-grade look final lightTone = palette.light;
chatBubbleUser: AppTheme.brandPrimary, final darkTone = palette.dark;
chatBubbleAssistant: Color(0xFFF7F7F7), return ConduitThemeExtension(
chatBubbleUser: lightTone.primary,
chatBubbleAssistant: const Color(0xFFF7F7F7),
chatBubbleUserText: AppTheme.neutral50, chatBubbleUserText: AppTheme.neutral50,
chatBubbleAssistantText: Color(0xFF1C1C1C), chatBubbleAssistantText: const Color(0xFF1C1C1C),
chatBubbleUserBorder: AppTheme.brandPrimaryDark, chatBubbleUserBorder: darkTone.primary,
chatBubbleAssistantBorder: Color(0xFFE7E7E7), chatBubbleAssistantBorder: const Color(0xFFE7E7E7),
// Input and form colors
inputBackground: AppTheme.neutral50, inputBackground: AppTheme.neutral50,
inputBorder: AppTheme.neutral200, inputBorder: AppTheme.neutral200,
inputBorderFocused: AppTheme.brandPrimary, inputBorderFocused: lightTone.primary,
inputText: AppTheme.neutral900, inputText: AppTheme.neutral900,
inputPlaceholder: AppTheme.neutral500, inputPlaceholder: AppTheme.neutral500,
inputError: AppTheme.error, inputError: AppTheme.error,
// Card and surface colors - Enhanced depth and hierarchy
cardBackground: AppTheme.neutral50, cardBackground: AppTheme.neutral50,
cardBorder: Color(0xFFE7E7E7), cardBorder: const Color(0xFFE7E7E7),
cardShadow: Color(0xFFF3F4F6), cardShadow: const Color(0xFFF3F4F6),
surfaceBackground: AppTheme.neutral50, surfaceBackground: AppTheme.neutral50,
surfaceContainer: Color(0xFFF7F7F7), surfaceContainer: const Color(0xFFF7F7F7),
surfaceContainerHighest: Color(0xFFF0F1F1), surfaceContainerHighest: const Color(0xFFF0F1F1),
// Interactive element colors - More vibrant and accessible buttonPrimary: lightTone.primary,
buttonPrimary: AppTheme.brandPrimary,
buttonPrimaryText: AppTheme.neutral50, buttonPrimaryText: AppTheme.neutral50,
buttonSecondary: Color(0xFFF0F1F1), buttonSecondary: const Color(0xFFF0F1F1),
buttonSecondaryText: Color(0xFF1C1C1C), buttonSecondaryText: const Color(0xFF1C1C1C),
buttonDisabled: AppTheme.neutral300, buttonDisabled: AppTheme.neutral300,
buttonDisabledText: AppTheme.neutral500, buttonDisabledText: AppTheme.neutral500,
success: const Color(0xFF166534),
// Status and feedback colors - Enhanced visibility successBackground: const Color(0xFFECFDF3),
success: Color(0xFF166534), error: const Color(0xFFB91C1C),
successBackground: Color(0xFFECFDF3), errorBackground: const Color(0xFFFEE2E2),
error: Color(0xFFB91C1C), warning: const Color(0xFF92400E),
errorBackground: Color(0xFFFEE2E2), warningBackground: const Color(0xFFFEF3C7),
warning: Color(0xFF92400E), info: const Color(0xFF1D4ED8),
warningBackground: Color(0xFFFEF3C7), infoBackground: const Color(0xFFDBEAFE),
info: Color(0xFF1D4ED8),
infoBackground: Color(0xFFDBEAFE),
// Navigation and UI element colors - Enhanced contrast
dividerColor: AppTheme.neutral100, dividerColor: AppTheme.neutral100,
navigationBackground: AppTheme.neutral50, navigationBackground: AppTheme.neutral50,
navigationSelected: AppTheme.brandPrimary, navigationSelected: lightTone.primary,
navigationUnselected: AppTheme.neutral600, navigationUnselected: AppTheme.neutral600,
navigationSelectedBackground: Color(0xFFE0E7FF), navigationSelectedBackground: _surfaceTint(
lightTone.primary,
// Loading and animation colors - Enhanced visibility AppTheme.neutral50,
shimmerBase: Color(0xFFF3F4F6), 0.16,
),
shimmerBase: const Color(0xFFF3F4F6),
shimmerHighlight: AppTheme.neutral50, shimmerHighlight: AppTheme.neutral50,
loadingIndicator: AppTheme.brandPrimary, loadingIndicator: lightTone.primary,
// Text colors - Enhanced hierarchy textPrimary: const Color(0xFF1C1C1C),
textPrimary: Color(0xFF1C1C1C), textSecondary: const Color(0xFF3A3F3E),
textSecondary: Color(0xFF3A3F3E),
textTertiary: AppTheme.neutral500, textTertiary: AppTheme.neutral500,
textInverse: AppTheme.neutral50, textInverse: AppTheme.neutral50,
textDisabled: AppTheme.neutral400, textDisabled: AppTheme.neutral400,
iconPrimary: const Color(0xFF1C1C1C),
// Icon colors - Enhanced visibility iconSecondary: const Color(0xFF666C6A),
iconPrimary: Color(0xFF1C1C1C),
iconSecondary: Color(0xFF666C6A),
iconDisabled: AppTheme.neutral400, iconDisabled: AppTheme.neutral400,
iconInverse: AppTheme.neutral50, iconInverse: AppTheme.neutral50,
// Typography styles
headingLarge: TextStyle( headingLarge: TextStyle(
fontSize: AppTypography.displaySmall, fontSize: AppTypography.displaySmall,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: Color(0xFF111827), // Better contrast color: const Color(0xFF111827),
height: 1.2, height: 1.2,
), ),
headingMedium: TextStyle( headingMedium: TextStyle(
fontSize: AppTypography.headlineLarge, fontSize: AppTypography.headlineLarge,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: Color(0xFF111827), // Better contrast color: const Color(0xFF111827),
height: 1.3, height: 1.3,
), ),
headingSmall: TextStyle( headingSmall: TextStyle(
fontSize: AppTypography.headlineSmall, fontSize: AppTypography.headlineSmall,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: Color(0xFF111827), // Better contrast color: const Color(0xFF111827),
height: 1.4, height: 1.4,
), ),
bodyLarge: TextStyle( bodyLarge: TextStyle(
fontSize: AppTypography.bodyLarge, fontSize: AppTypography.bodyLarge,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
color: Color(0xFF111827), // Better contrast color: const Color(0xFF111827),
height: 1.5, height: 1.5,
), ),
bodyMedium: TextStyle( bodyMedium: TextStyle(
fontSize: AppTypography.bodyMedium, fontSize: AppTypography.bodyMedium,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
color: Color(0xFF374151), // Better contrast color: const Color(0xFF111827),
height: 1.5, height: 1.5,
), ),
bodySmall: TextStyle( bodySmall: TextStyle(
fontSize: AppTypography.bodySmall, fontSize: AppTypography.bodySmall,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
color: Color(0xFF6B7280), // Better contrast color: AppTheme.neutral500,
height: 1.4, height: 1.4,
), ),
caption: TextStyle( caption: TextStyle(
fontSize: AppTypography.labelMedium, fontSize: AppTypography.labelMedium,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: AppTheme.neutral500, color: AppTheme.neutral400,
height: 1.3, height: 1.3,
letterSpacing: 0.5, letterSpacing: 0.5,
), ),
label: TextStyle( label: TextStyle(
fontSize: AppTypography.labelLarge, fontSize: AppTypography.labelLarge,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: Color(0xFF374151), // Better contrast color: const Color(0xFF444948),
height: 1.3, height: 1.3,
), ),
code: TextStyle( code: TextStyle(
fontSize: AppTypography.bodySmall, fontSize: AppTypography.bodySmall,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
color: Color(0xFF374151), // Better contrast color: const Color(0xFF1C1C1C),
height: 1.4, height: 1.4,
fontFamily: AppTypography.monospaceFontFamily, fontFamily: AppTypography.monospaceFontFamily,
), ),
); );
}
static Color _surfaceTint(Color tone, Color surface, double opacity) {
return Color.alphaBlend(tone.withValues(alpha: opacity), surface);
}
} }
/// Extension method to easily access Conduit theme from BuildContext /// Extension method to easily access Conduit theme from BuildContext
extension ConduitThemeContext on BuildContext { extension ConduitThemeContext on BuildContext {
ConduitThemeExtension get conduitTheme { ConduitThemeExtension get conduitTheme {
return Theme.of(this).extension<ConduitThemeExtension>() ?? final theme = Theme.of(this);
ConduitThemeExtension.dark; final extension = theme.extension<ConduitThemeExtension>();
if (extension != null) return extension;
final palette =
theme.extension<AppPaletteThemeExtension>()?.palette ??
AppColorPalettes.auroraViolet;
return theme.brightness == Brightness.dark
? ConduitThemeExtension.darkPalette(palette)
: ConduitThemeExtension.lightPalette(palette);
}
}
extension ConduitPaletteContext on BuildContext {
AppColorPalette get conduitPalette {
return Theme.of(this).extension<AppPaletteThemeExtension>()?.palette ??
AppColorPalettes.auroraViolet;
} }
} }
@@ -931,7 +932,9 @@ class ConduitShadows {
static List<BoxShadow> get glow => [ static List<BoxShadow> get glow => [
BoxShadow( BoxShadow(
color: AppTheme.brandPrimary.withValues(alpha: 0.25), color: AppColorPalettes.auroraViolet.light.primary.withValues(
alpha: 0.25,
),
blurRadius: 20, blurRadius: 20,
offset: const Offset(0, 0), offset: const Offset(0, 0),
spreadRadius: 0, spreadRadius: 0,

View File

@@ -21,7 +21,7 @@ class ConduitLoading {
}) { }) {
return _LoadingIndicator( return _LoadingIndicator(
size: size, size: size,
color: color ?? BrandService.primaryBrandColor, color: color,
message: message, message: message,
type: _LoadingType.primary, type: _LoadingType.primary,
); );
@@ -40,7 +40,7 @@ class ConduitLoading {
color ?? color ??
(context?.conduitTheme.loadingIndicator ?? (context?.conduitTheme.loadingIndicator ??
context?.conduitTheme.buttonPrimary ?? context?.conduitTheme.buttonPrimary ??
AppTheme.brandPrimary), BrandService.primaryBrandColor(context: context)),
message: message, message: message,
type: _LoadingType.inline, type: _LoadingType.inline,
); );
@@ -91,30 +91,35 @@ enum _LoadingType { primary, inline, button }
class _LoadingIndicator extends StatelessWidget { class _LoadingIndicator extends StatelessWidget {
final double size; final double size;
final Color color; final Color? color;
final String? message; final String? message;
final _LoadingType type; final _LoadingType type;
const _LoadingIndicator({ const _LoadingIndicator({
required this.size, required this.size,
required this.color, this.color,
this.message, this.message,
required this.type, required this.type,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final resolvedColor = color ?? context.conduitTheme.loadingIndicator;
Widget indicator; Widget indicator;
if (Platform.isIOS) { if (Platform.isIOS) {
indicator = CupertinoActivityIndicator(color: color, radius: size / 2); indicator = CupertinoActivityIndicator(
color: resolvedColor,
radius: size / 2,
);
} else { } else {
indicator = SizedBox( indicator = SizedBox(
width: size, width: size,
height: size, height: size,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: size / 8, strokeWidth: size / 8,
valueColor: AlwaysStoppedAnimation<Color>(color), valueColor: AlwaysStoppedAnimation<Color>(resolvedColor),
), ),
); );
} }