feat: separate default model for the app

This commit is contained in:
cogwheel0
2025-08-17 17:01:06 +05:30
parent 0e6ce3c3dc
commit f8433037f7
7 changed files with 667 additions and 1131 deletions

View File

@@ -12,6 +12,11 @@ import '../../../shared/utils/ui_utils.dart';
import '../../../shared/widgets/conduit_components.dart';
import '../../../core/providers/app_providers.dart';
import '../../auth/providers/unified_auth_providers.dart';
import '../../../core/services/settings_service.dart';
import '../../../core/models/model.dart';
import 'dart:async';
import 'dart:io';
import '../../chat/views/chat_page_helpers.dart';
/// Profile page (You tab) showing user info and main actions
/// Enhanced with production-grade design tokens for better cohesion
@@ -263,6 +268,8 @@ class ProfilePage extends ConsumerWidget {
padding: EdgeInsets.zero,
child: Column(
children: [
_buildDefaultModelTile(context, ref),
Divider(color: context.conduitTheme.dividerColor, height: 1),
_buildThemeToggleTile(context, ref),
Divider(color: context.conduitTheme.dividerColor, height: 1),
_buildAboutTile(context),
@@ -343,6 +350,141 @@ class ProfilePage extends ConsumerWidget {
);
}
Widget _buildDefaultModelTile(BuildContext context, WidgetRef ref) {
final settings = ref.watch(appSettingsProvider);
final modelsAsync = ref.watch(modelsProvider);
return modelsAsync.when(
data: (models) {
final currentModel = models.firstWhere(
(m) => m.id == settings.defaultModel,
orElse: () => models.isNotEmpty ? models.first : const Model(
id: 'none',
name: 'No models available',
),
);
return ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: Spacing.listItemPadding,
vertical: Spacing.sm,
),
leading: Container(
padding: const EdgeInsets.all(Spacing.sm),
decoration: BoxDecoration(
color: context.conduitTheme.buttonPrimary.withValues(
alpha: Alpha.highlight,
),
borderRadius: BorderRadius.circular(AppBorderRadius.small),
),
child: Icon(
UiUtils.platformIcon(
ios: CupertinoIcons.cube_box,
android: Icons.psychology,
),
color: context.conduitTheme.buttonPrimary,
size: IconSize.medium,
),
),
title: Text(
'Default Model',
style: context.conduitTheme.bodyLarge?.copyWith(
color: context.conduitTheme.textPrimary,
fontWeight: FontWeight.w500,
),
),
subtitle: Text(
settings.defaultModel != null ? currentModel.name : 'Auto-select',
style: context.conduitTheme.bodySmall?.copyWith(
color: context.conduitTheme.textSecondary,
),
),
trailing: Icon(
UiUtils.platformIcon(
ios: CupertinoIcons.chevron_right,
android: Icons.chevron_right,
),
color: context.conduitTheme.iconSecondary,
size: IconSize.small,
),
onTap: () => _showModelSelector(context, ref, models),
);
},
loading: () => ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: Spacing.listItemPadding,
vertical: Spacing.sm,
),
leading: Container(
padding: const EdgeInsets.all(Spacing.sm),
decoration: BoxDecoration(
color: context.conduitTheme.buttonPrimary.withValues(
alpha: Alpha.highlight,
),
borderRadius: BorderRadius.circular(AppBorderRadius.small),
),
child: Icon(
UiUtils.platformIcon(
ios: CupertinoIcons.cube_box,
android: Icons.psychology,
),
color: context.conduitTheme.buttonPrimary,
size: IconSize.medium,
),
),
title: Text(
'Default Model',
style: context.conduitTheme.bodyLarge?.copyWith(
color: context.conduitTheme.textPrimary,
fontWeight: FontWeight.w500,
),
),
subtitle: Text(
'Loading models...',
style: context.conduitTheme.bodySmall?.copyWith(
color: context.conduitTheme.textSecondary,
),
),
),
error: (error, stack) => ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: Spacing.listItemPadding,
vertical: Spacing.sm,
),
leading: Container(
padding: const EdgeInsets.all(Spacing.sm),
decoration: BoxDecoration(
color: context.conduitTheme.error.withValues(
alpha: Alpha.highlight,
),
borderRadius: BorderRadius.circular(AppBorderRadius.small),
),
child: Icon(
UiUtils.platformIcon(
ios: CupertinoIcons.exclamationmark_triangle,
android: Icons.error_outline,
),
color: context.conduitTheme.error,
size: IconSize.medium,
),
),
title: Text(
'Default Model',
style: context.conduitTheme.bodyLarge?.copyWith(
color: context.conduitTheme.textPrimary,
fontWeight: FontWeight.w500,
),
),
subtitle: Text(
'Failed to load models',
style: context.conduitTheme.bodySmall?.copyWith(
color: context.conduitTheme.error,
),
),
),
);
}
Widget _buildThemeToggleTile(BuildContext context, WidgetRef ref) {
final themeMode = ref.watch(themeModeProvider);
final platformBrightness = MediaQuery.platformBrightnessOf(context);
@@ -494,6 +636,22 @@ class ProfilePage extends ConsumerWidget {
}
}
Future<void> _showModelSelector(BuildContext context, WidgetRef ref, List<Model> models) async {
final result = await showModalBottomSheet<String?>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (ctx) => _DefaultModelBottomSheet(
models: models,
currentDefaultModelId: ref.read(appSettingsProvider).defaultModel,
),
);
if (result is String || result == null) {
await ref.read(appSettingsProvider.notifier).setDefaultModel(result);
}
}
void _signOut(BuildContext context, WidgetRef ref) async {
final confirm = await UiUtils.showConfirmationDialog(
context,
@@ -508,3 +666,412 @@ class ProfilePage extends ConsumerWidget {
}
}
}
class _DefaultModelBottomSheet extends ConsumerStatefulWidget {
final List<Model> models;
final String? currentDefaultModelId;
const _DefaultModelBottomSheet({
required this.models,
required this.currentDefaultModelId,
});
@override
ConsumerState<_DefaultModelBottomSheet> createState() => _DefaultModelBottomSheetState();
}
class _DefaultModelBottomSheetState extends ConsumerState<_DefaultModelBottomSheet> {
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
List<Model> _filteredModels = [];
Timer? _searchDebounce;
String? _selectedModelId;
Widget _capabilityChip({required IconData icon, required String label}) {
return Container(
margin: const EdgeInsets.only(right: Spacing.xs),
padding: const EdgeInsets.symmetric(horizontal: Spacing.xs, vertical: 2),
decoration: BoxDecoration(
color: context.conduitTheme.buttonPrimary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(AppBorderRadius.chip),
border: Border.all(
color: context.conduitTheme.buttonPrimary.withValues(alpha: 0.3),
width: BorderWidth.thin,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 12, color: context.conduitTheme.buttonPrimary),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
fontSize: AppTypography.labelSmall,
color: context.conduitTheme.textSecondary,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
@override
void initState() {
super.initState();
_selectedModelId = widget.currentDefaultModelId;
// Add auto-select as first item
_filteredModels = [
const Model(id: 'auto-select', name: 'Auto-select'),
...widget.models,
];
}
@override
void dispose() {
_searchController.dispose();
_searchDebounce?.cancel();
super.dispose();
}
void _filterModels(String query) {
_searchDebounce?.cancel();
_searchDebounce = Timer(const Duration(milliseconds: 160), () {
setState(() {
_searchQuery = query.toLowerCase();
List<Model> allModels = [
const Model(id: 'auto-select', name: 'Auto-select'),
...widget.models,
];
if (_searchQuery.isNotEmpty) {
_filteredModels = allModels.where((model) {
return model.name.toLowerCase().contains(_searchQuery) ||
model.id.toLowerCase().contains(_searchQuery);
}).toList();
} else {
_filteredModels = allModels;
}
});
});
}
@override
Widget build(BuildContext context) {
return DraggableScrollableSheet(
initialChildSize: 0.75,
maxChildSize: 0.92,
minChildSize: 0.45,
builder: (context, scrollController) {
return Container(
decoration: BoxDecoration(
color: context.conduitTheme.surfaceBackground,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(AppBorderRadius.bottomSheet),
),
border: Border.all(
color: context.conduitTheme.dividerColor,
width: BorderWidth.regular,
),
boxShadow: ConduitShadows.modal,
),
child: SafeArea(
top: false,
bottom: true,
child: Padding(
padding: const EdgeInsets.all(Spacing.bottomSheetPadding),
child: Column(
children: [
// Handle bar
Container(
margin: const EdgeInsets.only(
top: Spacing.sm,
bottom: Spacing.md,
),
width: Spacing.xxl,
height: Spacing.xs,
decoration: BoxDecoration(
color: context.conduitTheme.dividerColor,
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
),
),
// Header
Padding(
padding: const EdgeInsets.only(bottom: Spacing.md),
child: Row(
children: [
Expanded(
child: Text(
'Default Model',
style: context.conduitTheme.headingMedium?.copyWith(
color: context.conduitTheme.textPrimary,
fontWeight: FontWeight.w600,
),
),
),
TextButton(
onPressed: () => Navigator.pop(context, _selectedModelId == 'auto-select' ? null : _selectedModelId),
child: Text(
'Save',
style: context.conduitTheme.bodyMedium?.copyWith(
color: context.conduitTheme.buttonPrimary,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
// Search field
Padding(
padding: const EdgeInsets.only(bottom: Spacing.md),
child: TextField(
controller: _searchController,
style: TextStyle(color: context.conduitTheme.textPrimary),
decoration: InputDecoration(
hintText: 'Search...',
hintStyle: TextStyle(
color: context.conduitTheme.inputPlaceholder,
),
prefixIcon: Icon(
Platform.isIOS ? CupertinoIcons.search : Icons.search,
color: context.conduitTheme.iconSecondary,
),
filled: true,
fillColor: context.conduitTheme.inputBackground,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
borderSide: BorderSide(
color: context.conduitTheme.inputBorder,
width: 1,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
borderSide: BorderSide(
color: context.conduitTheme.buttonPrimary,
width: 1,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: Spacing.md,
vertical: Spacing.md,
),
),
onChanged: _filterModels,
),
),
const SizedBox(height: Spacing.sm),
// Models list
Expanded(
child: Scrollbar(
controller: scrollController,
child: _filteredModels.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Platform.isIOS
? CupertinoIcons.search_circle
: Icons.search_off,
size: 48,
color: context.conduitTheme.iconSecondary,
),
const SizedBox(height: Spacing.md),
Text(
'No results',
style: TextStyle(
color: context.conduitTheme.textSecondary,
fontSize: AppTypography.bodyLarge,
),
),
],
),
)
: ListView.builder(
controller: scrollController,
padding: EdgeInsets.zero,
itemCount: _filteredModels.length,
itemBuilder: (context, index) {
final model = _filteredModels[index];
final isAutoSelect = model.id == 'auto-select';
final isSelected = isAutoSelect
? _selectedModelId == null || _selectedModelId == 'auto-select'
: _selectedModelId == model.id;
return _buildModelListTile(
model: model,
isSelected: isSelected,
isAutoSelect: isAutoSelect,
onTap: () {
setState(() {
_selectedModelId = isAutoSelect ? 'auto-select' : model.id;
});
},
);
},
),
),
),
],
),
),
),
);
},
);
}
bool _modelSupportsReasoning(Model model) {
final params = model.supportedParameters ?? const [];
return params.any((p) => p.toLowerCase().contains('reasoning'));
}
Widget _buildModelListTile({
required Model model,
required bool isSelected,
required bool isAutoSelect,
required VoidCallback onTap,
}) {
return PressableScale(
onTap: onTap,
borderRadius: BorderRadius.circular(AppBorderRadius.md),
child: Container(
margin: const EdgeInsets.only(bottom: Spacing.md),
decoration: BoxDecoration(
gradient: isSelected
? LinearGradient(
colors: [
context.conduitTheme.buttonPrimary.withValues(alpha: 0.2),
context.conduitTheme.buttonPrimary.withValues(alpha: 0.1),
],
)
: null,
color: isSelected
? null
: context.conduitTheme.surfaceBackground.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(AppBorderRadius.md),
border: Border.all(
color: isSelected
? context.conduitTheme.buttonPrimary.withValues(alpha: 0.5)
: context.conduitTheme.dividerColor,
width: BorderWidth.regular,
),
boxShadow: isSelected ? ConduitShadows.card : null,
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.md,
vertical: Spacing.sm,
),
child: Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: context.conduitTheme.buttonPrimary.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(AppBorderRadius.md),
),
child: Icon(
isAutoSelect
? (Platform.isIOS ? CupertinoIcons.wand_stars : Icons.auto_awesome)
: (Platform.isIOS ? CupertinoIcons.cube : Icons.psychology),
color: context.conduitTheme.buttonPrimary,
size: 16,
),
),
const SizedBox(width: Spacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isAutoSelect ? 'Auto-select' : model.name,
style: TextStyle(
color: context.conduitTheme.textPrimary,
fontWeight: FontWeight.w600,
fontSize: AppTypography.bodyMedium,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (isAutoSelect) ...[
const SizedBox(height: Spacing.xs),
Text(
'Let the app choose the best model',
style: TextStyle(
fontSize: AppTypography.bodySmall,
color: context.conduitTheme.textSecondary,
fontWeight: FontWeight.w400,
),
),
] else ...[
const SizedBox(height: Spacing.xs),
Row(
children: [
if (model.isMultimodal)
_capabilityChip(
icon: Platform.isIOS
? CupertinoIcons.photo
: Icons.image,
label: 'Multimodal',
),
if (_modelSupportsReasoning(model))
_capabilityChip(
icon: Platform.isIOS
? CupertinoIcons.lightbulb
: Icons.psychology_alt,
label: 'Reasoning',
),
],
),
],
],
),
),
const SizedBox(width: Spacing.md),
AnimatedOpacity(
opacity: isSelected ? 1 : 0.6,
duration: AnimationDuration.fast,
child: Container(
padding: const EdgeInsets.all(Spacing.xxs),
decoration: BoxDecoration(
color: isSelected
? context.conduitTheme.buttonPrimary
: context.conduitTheme.surfaceBackground,
borderRadius: BorderRadius.circular(AppBorderRadius.md),
border: Border.all(
color: isSelected
? context.conduitTheme.buttonPrimary.withValues(alpha: 0.6)
: context.conduitTheme.dividerColor,
),
),
child: Icon(
isSelected
? (Platform.isIOS ? CupertinoIcons.check_mark : Icons.check)
: (Platform.isIOS ? CupertinoIcons.add : Icons.add),
color: isSelected
? context.conduitTheme.textInverse
: context.conduitTheme.iconSecondary,
size: 14,
),
),
),
],
),
),
),
).animate().fadeIn(duration: AnimationDuration.microInteraction);
}
}

