From 0d175b1e0a1ee5200b7f385dcb884bb7916ec6fa Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Sun, 7 Sep 2025 14:40:20 +0530 Subject: [PATCH] feat: customize quick pills --- lib/core/services/settings_service.dart | 43 +++- lib/core/services/socket_service.dart | 10 +- .../chat/widgets/modern_chat_input.dart | 212 ++++++++++++------ .../profile/views/app_customization_page.dart | 130 +++++++++++ 4 files changed, 320 insertions(+), 75 deletions(-) diff --git a/lib/core/services/settings_service.dart b/lib/core/services/settings_service.dart index 36747ae..df493d1 100644 --- a/lib/core/services/settings_service.dart +++ b/lib/core/services/settings_service.dart @@ -21,6 +21,8 @@ class SettingsService { static const String _voiceAutoSendKey = 'voice_auto_send_final'; // Realtime transport preference static const String _socketTransportModeKey = 'socket_transport_mode'; // 'auto' or 'ws' + // Quick pill visibility selections (max 2) + static const String _quickPillsKey = 'quick_pills'; // StringList of identifiers e.g. ['web','image','tools'] /// Get reduced motion preference static Future getReduceMotion() async { @@ -136,6 +138,7 @@ class SettingsService { voiceHoldToTalk: await getVoiceHoldToTalk(), voiceAutoSendFinal: await getVoiceAutoSendFinal(), socketTransportMode: await getSocketTransportMode(), + quickPills: await getQuickPills(), ); } @@ -154,6 +157,7 @@ class SettingsService { setVoiceHoldToTalk(settings.voiceHoldToTalk), setVoiceAutoSendFinal(settings.voiceAutoSendFinal), setSocketTransportMode(settings.socketTransportMode), + setQuickPills(settings.quickPills), ]); } @@ -204,6 +208,21 @@ class SettingsService { await prefs.setString(_socketTransportModeKey, mode); } + // Quick Pills (visibility) + static Future> getQuickPills() async { + final prefs = await SharedPreferences.getInstance(); + final list = prefs.getStringList(_quickPillsKey); + // Default: none selected + if (list == null) return const []; + // Enforce max 2; accept arbitrary tool IDs plus 'web' and 'image' + return list.take(2).toList(); + } + + static Future setQuickPills(List pills) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList(_quickPillsKey, pills.take(2).toList()); + } + /// Get effective animation duration considering all settings static Duration getEffectiveAnimationDuration( BuildContext context, @@ -258,6 +277,7 @@ class AppSettings { final bool voiceHoldToTalk; final bool voiceAutoSendFinal; final String socketTransportMode; // 'auto' or 'ws' + final List quickPills; // e.g., ['web','image'] const AppSettings({ this.reduceMotion = false, @@ -272,6 +292,7 @@ class AppSettings { this.voiceHoldToTalk = false, this.voiceAutoSendFinal = false, this.socketTransportMode = 'auto', + this.quickPills = const [], }); AppSettings copyWith({ @@ -287,6 +308,7 @@ class AppSettings { bool? voiceHoldToTalk, bool? voiceAutoSendFinal, String? socketTransportMode, + List? quickPills, }) { return AppSettings( reduceMotion: reduceMotion ?? this.reduceMotion, @@ -301,6 +323,7 @@ class AppSettings { voiceHoldToTalk: voiceHoldToTalk ?? this.voiceHoldToTalk, voiceAutoSendFinal: voiceAutoSendFinal ?? this.voiceAutoSendFinal, socketTransportMode: socketTransportMode ?? this.socketTransportMode, + quickPills: quickPills ?? this.quickPills, ); } @@ -318,7 +341,8 @@ class AppSettings { other.omitProviderInModelName == omitProviderInModelName && other.voiceLocaleId == voiceLocaleId && other.voiceHoldToTalk == voiceHoldToTalk && - other.voiceAutoSendFinal == voiceAutoSendFinal; + other.voiceAutoSendFinal == voiceAutoSendFinal && + _listEquals(other.quickPills, quickPills); // socketTransportMode intentionally not included in == to avoid frequent rebuilds } @@ -337,10 +361,20 @@ class AppSettings { voiceHoldToTalk, voiceAutoSendFinal, socketTransportMode, + Object.hashAllUnordered(quickPills), ); } } +bool _listEquals(List a, List b) { + if (identical(a, b)) return true; + if (a.length != b.length) return false; + for (int i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; +} + /// Provider for app settings final appSettingsProvider = StateNotifierProvider( @@ -417,6 +451,13 @@ class AppSettingsNotifier extends StateNotifier { await SettingsService.setSocketTransportMode(mode); } + Future setQuickPills(List pills) async { + // Enforce max 2; accept arbitrary server tool IDs plus built-ins + final filtered = pills.take(2).toList(); + state = state.copyWith(quickPills: filtered); + await SettingsService.setQuickPills(filtered); + } + Future resetToDefaults() async { const defaultSettings = AppSettings(); await SettingsService.saveSettings(defaultSettings); diff --git a/lib/core/services/socket_service.dart b/lib/core/services/socket_service.dart index a413f6f..ef4bc7e 100644 --- a/lib/core/services/socket_service.dart +++ b/lib/core/services/socket_service.dart @@ -26,7 +26,15 @@ class SocketService { _socket?.dispose(); } catch (_) {} - final base = serverConfig.url.replaceFirst(RegExp(r'/+$'), ''); + String base = serverConfig.url.replaceFirst(RegExp(r'/+$'), ''); + // Normalize accidental ":0" ports or invalid port values in stored URL + try { + final u = Uri.parse(base); + if (u.hasPort && u.port == 0) { + // Drop the explicit :0 to fall back to scheme default (80/443) + base = '${u.scheme}://${u.host}${u.path.isEmpty ? '' : u.path}'; + } + } catch (_) {} final path = '/ws/socket.io'; final builder = io.OptionBuilder() diff --git a/lib/features/chat/widgets/modern_chat_input.dart b/lib/features/chat/widgets/modern_chat_input.dart index 06ae2f4..7f53700 100644 --- a/lib/features/chat/widgets/modern_chat_input.dart +++ b/lib/features/chat/widgets/modern_chat_input.dart @@ -13,7 +13,9 @@ import 'dart:math' as math; import '../providers/chat_providers.dart'; import '../../tools/widgets/unified_tools_modal.dart'; import '../../tools/providers/tools_providers.dart'; +import '../../../core/models/tool.dart'; import '../../../core/providers/app_providers.dart'; +import '../../../core/services/settings_service.dart'; import '../../chat/services/voice_input_service.dart'; import '../../../shared/utils/platform_utils.dart'; @@ -372,6 +374,15 @@ class _ModernChatInputState extends ConsumerState final webSearchEnabled = ref.watch(webSearchEnabledProvider); final imageGenEnabled = ref.watch(imageGenerationEnabledProvider); final imageGenAvailable = ref.watch(imageGenerationAvailableProvider); + final selectedQuickPills = + ref.watch(appSettingsProvider.select((s) => s.quickPills)); + final toolsAsync = ref.watch(toolsListProvider); + final List availableTools = toolsAsync.maybeWhen>( + data: (t) => t, + orElse: () => const [], + ); + final bool showWebPill = selectedQuickPills.contains('web'); + final bool showImagePillPref = selectedQuickPills.contains('image'); final voiceAvailableAsync = ref.watch(voiceInputAvailableProvider); final bool voiceAvailable = voiceAvailableAsync.maybeWhen( data: (v) => v, @@ -603,103 +614,158 @@ class _ModernChatInputState extends ConsumerState child: LayoutBuilder( builder: (context, constraints) { final double total = constraints.maxWidth; - const double toolsWidth = TouchTarget.comfortable; - const double gapBeforeTools = Spacing.xs; - final double gapBetweenPills = imageGenAvailable ? Spacing.xs : 0; + final bool showImage = imageGenAvailable && showImagePillPref; + final bool showWeb = showWebPill; + // Tools button is always shown + final double toolsWidth = TouchTarget.comfortable; + final double gapBeforeTools = Spacing.xs; final double availableForPills = math.max(0.0, total - toolsWidth - gapBeforeTools); - // Measure natural widths (text + horizontal padding) + // Compose selected pill entries in order + final List> entries = []; final textStyle = AppTypography.labelStyle; const double horizontalPadding = Spacing.md * 2; - String webLabel = AppLocalizations.of(context)!.web; - final webTp = TextPainter( - text: TextSpan(text: webLabel, style: textStyle), - maxLines: 1, - textDirection: Directionality.of(context), - )..layout(); - final double webNatural = webTp.width + horizontalPadding; - - double imageNatural = 0; - if (imageGenAvailable) { - final imgLabel = AppLocalizations.of(context)!.imageGen; - final imgTp = TextPainter( - text: TextSpan(text: imgLabel, style: textStyle), - maxLines: 1, - textDirection: Directionality.of(context), - )..layout(); - imageNatural = imgTp.width + horizontalPadding; + for (final id in selectedQuickPills) { + if (id == 'web' && showWeb) { + final lbl = AppLocalizations.of(context)!.web; + final tp = TextPainter( + text: TextSpan(text: lbl, style: textStyle), + maxLines: 1, + textDirection: Directionality.of(context), + )..layout(); + entries.add({ + 'id': id, + 'label': lbl, + 'width': tp.width + horizontalPadding, + 'widgetBuilder': () => _buildPillButton( + icon: Platform.isIOS ? CupertinoIcons.search : Icons.search, + label: lbl, + isActive: webSearchEnabled, + onTap: widget.enabled && !_isRecording + ? () { + ref.read(webSearchEnabledProvider.notifier).state = !webSearchEnabled; + } + : null, + ), + }); + } else if (id == 'image' && showImage) { + final lbl = AppLocalizations.of(context)!.imageGen; + final tp = TextPainter( + text: TextSpan(text: lbl, style: textStyle), + maxLines: 1, + textDirection: Directionality.of(context), + )..layout(); + entries.add({ + 'id': id, + 'label': lbl, + 'width': tp.width + horizontalPadding, + 'widgetBuilder': () => _buildPillButton( + icon: Platform.isIOS ? CupertinoIcons.photo : Icons.image, + label: lbl, + isActive: imageGenEnabled, + onTap: widget.enabled && !_isRecording + ? () { + ref + .read(imageGenerationEnabledProvider.notifier) + .state = !imageGenEnabled; + } + : null, + ), + }); + } else { + // Tool ID from server + Tool? tool; + for (final t in availableTools) { + if (t.id == id) { tool = t; break; } + } + if (tool != null) { + final lbl = tool!.name; + final tp = TextPainter( + text: TextSpan(text: lbl, style: textStyle), + maxLines: 1, + textDirection: Directionality.of(context), + )..layout(); + final selectedIds = ref.watch(selectedToolIdsProvider); + final isActive = selectedIds.contains(id); + entries.add({ + 'id': id, + 'label': lbl, + 'width': tp.width + horizontalPadding, + 'widgetBuilder': () => _buildPillButton( + icon: Icons.extension, + label: lbl, + isActive: isActive, + onTap: widget.enabled && !_isRecording + ? () { + final current = List.from( + ref.read(selectedToolIdsProvider)); + if (current.contains(id)) { + current.remove(id); + } else { + current.add(id); + } + ref.read(selectedToolIdsProvider.notifier).state = current; + } + : null, + ), + }); + } + } } - List rowChildren = []; - - Widget webPill = _buildPillButton( - icon: Platform.isIOS - ? CupertinoIcons.search - : Icons.search, - label: webLabel, - isActive: webSearchEnabled, - onTap: widget.enabled && !_isRecording - ? () { - ref.read( - webSearchEnabledProvider.notifier, - ).state = !webSearchEnabled; - } - : null, - ); - - if (!imageGenAvailable) { - if (webNatural <= availableForPills) { - rowChildren.add(webPill); + // Build rowChildren according to measured widths and available space + final List rowChildren = []; + if (entries.isEmpty) { + // no quick pills, will just show tools later + } else if (entries.length == 1) { + final e = entries.first; + final pill = e['widgetBuilder']() as Widget; + final w = (e['width'] as double); + if (w <= availableForPills) { + rowChildren.add(pill); } else { - rowChildren.add(Flexible(fit: FlexFit.loose, child: webPill)); + rowChildren.add(Flexible(fit: FlexFit.loose, child: pill)); } } else { - Widget imagePill = _buildPillButton( - icon: Platform.isIOS - ? CupertinoIcons.photo - : Icons.image, - label: AppLocalizations.of(context)!.imageGen, - isActive: imageGenEnabled, - onTap: widget.enabled && !_isRecording - ? () { - ref.read( - imageGenerationEnabledProvider.notifier, - ).state = !imageGenEnabled; - } - : null, - ); + // up to 2 based on settings enforcement; if more, take first 2 + final e1 = entries[0]; + final e2 = entries[1]; + final w1 = (e1['width'] as double); + final w2 = (e2['width'] as double); + const double gapBetweenPills = Spacing.xs; + final combined = w1 + gapBetweenPills + w2; + final pill1 = e1['widgetBuilder']() as Widget; + final pill2 = e2['widgetBuilder']() as Widget; - final double combined = webNatural + gapBetweenPills + imageNatural; if (combined <= availableForPills) { - // Both fit naturally - rowChildren..add(webPill)..add(const SizedBox(width: Spacing.xs))..add(imagePill); - } else if (webNatural < availableForPills) { - // Keep web natural, let image take remaining rowChildren - ..add(webPill) + ..add(pill1) ..add(const SizedBox(width: Spacing.xs)) - ..add(Flexible(fit: FlexFit.loose, child: imagePill)); - } else if (imageNatural < availableForPills) { - // Keep image natural, let web take remaining + ..add(pill2); + } else if (w1 < availableForPills) { rowChildren - ..add(Flexible(fit: FlexFit.loose, child: webPill)) + ..add(pill1) ..add(const SizedBox(width: Spacing.xs)) - ..add(imagePill); + ..add(Flexible(fit: FlexFit.loose, child: pill2)); + } else if (w2 < availableForPills) { + rowChildren + ..add(Flexible(fit: FlexFit.loose, child: pill1)) + ..add(const SizedBox(width: Spacing.xs)) + ..add(pill2); } else { - // Both too large: apportion space proportional to their natural widths - final int webFlex = math.max(1, webNatural.round()); - final int imgFlex = math.max(1, imageNatural.round()); + final int f1 = math.max(1, w1.round()); + final int f2 = math.max(1, w2.round()); rowChildren - ..add(Flexible(fit: FlexFit.loose, flex: webFlex, child: webPill)) + ..add(Flexible(fit: FlexFit.loose, flex: f1, child: pill1)) ..add(const SizedBox(width: Spacing.xs)) - ..add(Flexible(fit: FlexFit.loose, flex: imgFlex, child: imagePill)); + ..add(Flexible(fit: FlexFit.loose, flex: f2, child: pill2)); } } - // Append tools button at the end + // Append tools button at the end (always visible) rowChildren ..add(const SizedBox(width: Spacing.xs)) ..add(_buildRoundButton( diff --git a/lib/features/profile/views/app_customization_page.dart b/lib/features/profile/views/app_customization_page.dart index 8941468..c7dd129 100644 --- a/lib/features/profile/views/app_customization_page.dart +++ b/lib/features/profile/views/app_customization_page.dart @@ -6,6 +6,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/services/settings_service.dart'; import '../../../shared/theme/theme_extensions.dart'; +import '../../tools/providers/tools_providers.dart'; +import '../../../core/models/tool.dart'; import '../../../shared/widgets/conduit_components.dart'; import '../../../shared/utils/ui_utils.dart'; import '../../../core/providers/app_providers.dart'; @@ -257,6 +259,134 @@ class AppCustomizationPage extends ConsumerWidget { ), ), + const SizedBox(height: Spacing.lg), + // Quick pills (Web / Image Gen) + Text( + AppLocalizations.of(context)!.onboardQuickTitle, + style: context.conduitTheme.headingSmall?.copyWith( + color: context.conduitTheme.textPrimary, + ), + ), + const SizedBox(height: Spacing.md), + Consumer( + builder: (context, ref, _) { + final selectedRaw = ref.watch( + appSettingsProvider.select((s) => s.quickPills), + ); + final toolsAsync = ref.watch(toolsListProvider); + final tools = toolsAsync.maybeWhen( + data: (t) => t, + orElse: () => const [], + ); + final allowed = { + 'web', + 'image', + ...tools.map((t) => t.id), + }; + // Sanitize persisted selection + final selected = + selectedRaw.where((id) => allowed.contains(id)).take(2).toList(); + if (selected.length != selectedRaw.length) { + // Persist sanitized list asynchronously + Future.microtask(() => ref + .read(appSettingsProvider.notifier) + .setQuickPills(selected)); + } + final int selectedCount = selected.length; + + void toggle(String id) async { + final current = List.from(selected); + if (current.contains(id)) { + current.remove(id); + } else { + if (current.length >= 2) return; // enforce max 2 + current.add(id); + } + await ref.read(appSettingsProvider.notifier).setQuickPills(current); + } + + // Build dynamic tool chips list once + final List dynamicToolChips = ref + .watch(toolsListProvider) + .maybeWhen>( + data: (tools) => tools.map((Tool t) { + final isSel = selected.contains(t.id); + final canSelect = selectedCount < 2 || isSel; + return ConduitChip( + label: t.name, + icon: Icons.extension, + isSelected: isSel, + onTap: canSelect ? () => toggle(t.id) : null, + ); + }).toList(), + orElse: () => const [], + ); + + return ConduitCard( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.listItemPadding, + vertical: Spacing.sm, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + AppLocalizations.of(context)!.appCustomizationSubtitle, + style: context.conduitTheme.bodySmall?.copyWith( + color: context.conduitTheme.textSecondary, + ), + ), + ), + TextButton( + onPressed: selected.isEmpty + ? null + : () async { + await ref + .read(appSettingsProvider.notifier) + .setQuickPills(const []); + }, + child: Text(AppLocalizations.of(context)!.clear), + ), + ], + ), + const SizedBox(height: Spacing.sm), + Wrap( + spacing: Spacing.sm, + runSpacing: Spacing.sm, + children: [ + ConduitChip( + label: AppLocalizations.of(context)!.web, + icon: Platform.isIOS + ? CupertinoIcons.search + : Icons.search, + isSelected: selected.contains('web'), + onTap: (selectedCount < 2 || selected.contains('web')) + ? () => toggle('web') + : null, + ), + ConduitChip( + label: AppLocalizations.of(context)!.imageGen, + icon: Platform.isIOS + ? CupertinoIcons.photo + : Icons.image, + isSelected: selected.contains('image'), + onTap: (selectedCount < 2 || selected.contains('image')) + ? () => toggle('image') + : null, + ), + // Dynamic tools from server + ...dynamicToolChips, + ], + ), + ], + ), + ); + }, + ), + const SizedBox(height: Spacing.lg), Text( AppLocalizations.of(context)!.realtime,