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:
@@ -946,8 +946,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
final greetingName = deriveUserDisplayName(user);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(Spacing.lg),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||
|
||||
@@ -115,6 +115,15 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
||||
_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
|
||||
if (oldWidget.modelName != widget.modelName ||
|
||||
oldWidget.modelIconUrl != widget.modelIconUrl) {
|
||||
@@ -505,7 +514,17 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
||||
}
|
||||
|
||||
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() {
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/services/settings_service.dart';
|
||||
import '../../../shared/theme/theme_extensions.dart';
|
||||
import '../../../shared/theme/color_palettes.dart';
|
||||
import '../../tools/providers/tools_providers.dart';
|
||||
import '../../../core/models/tool.dart';
|
||||
import '../../../shared/widgets/conduit_components.dart';
|
||||
@@ -36,6 +37,7 @@ class AppCustomizationPage extends ConsumerWidget {
|
||||
final locale = ref.watch(appLocaleProvider);
|
||||
final currentLanguageCode = locale?.languageCode ?? 'system';
|
||||
final languageLabel = _resolveLanguageLabel(context, currentLanguageCode);
|
||||
final activePalette = ref.watch(appThemePaletteProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: context.conduitTheme.surfaceBackground,
|
||||
@@ -58,6 +60,7 @@ class AppCustomizationPage extends ConsumerWidget {
|
||||
currentLanguageCode,
|
||||
languageLabel,
|
||||
settings,
|
||||
activePalette,
|
||||
),
|
||||
const SizedBox(height: Spacing.sectionGap),
|
||||
_buildQuickPillsSection(context, ref, settings),
|
||||
@@ -110,6 +113,7 @@ class AppCustomizationPage extends ConsumerWidget {
|
||||
String currentLanguageCode,
|
||||
String languageLabel,
|
||||
AppSettings settings,
|
||||
AppColorPalette palette,
|
||||
) {
|
||||
final theme = context.conduitTheme;
|
||||
|
||||
@@ -125,6 +129,8 @@ class AppCustomizationPage extends ConsumerWidget {
|
||||
const SizedBox(height: Spacing.sm),
|
||||
_buildThemeSelector(context, ref, themeMode, themeDescription),
|
||||
const SizedBox(height: Spacing.md),
|
||||
_buildPaletteSelector(context, ref, palette),
|
||||
const SizedBox(height: Spacing.md),
|
||||
_CustomizationTile(
|
||||
leading: _buildIconBadge(
|
||||
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(
|
||||
BuildContext context,
|
||||
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 {
|
||||
const _CustomizationTile({
|
||||
required this.leading,
|
||||
|
||||
Reference in New Issue
Block a user