View File

@@ -1,278 +0,0 @@
import 'package:flutter/material.dart';
import '../../../shared/theme/theme_extensions.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../shared/widgets/conduit_components.dart';
import '../../../core/services/settings_service.dart';
import '../../../core/services/enhanced_accessibility_service.dart';
import '../../../core/services/platform_service.dart';
/// Accessibility settings page with WCAG 2.2 AA compliance controls
class AccessibilitySettingsPage extends ConsumerWidget {
const AccessibilitySettingsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(appSettingsProvider);
return Scaffold(
backgroundColor: context.conduitTheme.surfaceBackground,
appBar: PlatformService.createPlatformAppBar(
title: 'Accessibility',
backgroundColor: context.conduitTheme.surfaceBackground,
foregroundColor: context.conduitTheme.textPrimary,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(Spacing.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader(context, 'Motion & Animation'),
const SizedBox(height: Spacing.sm),
// Reduce Motion Toggle
ConduitCard(
child: EnhancedAccessibilityService.createAccessibleSwitch(
value: settings.reduceMotion,
onChanged: (value) {
ref.read(appSettingsProvider.notifier).setReduceMotion(value);
EnhancedAccessibilityService.announceSuccess(
value
? 'Reduced motion enabled'
: 'Reduced motion disabled',
);
},
label: 'Reduce Motion',
description:
'Minimize animations and transitions for better focus and reduced vestibular disturbance',
),
),
const SizedBox(height: Spacing.sm),
// Animation Speed Slider
if (!settings.reduceMotion) ...[
ConduitCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Animation Speed',
style: TextStyle(
color: context.conduitTheme.textPrimary,
fontSize: AppTypography.bodyLarge,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: Spacing.sm),
Text(
'Adjust the speed of animations and transitions',
style: TextStyle(
color: context.conduitTheme.textSecondary,
fontSize: AppTypography.labelLarge,
),
),
const SizedBox(height: Spacing.md),
EnhancedAccessibilityService.createAccessibleSlider(
value: settings.animationSpeed,
onChanged: (value) {
ref
.read(appSettingsProvider.notifier)
.setAnimationSpeed(value);
},
label: 'Animation speed',
min: 0.5,
max: 2.0,
divisions: 6,
valueFormatter: (value) {
if (value < 0.75) return 'Slow';
if (value < 1.25) return 'Normal';
return 'Fast';
},
),
],
),
),
const SizedBox(height: Spacing.sm),
],
const SizedBox(height: Spacing.lg),
_buildSectionHeader(context, 'Visual & Text'),
const SizedBox(height: Spacing.sm),
// Large Text Toggle
ConduitCard(
child: EnhancedAccessibilityService.createAccessibleSwitch(
value: settings.largeText,
onChanged: (value) {
ref.read(appSettingsProvider.notifier).setLargeText(value);
EnhancedAccessibilityService.announceSuccess(
value ? 'Large text enabled' : 'Large text disabled',
);
},
label: 'Large Text',
description:
'Increase text size throughout the app for better readability',
),
),
const SizedBox(height: Spacing.sm),
// High Contrast Toggle
ConduitCard(
child: EnhancedAccessibilityService.createAccessibleSwitch(
value: settings.highContrast,
onChanged: (value) {
ref.read(appSettingsProvider.notifier).setHighContrast(value);
EnhancedAccessibilityService.announceSuccess(
value ? 'High contrast enabled' : 'High contrast disabled',
);
},
label: 'High Contrast',
description:
'Increase contrast between text and background colors',
),
),
const SizedBox(height: Spacing.lg),
_buildSectionHeader(context, 'Interaction'),
const SizedBox(height: Spacing.sm),
// Haptic Feedback Toggle
ConduitCard(
child: EnhancedAccessibilityService.createAccessibleSwitch(
value: settings.hapticFeedback,
onChanged: (value) {
ref
.read(appSettingsProvider.notifier)
.setHapticFeedback(value);
if (value) {
PlatformService.hapticFeedback(type: HapticType.success);
}
EnhancedAccessibilityService.announceSuccess(
value
? 'Haptic feedback enabled'
: 'Haptic feedback disabled',
);
},
label: 'Haptic Feedback',
description:
'Feel vibrations when interacting with buttons and controls',
),
),
const SizedBox(height: Spacing.lg),
_buildSectionHeader(context, 'System Integration'),
const SizedBox(height: Spacing.sm),
// System Settings Info Card
ConduitCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.info_outline,
color: context.conduitTheme.buttonPrimary,
size: IconSize.md,
),
const SizedBox(width: Spacing.sm),
Text(
'System Settings',
style: TextStyle(
color: context.conduitTheme.textPrimary,
fontSize: AppTypography.bodyLarge,
fontWeight: FontWeight.w500,
),
),
],
),
const SizedBox(height: Spacing.sm),
Text(
'Conduit automatically respects your device\'s accessibility settings, including:',
style: TextStyle(
color: context.conduitTheme.textSecondary,
fontSize: AppTypography.labelLarge,
),
),
const SizedBox(height: Spacing.sm),
...[
'• Reduce Motion (iOS/Android)',
'• VoiceOver/TalkBack screen readers',
'• Dynamic Type/Font scale',
'• Color inversion and filters',
].map(
(item) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
item,
style: TextStyle(
color: context.conduitTheme.textSecondary,
fontSize: AppTypography.labelLarge,
),
),
),
),
],
),
),
const SizedBox(height: Spacing.lg),
// Reset to Defaults Button
ConduitButton(
text: 'Reset to Defaults',
onPressed: () => _showResetDialog(context, ref),
isSecondary: true,
width: double.infinity,
),
const SizedBox(height: Spacing.xl),
],
),
),
);
}
Widget _buildSectionHeader(BuildContext context, String title) {
return EnhancedAccessibilityService.createAccessibleText(
title,
style: TextStyle(
color: context.conduitTheme.buttonPrimary,
fontSize: AppTypography.headlineSmall,
fontWeight: FontWeight.w600,
),
isHeader: true,
);
}
Future<void> _showResetDialog(BuildContext context, WidgetRef ref) async {
final confirmed = await PlatformService.showPlatformAlert(
context: context,
title: 'Reset Accessibility Settings',
content:
'This will reset all accessibility preferences to their default values. Are you sure?',
confirmText: 'Reset',
cancelText: 'Cancel',
isDestructive: true,
);
if (confirmed == true) {
await ref.read(appSettingsProvider.notifier).resetToDefaults();
EnhancedAccessibilityService.announceSuccess(
'Accessibility settings reset to defaults',
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Accessibility settings reset to defaults'),
backgroundColor: context.conduitTheme.buttonPrimary,
behavior: SnackBarBehavior.floating,
),
);
}
}
}
}

