diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index df6c596..a1560cf 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -589,7 +589,7 @@ class ApiService { } // Fallback to chat.messages (list format) if history is missing or empty - if ((messagesList == null || (messagesList is List && messagesList.isEmpty)) && + if (((messagesList?.isEmpty ?? true)) && chatObject['messages'] != null) { messagesList = chatObject['messages'] as List; debugPrint( diff --git a/lib/core/services/settings_service.dart b/lib/core/services/settings_service.dart index e112991..aeda7c5 100644 --- a/lib/core/services/settings_service.dart +++ b/lib/core/services/settings_service.dart @@ -12,6 +12,9 @@ class SettingsService { static const String _largeTextKey = 'large_text'; static const String _darkModeKey = 'dark_mode'; static const String _defaultModelKey = 'default_model'; + // Model name formatting + static const String _omitProviderInModelNameKey = + 'omit_provider_in_model_name'; // Voice input settings static const String _voiceLocaleKey = 'voice_locale_id'; static const String _voiceHoldToTalkKey = 'voice_hold_to_talk'; @@ -105,6 +108,17 @@ class SettingsService { } } + /// Whether to omit the provider prefix when displaying model names + static Future getOmitProviderInModelName() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_omitProviderInModelNameKey) ?? true; // default: omit + } + + static Future setOmitProviderInModelName(bool value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_omitProviderInModelNameKey, value); + } + /// Load all settings static Future loadSettings() async { return AppSettings( @@ -115,6 +129,7 @@ class SettingsService { largeText: await getLargeText(), darkMode: await getDarkMode(), defaultModel: await getDefaultModel(), + omitProviderInModelName: await getOmitProviderInModelName(), voiceLocaleId: await getVoiceLocaleId(), voiceHoldToTalk: await getVoiceHoldToTalk(), voiceAutoSendFinal: await getVoiceAutoSendFinal(), @@ -131,6 +146,7 @@ class SettingsService { setLargeText(settings.largeText), setDarkMode(settings.darkMode), setDefaultModel(settings.defaultModel), + setOmitProviderInModelName(settings.omitProviderInModelName), setVoiceLocaleId(settings.voiceLocaleId), setVoiceHoldToTalk(settings.voiceHoldToTalk), setVoiceAutoSendFinal(settings.voiceAutoSendFinal), @@ -221,6 +237,7 @@ class AppSettings { final bool largeText; final bool darkMode; final String? defaultModel; + final bool omitProviderInModelName; final String? voiceLocaleId; final bool voiceHoldToTalk; final bool voiceAutoSendFinal; @@ -233,6 +250,7 @@ class AppSettings { this.largeText = false, this.darkMode = true, this.defaultModel, + this.omitProviderInModelName = true, this.voiceLocaleId, this.voiceHoldToTalk = false, this.voiceAutoSendFinal = false, @@ -246,6 +264,7 @@ class AppSettings { bool? largeText, bool? darkMode, Object? defaultModel = const _DefaultValue(), + bool? omitProviderInModelName, Object? voiceLocaleId = const _DefaultValue(), bool? voiceHoldToTalk, bool? voiceAutoSendFinal, @@ -258,6 +277,7 @@ class AppSettings { largeText: largeText ?? this.largeText, darkMode: darkMode ?? this.darkMode, defaultModel: defaultModel is _DefaultValue ? this.defaultModel : defaultModel as String?, + omitProviderInModelName: omitProviderInModelName ?? this.omitProviderInModelName, voiceLocaleId: voiceLocaleId is _DefaultValue ? this.voiceLocaleId : voiceLocaleId as String?, voiceHoldToTalk: voiceHoldToTalk ?? this.voiceHoldToTalk, voiceAutoSendFinal: voiceAutoSendFinal ?? this.voiceAutoSendFinal, @@ -275,6 +295,7 @@ class AppSettings { other.largeText == largeText && other.darkMode == darkMode && other.defaultModel == defaultModel && + other.omitProviderInModelName == omitProviderInModelName && other.voiceLocaleId == voiceLocaleId && other.voiceHoldToTalk == voiceHoldToTalk && other.voiceAutoSendFinal == voiceAutoSendFinal; @@ -290,6 +311,7 @@ class AppSettings { largeText, darkMode, defaultModel, + omitProviderInModelName, voiceLocaleId, voiceHoldToTalk, voiceAutoSendFinal, @@ -348,6 +370,11 @@ class AppSettingsNotifier extends StateNotifier { await SettingsService.setDefaultModel(modelId); } + Future setOmitProviderInModelName(bool value) async { + state = state.copyWith(omitProviderInModelName: value); + await SettingsService.setOmitProviderInModelName(value); + } + Future setVoiceLocaleId(String? localeId) async { state = state.copyWith(voiceLocaleId: localeId); await SettingsService.setVoiceLocaleId(localeId); diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index b4cd3ea..c66d564 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -35,6 +35,7 @@ import '../../onboarding/views/onboarding_sheet.dart'; import '../../../shared/widgets/sheet_handle.dart'; import '../../../shared/widgets/measure_size.dart'; import '../../../shared/widgets/conduit_components.dart'; +import '../../../shared/widgets/middle_ellipsis_text.dart'; import '../../../core/services/settings_service.dart'; // Removed unused PlatformUtils import import '../../../core/services/platform_service.dart' as ps; @@ -56,16 +57,21 @@ class _ChatPageState extends ConsumerState { bool _isDeactivated = false; double _inputHeight = 0; // dynamic input height to position scroll button - String _formatModelDisplayName(String name) { + String _formatModelDisplayName( + String name, { + required bool omitProvider, + }) { var display = name.trim(); - // Prefer the segment after the last '/' - if (display.contains('/')) { - display = display.split('/').last.trim(); - } - // If an org prefix like 'OpenAI: gpt-4o' exists, use the part after ':' - if (display.contains(':')) { - final parts = display.split(':'); - display = parts.last.trim(); + if (omitProvider) { + // Prefer the segment after the last '/' + if (display.contains('/')) { + display = display.split('/').last.trim(); + } + // If an org prefix like 'OpenAI: gpt-4o' exists, use the part after ':' + if (display.contains(':')) { + final parts = display.split(':'); + display = parts.last.trim(); + } } return display; } @@ -698,6 +704,8 @@ class _ChatPageState extends ConsumerState { String? displayModelName; final rawModel = message.model; if (rawModel != null && rawModel.isNotEmpty) { + final omitProvider = + ref.watch(appSettingsProvider).omitProviderInModelName; final modelsAsync = ref.watch(modelsProvider); if (modelsAsync.hasValue) { final models = modelsAsync.value!; @@ -706,14 +714,23 @@ class _ChatPageState extends ConsumerState { final match = models.firstWhere( (m) => m.id == rawModel || m.name == rawModel, ); - displayModelName = match.name; + displayModelName = _formatModelDisplayName( + match.name, + omitProvider: omitProvider, + ); } catch (_) { // As a fallback, format the raw value to be more readable - displayModelName = _formatModelDisplayName(rawModel); + displayModelName = _formatModelDisplayName( + rawModel, + omitProvider: omitProvider, + ); } } else { // Models not loaded yet; format raw value for readability - displayModelName = _formatModelDisplayName(rawModel); + displayModelName = _formatModelDisplayName( + rawModel, + omitProvider: omitProvider, + ); } } @@ -1133,17 +1150,27 @@ class _ChatPageState extends ConsumerState { ), const SizedBox(width: Spacing.xs), Flexible( - child: Text( - _formatModelDisplayName(selectedModel.name), - style: AppTypography.headlineSmallStyle - .copyWith( + child: Builder( + builder: (context) { + final omitProvider = ref + .watch(appSettingsProvider) + .omitProviderInModelName; + final label = _formatModelDisplayName( + selectedModel.name, + omitProvider: omitProvider, + ); + return MiddleEllipsisText( + label, + style: AppTypography.headlineSmallStyle + .copyWith( color: context.conduitTheme.textPrimary, fontWeight: FontWeight.w600, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, + textAlign: TextAlign.center, + semanticsLabel: label, + ); + }, ), ), const SizedBox(width: Spacing.xs), diff --git a/lib/features/profile/views/app_customization_page.dart b/lib/features/profile/views/app_customization_page.dart new file mode 100644 index 0000000..0851241 --- /dev/null +++ b/lib/features/profile/views/app_customization_page.dart @@ -0,0 +1,110 @@ +import 'dart:io' show Platform; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/services/settings_service.dart'; +import '../../../shared/theme/theme_extensions.dart'; +import '../../../shared/widgets/conduit_components.dart'; +import '../../../shared/utils/ui_utils.dart'; + +class AppCustomizationPage extends ConsumerWidget { + const AppCustomizationPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(appSettingsProvider); + + return Scaffold( + backgroundColor: context.conduitTheme.surfaceBackground, + appBar: AppBar( + backgroundColor: context.conduitTheme.surfaceBackground, + elevation: Elevation.none, + leading: IconButton( + icon: Icon( + UiUtils.platformIcon( + ios: CupertinoIcons.back, + android: Icons.arrow_back, + ), + color: context.conduitTheme.textPrimary, + ), + onPressed: () => Navigator.of(context).maybePop(), + tooltip: 'Back', + ), + title: Text( + 'App Customization', + style: AppTypography.headlineSmallStyle.copyWith( + color: context.conduitTheme.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + centerTitle: true, + ), + body: Padding( + padding: const EdgeInsets.all(Spacing.pagePadding), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Display', + style: context.conduitTheme.headingSmall?.copyWith( + color: context.conduitTheme.textPrimary, + ), + ), + const SizedBox(height: Spacing.md), + ConduitCard( + padding: EdgeInsets.zero, + child: Column( + children: [ + SwitchListTile.adaptive( + contentPadding: const EdgeInsets.symmetric( + horizontal: Spacing.listItemPadding, + vertical: Spacing.sm, + ), + // Use platform defaults for switch colors to match theme + value: settings.omitProviderInModelName, + title: Text( + 'Hide provider in model names', + style: context.conduitTheme.bodyLarge?.copyWith( + color: context.conduitTheme.textPrimary, + fontWeight: FontWeight.w500, + ), + ), + subtitle: Text( + 'Show names like "gpt-4o" instead of "openai/gpt-4o".', + style: context.conduitTheme.bodySmall?.copyWith( + color: context.conduitTheme.textSecondary, + ), + ), + onChanged: (v) { + ref + .read(appSettingsProvider.notifier) + .setOmitProviderInModelName(v); + }, + secondary: Container( + padding: const EdgeInsets.all(Spacing.sm), + decoration: BoxDecoration( + color: context.conduitTheme.buttonPrimary + .withValues(alpha: Alpha.highlight), + borderRadius: + BorderRadius.circular(AppBorderRadius.small), + ), + child: Icon( + Platform.isIOS + ? CupertinoIcons.textformat + : Icons.text_fields, + color: context.conduitTheme.buttonPrimary, + size: IconSize.medium, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/profile/views/profile_page.dart b/lib/features/profile/views/profile_page.dart index 82571af..c659428 100644 --- a/lib/features/profile/views/profile_page.dart +++ b/lib/features/profile/views/profile_page.dart @@ -20,6 +20,7 @@ import '../../../core/models/model.dart'; import 'dart:async'; import 'dart:io'; import '../../chat/views/chat_page_helpers.dart'; +import 'app_customization_page.dart'; /// Profile page (You tab) showing user info and main actions /// Enhanced with production-grade design tokens for better cohesion @@ -232,6 +233,22 @@ class ProfilePage extends ConsumerWidget { Divider(color: context.conduitTheme.dividerColor, height: 1), _buildLanguageTile(context, ref), Divider(color: context.conduitTheme.dividerColor, height: 1), + _buildAccountOption( + icon: UiUtils.platformIcon( + ios: CupertinoIcons.slider_horizontal_3, + android: Icons.tune, + ), + title: 'App Customization', + subtitle: 'Personalize how names and UI display', + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const AppCustomizationPage(), + ), + ); + }, + ), + Divider(color: context.conduitTheme.dividerColor, height: 1), _buildAboutTile(context), Divider(color: context.conduitTheme.dividerColor, height: 1), _buildAccountOption( diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e9fd505..b64df28 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -231,8 +231,8 @@ "pinned": "Pinned", "folders": "Folders", "archived": "Archived", - "appLanguage": "App language", - "darkMode": "Dark mode", + "appLanguage": "App Language", + "darkMode": "Dark Mode", "webSearch": "Web Search", "webSearchDescription": "Search the web and cite sources in replies.", "imageGeneration": "Image Generation", diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 96f3c80..c6739fb 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -596,10 +596,10 @@ class AppLocalizationsEn extends AppLocalizations { String get archived => 'Archived'; @override - String get appLanguage => 'App language'; + String get appLanguage => 'App Language'; @override - String get darkMode => 'Dark mode'; + String get darkMode => 'Dark Mode'; @override String get webSearch => 'Web Search'; diff --git a/lib/shared/widgets/middle_ellipsis_text.dart b/lib/shared/widgets/middle_ellipsis_text.dart new file mode 100644 index 0000000..28dfb71 --- /dev/null +++ b/lib/shared/widgets/middle_ellipsis_text.dart @@ -0,0 +1,119 @@ +import 'package:flutter/widgets.dart'; + +/// A single-line text widget that truncates the middle of long strings +/// with an ellipsis (e.g., "prefix…suffix") so both ends remain visible. +class MiddleEllipsisText extends StatelessWidget { + final String text; + final TextStyle? style; + final TextAlign? textAlign; + final String ellipsis; + final String? semanticsLabel; + + const MiddleEllipsisText( + this.text, { + super.key, + this.style, + this.textAlign, + this.ellipsis = '…', + this.semanticsLabel, + }); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final TextStyle effectiveStyle = + DefaultTextStyle.of(context).style.merge(style); + final TextDirection direction = Directionality.of(context); + final double maxWidth = constraints.maxWidth; + + // Measure full text width first. + final fullSpan = TextSpan(text: text, style: effectiveStyle); + final fullPainter = TextPainter( + text: fullSpan, + textDirection: direction, + maxLines: 1, + )..layout(minWidth: 0, maxWidth: double.infinity); + + if (fullPainter.width <= maxWidth) { + return Text( + text, + style: effectiveStyle, + maxLines: 1, + overflow: TextOverflow.clip, + textAlign: textAlign, + semanticsLabel: semanticsLabel, + ); + } + + // Pre-measure ellipsis width (used implicitly during search). + final ellipsisSpan = TextSpan(text: ellipsis, style: effectiveStyle); + final ellipsisPainter = TextPainter( + text: ellipsisSpan, + textDirection: direction, + maxLines: 1, + )..layout(minWidth: 0, maxWidth: double.infinity); + final double _ = ellipsisPainter.width; // hint width; not used directly + + // Binary search the maximum number of visible characters (k), split + // between start and end. For a given k, we use ceil(k/2) from start + // and floor(k/2) from end. + int low = 0; + int high = text.length; // exclusive upper bound in practice + int bestK = 0; + String bestStart = ''; + String bestEnd = ''; + + while (low <= high) { + final int k = (low + high) >> 1; // candidate visible char count + final int leftCount = (k + 1) >> 1; // ceil(k/2) + final int rightCount = k - leftCount; // floor(k/2) + + final String start = text.substring(0, leftCount); + final String end = rightCount == 0 + ? '' + : text.substring(text.length - rightCount); + + final trialSpan = + TextSpan(text: '$start$ellipsis$end', style: effectiveStyle); + final trialPainter = TextPainter( + text: trialSpan, + textDirection: direction, + maxLines: 1, + )..layout(minWidth: 0, maxWidth: double.infinity); + + if (trialPainter.width <= maxWidth) { + bestK = k; + bestStart = start; + bestEnd = end; + low = k + 1; // try to fit more + } else { + high = k - 1; // need fewer characters + } + } + + if (bestK == 0) { + return Text( + ellipsis, + style: effectiveStyle, + maxLines: 1, + overflow: TextOverflow.clip, + textAlign: textAlign, + semanticsLabel: semanticsLabel ?? text, + ); + } + + final String display = '$bestStart$ellipsis$bestEnd'; + return Text( + display, + style: effectiveStyle, + maxLines: 1, + overflow: TextOverflow.clip, + textAlign: textAlign, + semanticsLabel: semanticsLabel ?? text, + ); + }, + ); + } +} +