feat: separate default model for the app
This commit is contained in:
@@ -31,6 +31,9 @@ sealed class UserSettings with _$UserSettings {
|
|||||||
@Default(false) bool reduceMotion,
|
@Default(false) bool reduceMotion,
|
||||||
@Default(true) bool hapticFeedback,
|
@Default(true) bool hapticFeedback,
|
||||||
|
|
||||||
|
// Model preferences
|
||||||
|
String? defaultModelId,
|
||||||
|
|
||||||
// Advanced settings
|
// Advanced settings
|
||||||
@Default({}) Map<String, dynamic> customSettings,
|
@Default({}) Map<String, dynamic> customSettings,
|
||||||
}) = _UserSettings;
|
}) = _UserSettings;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import '../models/folder.dart';
|
|||||||
import '../models/user_settings.dart';
|
import '../models/user_settings.dart';
|
||||||
import '../models/file_info.dart';
|
import '../models/file_info.dart';
|
||||||
import '../models/knowledge_base.dart';
|
import '../models/knowledge_base.dart';
|
||||||
|
import '../services/settings_service.dart';
|
||||||
import '../services/optimized_storage_service.dart';
|
import '../services/optimized_storage_service.dart';
|
||||||
|
|
||||||
// Storage providers
|
// Storage providers
|
||||||
@@ -500,8 +501,10 @@ final loadConversationProvider = FutureProvider.family<Conversation, String>((
|
|||||||
return fullConversation;
|
return fullConversation;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Provider to automatically load and set the default model from OpenWebUI
|
// Provider to automatically load and set the default model from user settings or OpenWebUI
|
||||||
final defaultModelProvider = FutureProvider<Model?>((ref) async {
|
final defaultModelProvider = FutureProvider<Model?>((ref) async {
|
||||||
|
// Watch user settings to refresh when default model changes
|
||||||
|
ref.watch(appSettingsProvider);
|
||||||
// Handle reviewer mode first
|
// Handle reviewer mode first
|
||||||
final reviewerMode = ref.watch(reviewerModeProvider);
|
final reviewerMode = ref.watch(reviewerModeProvider);
|
||||||
if (reviewerMode) {
|
if (reviewerMode) {
|
||||||
@@ -562,45 +565,71 @@ final defaultModelProvider = FutureProvider<Model?>((ref) async {
|
|||||||
|
|
||||||
Model? selectedModel;
|
Model? selectedModel;
|
||||||
|
|
||||||
// Try to get the server's default model configuration
|
// First check user's preferred default model
|
||||||
try {
|
final userSettings = ref.read(appSettingsProvider);
|
||||||
final defaultModelId = await api.getDefaultModel();
|
final userDefaultModelId = userSettings.defaultModel;
|
||||||
|
|
||||||
|
if (userDefaultModelId != null && userDefaultModelId.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
selectedModel = models.firstWhere(
|
||||||
|
(model) =>
|
||||||
|
model.id == userDefaultModelId ||
|
||||||
|
model.name == userDefaultModelId ||
|
||||||
|
model.id.contains(userDefaultModelId) ||
|
||||||
|
model.name.contains(userDefaultModelId),
|
||||||
|
);
|
||||||
|
foundation.debugPrint(
|
||||||
|
'DEBUG: Found user default model: ${selectedModel.name}',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
foundation.debugPrint(
|
||||||
|
'DEBUG: User default model "$userDefaultModelId" not found in available models',
|
||||||
|
);
|
||||||
|
selectedModel = null; // Will fall back to server default or first model
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (defaultModelId != null && defaultModelId.isNotEmpty) {
|
// If no user default or user default not found, try server's default model
|
||||||
// Find the model that matches the default model ID
|
if (selectedModel == null) {
|
||||||
try {
|
try {
|
||||||
selectedModel = models.firstWhere(
|
final defaultModelId = await api.getDefaultModel();
|
||||||
(model) =>
|
|
||||||
model.id == defaultModelId ||
|
if (defaultModelId != null && defaultModelId.isNotEmpty) {
|
||||||
model.name == defaultModelId ||
|
// Find the model that matches the default model ID
|
||||||
model.id.contains(defaultModelId) ||
|
try {
|
||||||
model.name.contains(defaultModelId),
|
selectedModel = models.firstWhere(
|
||||||
);
|
(model) =>
|
||||||
foundation.debugPrint(
|
model.id == defaultModelId ||
|
||||||
'DEBUG: Found server default model: ${selectedModel.name}',
|
model.name == defaultModelId ||
|
||||||
);
|
model.id.contains(defaultModelId) ||
|
||||||
} catch (e) {
|
model.name.contains(defaultModelId),
|
||||||
foundation.debugPrint(
|
);
|
||||||
'DEBUG: Default model "$defaultModelId" not found in available models',
|
foundation.debugPrint(
|
||||||
);
|
'DEBUG: Found server default model: ${selectedModel.name}',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
foundation.debugPrint(
|
||||||
|
'DEBUG: Server default model "$defaultModelId" not found in available models',
|
||||||
|
);
|
||||||
|
selectedModel = models.first;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No server default, use first available model
|
||||||
selectedModel = models.first;
|
selectedModel = models.first;
|
||||||
|
foundation.debugPrint(
|
||||||
|
'DEBUG: No server default model, using first available: ${selectedModel.name}',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} catch (apiError) {
|
||||||
// No server default, use first available model
|
foundation.debugPrint(
|
||||||
|
'DEBUG: Failed to get default model from server: $apiError',
|
||||||
|
);
|
||||||
|
// Use first available model as fallback
|
||||||
selectedModel = models.first;
|
selectedModel = models.first;
|
||||||
foundation.debugPrint(
|
foundation.debugPrint(
|
||||||
'DEBUG: No server default model, using first available: ${selectedModel.name}',
|
'DEBUG: Using first available model as fallback: ${selectedModel.name}',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (apiError) {
|
|
||||||
foundation.debugPrint(
|
|
||||||
'DEBUG: Failed to get default model from server: $apiError',
|
|
||||||
);
|
|
||||||
// Use first available model as fallback
|
|
||||||
selectedModel = models.first;
|
|
||||||
foundation.debugPrint(
|
|
||||||
'DEBUG: Using first available model as fallback: ${selectedModel.name}',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Defer the state update to avoid modifying providers during initialization
|
// Defer the state update to avoid modifying providers during initialization
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import 'package:flutter/services.dart';
|
|||||||
// ThemedDialogs handles theming; no direct use of extensions here
|
// ThemedDialogs handles theming; no direct use of extensions here
|
||||||
import '../../features/chat/views/chat_page.dart';
|
import '../../features/chat/views/chat_page.dart';
|
||||||
import '../../features/auth/views/connect_signin_page.dart';
|
import '../../features/auth/views/connect_signin_page.dart';
|
||||||
import '../../features/settings/views/searchable_settings_page.dart';
|
|
||||||
import '../../features/profile/views/profile_page.dart';
|
import '../../features/profile/views/profile_page.dart';
|
||||||
import '../../features/files/views/files_page.dart';
|
import '../../features/files/views/files_page.dart';
|
||||||
|
|
||||||
@@ -148,10 +148,7 @@ class NavigationService {
|
|||||||
return navigateTo(Routes.login, clearStack: true);
|
return navigateTo(Routes.login, clearStack: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Navigate to settings
|
|
||||||
static Future<void> navigateToSettings() {
|
|
||||||
return navigateTo(Routes.settings);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Navigate to profile
|
/// Navigate to profile
|
||||||
static Future<void> navigateToProfile() {
|
static Future<void> navigateToProfile() {
|
||||||
@@ -202,9 +199,7 @@ class NavigationService {
|
|||||||
page = const ConnectAndSignInPage();
|
page = const ConnectAndSignInPage();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case Routes.settings:
|
|
||||||
page = const SearchableSettingsPage();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case Routes.profile:
|
case Routes.profile:
|
||||||
page = const ProfilePage();
|
page = const ProfilePage();
|
||||||
@@ -244,7 +239,7 @@ class NavigationService {
|
|||||||
class Routes {
|
class Routes {
|
||||||
static const String chat = '/chat';
|
static const String chat = '/chat';
|
||||||
static const String login = '/login';
|
static const String login = '/login';
|
||||||
static const String settings = '/settings';
|
|
||||||
static const String profile = '/profile';
|
static const String profile = '/profile';
|
||||||
static const String serverConnection = '/server-connection';
|
static const String serverConnection = '/server-connection';
|
||||||
static const String search = '/search';
|
static const String search = '/search';
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class SettingsService {
|
|||||||
static const String _highContrastKey = 'high_contrast';
|
static const String _highContrastKey = 'high_contrast';
|
||||||
static const String _largeTextKey = 'large_text';
|
static const String _largeTextKey = 'large_text';
|
||||||
static const String _darkModeKey = 'dark_mode';
|
static const String _darkModeKey = 'dark_mode';
|
||||||
|
static const String _defaultModelKey = 'default_model';
|
||||||
|
|
||||||
/// Get reduced motion preference
|
/// Get reduced motion preference
|
||||||
static Future<bool> getReduceMotion() async {
|
static Future<bool> getReduceMotion() async {
|
||||||
@@ -84,6 +85,22 @@ class SettingsService {
|
|||||||
await prefs.setBool(_darkModeKey, value);
|
await prefs.setBool(_darkModeKey, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get default model preference
|
||||||
|
static Future<String?> getDefaultModel() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getString(_defaultModelKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set default model preference
|
||||||
|
static Future<void> setDefaultModel(String? modelId) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
if (modelId != null) {
|
||||||
|
await prefs.setString(_defaultModelKey, modelId);
|
||||||
|
} else {
|
||||||
|
await prefs.remove(_defaultModelKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Load all settings
|
/// Load all settings
|
||||||
static Future<AppSettings> loadSettings() async {
|
static Future<AppSettings> loadSettings() async {
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
@@ -93,6 +110,7 @@ class SettingsService {
|
|||||||
highContrast: await getHighContrast(),
|
highContrast: await getHighContrast(),
|
||||||
largeText: await getLargeText(),
|
largeText: await getLargeText(),
|
||||||
darkMode: await getDarkMode(),
|
darkMode: await getDarkMode(),
|
||||||
|
defaultModel: await getDefaultModel(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +123,7 @@ class SettingsService {
|
|||||||
setHighContrast(settings.highContrast),
|
setHighContrast(settings.highContrast),
|
||||||
setLargeText(settings.largeText),
|
setLargeText(settings.largeText),
|
||||||
setDarkMode(settings.darkMode),
|
setDarkMode(settings.darkMode),
|
||||||
|
setDefaultModel(settings.defaultModel),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +170,7 @@ class AppSettings {
|
|||||||
final bool highContrast;
|
final bool highContrast;
|
||||||
final bool largeText;
|
final bool largeText;
|
||||||
final bool darkMode;
|
final bool darkMode;
|
||||||
|
final String? defaultModel;
|
||||||
|
|
||||||
const AppSettings({
|
const AppSettings({
|
||||||
this.reduceMotion = false,
|
this.reduceMotion = false,
|
||||||
@@ -159,6 +179,7 @@ class AppSettings {
|
|||||||
this.highContrast = false,
|
this.highContrast = false,
|
||||||
this.largeText = false,
|
this.largeText = false,
|
||||||
this.darkMode = true,
|
this.darkMode = true,
|
||||||
|
this.defaultModel,
|
||||||
});
|
});
|
||||||
|
|
||||||
AppSettings copyWith({
|
AppSettings copyWith({
|
||||||
@@ -168,6 +189,7 @@ class AppSettings {
|
|||||||
bool? highContrast,
|
bool? highContrast,
|
||||||
bool? largeText,
|
bool? largeText,
|
||||||
bool? darkMode,
|
bool? darkMode,
|
||||||
|
String? defaultModel,
|
||||||
}) {
|
}) {
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
reduceMotion: reduceMotion ?? this.reduceMotion,
|
reduceMotion: reduceMotion ?? this.reduceMotion,
|
||||||
@@ -176,6 +198,7 @@ class AppSettings {
|
|||||||
highContrast: highContrast ?? this.highContrast,
|
highContrast: highContrast ?? this.highContrast,
|
||||||
largeText: largeText ?? this.largeText,
|
largeText: largeText ?? this.largeText,
|
||||||
darkMode: darkMode ?? this.darkMode,
|
darkMode: darkMode ?? this.darkMode,
|
||||||
|
defaultModel: defaultModel ?? this.defaultModel,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +211,8 @@ class AppSettings {
|
|||||||
other.hapticFeedback == hapticFeedback &&
|
other.hapticFeedback == hapticFeedback &&
|
||||||
other.highContrast == highContrast &&
|
other.highContrast == highContrast &&
|
||||||
other.largeText == largeText &&
|
other.largeText == largeText &&
|
||||||
other.darkMode == darkMode;
|
other.darkMode == darkMode &&
|
||||||
|
other.defaultModel == defaultModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -200,6 +224,7 @@ class AppSettings {
|
|||||||
highContrast,
|
highContrast,
|
||||||
largeText,
|
largeText,
|
||||||
darkMode,
|
darkMode,
|
||||||
|
defaultModel,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -250,6 +275,11 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
|
|||||||
await SettingsService.setDarkMode(value);
|
await SettingsService.setDarkMode(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setDefaultModel(String? modelId) async {
|
||||||
|
state = state.copyWith(defaultModel: modelId);
|
||||||
|
await SettingsService.setDefaultModel(modelId);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> resetToDefaults() async {
|
Future<void> resetToDefaults() async {
|
||||||
const defaultSettings = AppSettings();
|
const defaultSettings = AppSettings();
|
||||||
await SettingsService.saveSettings(defaultSettings);
|
await SettingsService.saveSettings(defaultSettings);
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ import '../../../shared/utils/ui_utils.dart';
|
|||||||
import '../../../shared/widgets/conduit_components.dart';
|
import '../../../shared/widgets/conduit_components.dart';
|
||||||
import '../../../core/providers/app_providers.dart';
|
import '../../../core/providers/app_providers.dart';
|
||||||
import '../../auth/providers/unified_auth_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
|
/// Profile page (You tab) showing user info and main actions
|
||||||
/// Enhanced with production-grade design tokens for better cohesion
|
/// Enhanced with production-grade design tokens for better cohesion
|
||||||
@@ -263,6 +268,8 @@ class ProfilePage extends ConsumerWidget {
|
|||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
_buildDefaultModelTile(context, ref),
|
||||||
|
Divider(color: context.conduitTheme.dividerColor, height: 1),
|
||||||
_buildThemeToggleTile(context, ref),
|
_buildThemeToggleTile(context, ref),
|
||||||
Divider(color: context.conduitTheme.dividerColor, height: 1),
|
Divider(color: context.conduitTheme.dividerColor, height: 1),
|
||||||
_buildAboutTile(context),
|
_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) {
|
Widget _buildThemeToggleTile(BuildContext context, WidgetRef ref) {
|
||||||
final themeMode = ref.watch(themeModeProvider);
|
final themeMode = ref.watch(themeModeProvider);
|
||||||
final platformBrightness = MediaQuery.platformBrightnessOf(context);
|
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 {
|
void _signOut(BuildContext context, WidgetRef ref) async {
|
||||||
final confirm = await UiUtils.showConfirmationDialog(
|
final confirm = await UiUtils.showConfirmationDialog(
|
||||||
context,
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user