View File

@@ -1,810 +0,0 @@
import 'package:flutter/material.dart';
import '../../../shared/theme/theme_extensions.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'dart:io' show Platform;
import '../../../core/widgets/error_boundary.dart';
import '../../../core/services/navigation_service.dart';
import '../../../shared/widgets/themed_dialogs.dart';
import '../../../core/services/focus_management_service.dart';
import '../../../shared/widgets/improved_loading_states.dart';
import '../../../shared/widgets/conduit_components.dart';
import '../../../core/models/user_settings.dart';
import '../../../core/providers/app_providers.dart';
import '../../../shared/utils/platform_utils.dart';
enum ThemeVariant { conduit }
// Settings search provider
final settingsSearchQueryProvider = StateProvider<String>((ref) => '');
// Setting item model
class SettingItem {
final String id;
final String title;
final String? subtitle;
final IconData icon;
final String category;
final List<String> searchTerms;
final VoidCallback? onTap;
final Widget? trailing;
SettingItem({
required this.id,
required this.title,
this.subtitle,
required this.icon,
required this.category,
required this.searchTerms,
this.onTap,
this.trailing,
});
bool matchesSearch(String query) {
final lowerQuery = query.toLowerCase();
return title.toLowerCase().contains(lowerQuery) ||
(subtitle?.toLowerCase().contains(lowerQuery) ?? false) ||
category.toLowerCase().contains(lowerQuery) ||
searchTerms.any((term) => term.toLowerCase().contains(lowerQuery));
}
}
class SearchableSettingsPage extends ConsumerStatefulWidget {
const SearchableSettingsPage({super.key});
@override
ConsumerState<SearchableSettingsPage> createState() =>
_SearchableSettingsPageState();
}
class _SearchableSettingsPageState
extends ConsumerState<SearchableSettingsPage> {
final TextEditingController _searchController = TextEditingController();
late FocusNode _searchFocusNode;
bool _isSearching = false;
@override
void initState() {
super.initState();
_searchFocusNode = FocusManagementService.registerFocusNode(
'settings_search',
debugLabel: 'Settings Search Field',
);
}
@override
void dispose() {
_searchController.dispose();
FocusManagementService.disposeFocusNode('settings_search');
super.dispose();
}
List<SettingItem> _buildSettingItems(BuildContext context, WidgetRef ref) {
final themeMode = ref.watch(themeModeProvider);
// Single Conduit theme variant in this refactor; kept provider for future use
final userSettingsAsync = ref.watch(userSettingsProvider);
final userSettings = userSettingsAsync.when(
data: (data) => data,
loading: () => null,
error: (_, _) => null,
);
return [
// Profile & Account
SettingItem(
id: 'profile',
title: 'Profile',
subtitle: 'Manage your account details',
icon: Platform.isIOS
? CupertinoIcons.person_circle
: Icons.account_circle,
category: 'Profile & Account',
searchTerms: ['account', 'user', 'name', 'email', 'avatar'],
onTap: () => _navigateToProfile(context),
),
SettingItem(
id: 'server',
title: 'Server Connection',
subtitle: 'Manage Open WebUI servers',
icon: Platform.isIOS ? CupertinoIcons.cloud : Icons.cloud,
category: 'Profile & Account',
searchTerms: ['server', 'connection', 'api', 'host', 'url'],
onTap: () => _navigateToServerSettings(context),
),
SettingItem(
id: 'sign-out',
title: 'Sign Out',
subtitle: 'Sign out of your account',
icon: Platform.isIOS ? CupertinoIcons.square_arrow_right : Icons.logout,
category: 'Profile & Account',
searchTerms: ['logout', 'signout', 'exit'],
onTap: () => _handleSignOut(context, ref),
),
// Appearance
SettingItem(
id: 'theme',
title: 'Theme',
subtitle: 'Choose light or dark theme',
icon: Platform.isIOS ? CupertinoIcons.moon_circle : Icons.dark_mode,
category: 'Appearance',
searchTerms: ['dark', 'light', 'mode', 'appearance', 'color'],
trailing: _buildThemeSelector(ref, themeMode),
),
// Removed variant switching; Conduit brand theme is the single source of truth
SettingItem(
id: 'text-size',
title: 'Text Size',
subtitle: 'Adjust font size for better readability',
icon: Platform.isIOS
? CupertinoIcons.textformat_size
: Icons.text_fields,
category: 'Appearance',
searchTerms: ['font', 'size', 'text', 'readability', 'accessibility'],
onTap: () => _showTextSizeDialog(context),
),
// Chat & AI
SettingItem(
id: 'stream-responses',
title: 'Stream Responses',
subtitle: 'See responses as they\'re generated',
icon: Platform.isIOS ? CupertinoIcons.bolt : Icons.flash_on,
category: 'Chat & AI',
searchTerms: ['stream', 'real-time', 'live', 'responses'],
trailing: PlatformUtils.createSwitch(
value: userSettings?.streamResponses ?? true,
onChanged: (value) => _updateSetting(ref, 'streamResponses', value),
),
),
SettingItem(
id: 'save-conversations',
title: 'Save Conversations',
subtitle: 'Keep chat history between sessions',
icon: Platform.isIOS ? CupertinoIcons.archivebox : Icons.save,
category: 'Chat & AI',
searchTerms: ['save', 'history', 'conversations', 'chat', 'archive'],
trailing: PlatformUtils.createSwitch(
value: userSettings?.saveConversations ?? true,
onChanged: (value) => _updateSetting(ref, 'saveConversations', value),
),
),
SettingItem(
id: 'web-search',
title: 'Web Search',
subtitle: 'Allow AI to search the web for information',
icon: Platform.isIOS ? CupertinoIcons.globe : Icons.public,
category: 'Chat & AI',
searchTerms: ['web', 'search', 'internet', 'browse', 'online'],
trailing: Consumer(
builder: (context, ref, child) {
final settings = ref.watch(userSettingsProvider);
return settings.when(
data: (userSettings) => PlatformUtils.createSwitch(
value: userSettings.webSearchEnabled,
onChanged: (value) =>
_updateSetting(ref, 'webSearchEnabled', value),
),
loading: () =>
const ImprovedLoadingState(message: 'Loading setting...'),
error: (error, stackTrace) => PlatformUtils.createSwitch(
value: false,
onChanged: (value) =>
_updateSetting(ref, 'webSearchEnabled', value),
),
);
},
),
),
SettingItem(
id: 'model-selection',
title: 'Default Model',
subtitle: 'Choose your preferred AI model',
icon: Platform.isIOS ? CupertinoIcons.cube : Icons.psychology,
category: 'Chat & AI',
searchTerms: ['model', 'ai', 'gpt', 'conduit', 'llm'],
onTap: () => _showModelSelector(context),
),
// Privacy & Security
SettingItem(
id: 'clear-history',
title: 'Clear Chat History',
subtitle: 'Delete all conversations',
icon: Platform.isIOS ? CupertinoIcons.trash : Icons.delete_outline,
category: 'Privacy & Security',
searchTerms: ['clear', 'delete', 'history', 'privacy', 'remove'],
onTap: () => _showClearHistoryDialog(context, ref),
),
SettingItem(
id: 'export-data',
title: 'Export Data',
subtitle: 'Download your conversations',
icon: Platform.isIOS
? CupertinoIcons.square_arrow_down
: Icons.download,
category: 'Privacy & Security',
searchTerms: ['export', 'download', 'backup', 'data'],
onTap: () => _handleExportData(context),
),
// Accessibility
SettingItem(
id: 'reduce-motion',
title: 'Reduce Motion',
subtitle: 'Minimize animations',
icon: Platform.isIOS ? CupertinoIcons.slowmo : Icons.animation,
category: 'Accessibility',
searchTerms: ['motion', 'animation', 'reduce', 'accessibility'],
trailing: Consumer(
builder: (context, ref, child) {
final settings = ref.watch(userSettingsProvider);
return settings.when(
data: (userSettings) => PlatformUtils.createSwitch(
value: userSettings.reduceMotion,
onChanged: (value) =>
_updateSetting(ref, 'reduceMotion', value),
),
loading: () =>
const ImprovedLoadingState(message: 'Loading setting...'),
error: (error, stackTrace) => PlatformUtils.createSwitch(
value: false,
onChanged: (value) =>
_updateSetting(ref, 'reduceMotion', value),
),
);
},
),
),
SettingItem(
id: 'haptic-feedback',
title: 'Haptic Feedback',
subtitle: 'Vibration feedback for actions',
icon: Platform.isIOS ? CupertinoIcons.hand_draw : Icons.vibration,
category: 'Accessibility',
searchTerms: ['haptic', 'vibration', 'feedback', 'touch'],
trailing: Consumer(
builder: (context, ref, child) {
final settings = ref.watch(userSettingsProvider);
return settings.when(
data: (userSettings) => PlatformUtils.createSwitch(
value: userSettings.hapticFeedback,
onChanged: (value) =>
_updateSetting(ref, 'hapticFeedback', value),
),
loading: () =>
const ImprovedLoadingState(message: 'Loading setting...'),
error: (error, stackTrace) => PlatformUtils.createSwitch(
value: true,
onChanged: (value) =>
_updateSetting(ref, 'hapticFeedback', value),
),
);
},
),
),
// About
SettingItem(
id: 'version',
title: 'App Version',
subtitle: 'Conduit v1.0.0',
icon: Platform.isIOS ? CupertinoIcons.info_circle : Icons.info_outline,
category: 'About',
searchTerms: ['version', 'about', 'info', 'conduit'],
onTap: () => _showAboutDialog(context),
),
SettingItem(
id: 'help',
title: 'Help & Support',
subtitle: 'Get assistance and report issues',
icon: Platform.isIOS
? CupertinoIcons.question_circle
: Icons.help_outline,
category: 'About',
searchTerms: ['help', 'support', 'assistance', 'contact'],
onTap: () => _navigateToHelp(context),
),
];
}
List<SettingItem> _getFilteredSettings(BuildContext context, WidgetRef ref) {
final searchQuery = ref.watch(settingsSearchQueryProvider);
final allSettings = _buildSettingItems(context, ref);
if (searchQuery.isEmpty) {
return allSettings;
}
return allSettings
.where((item) => item.matchesSearch(searchQuery))
.toList();
}
Map<String, List<SettingItem>> _groupSettingsByCategory(
List<SettingItem> settings,
) {
final grouped = <String, List<SettingItem>>{};
for (final setting in settings) {
grouped.putIfAbsent(setting.category, () => []).add(setting);
}
return grouped;
}
@override
Widget build(BuildContext context) {
final filteredSettings = _getFilteredSettings(context, ref);
final groupedSettings = _groupSettingsByCategory(filteredSettings);
final categories = groupedSettings.keys.toList()..sort();
return ErrorBoundary(
child: Scaffold(
backgroundColor: context.conduitTheme.surfaceBackground,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: Elevation.none,
title: _isSearching
? _buildSearchBar()
: Text(
'Settings',
style: TextStyle(
color: context.conduitTheme.textPrimary,
fontSize: AppTypography.headlineMedium,
fontWeight: FontWeight.w600,
),
),
leading: ConduitIconButton(
icon: Platform.isIOS ? CupertinoIcons.back : Icons.arrow_back,
onPressed: () {
if (_isSearching) {
setState(() {
_isSearching = false;
_searchController.clear();
ref.read(settingsSearchQueryProvider.notifier).state = '';
});
} else {
NavigationService.goBack();
}
},
),
actions: [
if (!_isSearching)
ConduitIconButton(
icon: Platform.isIOS ? CupertinoIcons.search : Icons.search,
onPressed: () {
setState(() {
_isSearching = true;
});
_searchFocusNode.requestFocus();
},
),
const SizedBox(width: Spacing.sm),
],
),
body: SafeArea(
top: false,
child: filteredSettings.isEmpty
? _buildEmptySearchResults()
: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: categories.length,
itemBuilder: (context, index) {
final category = categories[index];
final items = groupedSettings[category]!;
return _buildCategorySection(category, items);
},
),
),
), // Added closing parenthesis for ErrorBoundary
);
}
Widget _buildSearchBar() {
return TextField(
controller: _searchController,
focusNode: _searchFocusNode,
style: TextStyle(
color: context.conduitTheme.textPrimary,
fontSize: AppTypography.bodyLarge,
),
decoration: InputDecoration(
hintText: 'Search settings...',
hintStyle: TextStyle(
color: context.conduitTheme.inputPlaceholder,
fontSize: AppTypography.bodyLarge,
),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
),
onChanged: (value) {
ref.read(settingsSearchQueryProvider.notifier).state = value;
},
);
}
Widget _buildEmptySearchResults() {
return ImprovedEmptyState(
title: 'No settings found',
subtitle: 'Try a different search term',
icon: Platform.isIOS ? CupertinoIcons.search : Icons.search_off,
showAnimation: true,
);
}
Widget _buildCategorySection(String category, List<SettingItem> items) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(
Spacing.md,
Spacing.md,
Spacing.md,
Spacing.sm,
),
child: Text(
category,
style: TextStyle(
color: context.conduitTheme.textSecondary,
fontSize: AppTypography.bodySmall,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
),
),
Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: context.conduitTheme.surfaceBackground,
borderRadius: BorderRadius.circular(AppBorderRadius.md),
border: Border.all(
color: context.conduitTheme.dividerColor,
width: 1,
),
),
child: Column(
children: items.asMap().entries.map((entry) {
final index = entry.key;
final item = entry.value;
final isLast = index == items.length - 1;
return Column(
children: [
_buildSettingTile(item),
if (!isLast)
Divider(
height: 1,
color: context.conduitTheme.dividerColor,
indent: 56,
),
],
);
}).toList(),
),
),
],
);
}
Widget _buildSettingTile(SettingItem item) {
final searchQuery = ref.watch(settingsSearchQueryProvider);
return Material(
color: Colors.transparent,
child: InkWell(
onTap: item.onTap,
borderRadius: BorderRadius.circular(AppBorderRadius.md),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: context.conduitTheme.surfaceBackground,
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
),
child: Icon(
item.icon,
color: context.conduitTheme.iconSecondary,
size: IconSize.md,
),
),
const SizedBox(width: Spacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_highlightSearchText(item.title, searchQuery),
if (item.subtitle != null) ...[
const SizedBox(height: Spacing.xxs),
_highlightSearchText(
item.subtitle!,
searchQuery,
style: TextStyle(
color: context.conduitTheme.textSecondary,
fontSize: AppTypography.bodySmall,
),
),
],
],
),
),
if (item.trailing != null) ...[
const SizedBox(width: Spacing.sm),
item.trailing!,
] else if (item.onTap != null)
Icon(
Platform.isIOS
? CupertinoIcons.chevron_forward
: Icons.chevron_right,
color: context.conduitTheme.iconSecondary,
size: IconSize.md,
),
],
),
),
),
);
}
Widget _highlightSearchText(String text, String query, {TextStyle? style}) {
if (query.isEmpty) {
return Text(
text,
style:
style ??
TextStyle(
color: context.conduitTheme.textPrimary,
fontSize: AppTypography.bodyLarge,
fontWeight: FontWeight.w500,
),
);
}
final lowerText = text.toLowerCase();
final lowerQuery = query.toLowerCase();
final index = lowerText.indexOf(lowerQuery);
if (index == -1) {
return Text(text, style: style);
}
final before = text.substring(0, index);
final match = text.substring(index, index + query.length);
final after = text.substring(index + query.length);
return RichText(
text: TextSpan(
style:
style ??
TextStyle(
color: context.conduitTheme.textPrimary,
fontSize: AppTypography.bodyLarge,
fontWeight: FontWeight.w500,
),
children: [
TextSpan(text: before),
TextSpan(
text: match,
style: TextStyle(
backgroundColor: context.conduitTheme.buttonPrimary.withValues(
alpha: 0.3,
),
fontWeight: FontWeight.w600,
),
),
TextSpan(text: after),
],
),
);
}
Widget _buildThemeSelector(WidgetRef ref, ThemeMode themeMode) {
return CupertinoSlidingSegmentedControl<ThemeMode>(
groupValue: themeMode,
children: const {
ThemeMode.light: Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Text(
'Light',
style: TextStyle(fontSize: AppTypography.bodySmall),
),
),
ThemeMode.dark: Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Text(
'Dark',
style: TextStyle(fontSize: AppTypography.bodySmall),
),
),
ThemeMode.system: Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Text(
'Auto',
style: TextStyle(fontSize: AppTypography.bodySmall),
),
),
},
onValueChanged: (value) {
if (value != null) {
ref.read(themeModeProvider.notifier).setTheme(value);
}
},
);
}
// Theme variant state removed; single Conduit theme in use
void _updateSetting(WidgetRef ref, String key, dynamic value) async {
try {
final currentSettings = await ref.read(userSettingsProvider.future);
// Create updated settings based on the key
UserSettings updatedSettings;
switch (key) {
case 'webSearchEnabled':
updatedSettings = currentSettings.copyWith(
webSearchEnabled: value as bool,
);
break;
case 'reduceMotion':
updatedSettings = currentSettings.copyWith(
reduceMotion: value as bool,
);
break;
case 'hapticFeedback':
updatedSettings = currentSettings.copyWith(
hapticFeedback: value as bool,
);
break;
case 'streamResponses':
updatedSettings = currentSettings.copyWith(
streamResponses: value as bool,
);
break;
case 'saveConversations':
updatedSettings = currentSettings.copyWith(
saveConversations: value as bool,
);
break;
case 'showReadReceipts':
updatedSettings = currentSettings.copyWith(
showReadReceipts: value as bool,
);
break;
case 'enableNotifications':
updatedSettings = currentSettings.copyWith(
enableNotifications: value as bool,
);
break;
case 'enableSounds':
updatedSettings = currentSettings.copyWith(
enableSounds: value as bool,
);
break;
case 'shareUsageData':
updatedSettings = currentSettings.copyWith(
shareUsageData: value as bool,
);
break;
case 'temperature':
updatedSettings = currentSettings.copyWith(
temperature: value as double,
);
break;
case 'maxTokens':
updatedSettings = currentSettings.copyWith(maxTokens: value as int);
break;
case 'fontSize':
updatedSettings = currentSettings.copyWith(fontSize: value as double);
break;
case 'theme':
updatedSettings = currentSettings.copyWith(theme: value as String);
break;
case 'density':
updatedSettings = currentSettings.copyWith(density: value as String);
break;
case 'language':
updatedSettings = currentSettings.copyWith(language: value as String);
break;
default:
// Handle custom settings
final customSettings = Map<String, dynamic>.from(
currentSettings.customSettings,
);
customSettings[key] = value;
updatedSettings = currentSettings.copyWith(
customSettings: customSettings,
);
}
// Update settings on server
final api = ref.read(apiServiceProvider);
if (api != null) {
await api.updateUserSettings(updatedSettings.toJson());
// Invalidate the provider to refresh the UI
ref.invalidate(userSettingsProvider);
// Show success message
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Setting updated'),
duration: const Duration(seconds: 2),
),
);
}
}
} catch (e) {
// Show error message
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to update setting: $e'),
backgroundColor: context.conduitTheme.error,
),
);
}
}
}
void _navigateToProfile(BuildContext context) {
// TODO: Navigate to profile page
}
void _navigateToServerSettings(BuildContext context) {
NavigationService.navigateTo('/server-connection');
}
void _handleSignOut(BuildContext context, WidgetRef ref) {
// ignore: unawaited_futures
ThemedDialogs.confirm(
context,
title: 'Sign Out',
message: 'Are you sure you want to sign out?',
confirmText: 'Sign Out',
).then((confirmed) {
if (confirmed) {
// TODO: Implement proper logout functionality when auth service is available
// ref.read(authServiceProvider.notifier).logout();
NavigationService.navigateTo('/login', clearStack: true);
}
});
}
void _showTextSizeDialog(BuildContext context) {
// TODO: Implement text size adjustment dialog
}
void _showModelSelector(BuildContext context) {
// TODO: Implement model selection dialog
}
void _showClearHistoryDialog(BuildContext context, WidgetRef ref) {
// TODO: Implement clear history dialog
}
void _handleExportData(BuildContext context) {
// TODO: Implement data export
}
void _showAboutDialog(BuildContext context) {
showAboutDialog(
context: context,
applicationName: 'Conduit',
applicationVersion: '1.0.0',
applicationLegalese: '© 2024 Conduit Team',
);
}
void _navigateToHelp(BuildContext context) {
// TODO: Navigate to help page
}
}