feat: customize quick pills

This commit is contained in:
cogwheel0
2025-09-07 14:40:20 +05:30
parent 9cb835861a
commit 0d175b1e0a
4 changed files with 320 additions and 75 deletions

View File

@@ -21,6 +21,8 @@ class SettingsService {
static const String _voiceAutoSendKey = 'voice_auto_send_final'; static const String _voiceAutoSendKey = 'voice_auto_send_final';
// Realtime transport preference // Realtime transport preference
static const String _socketTransportModeKey = 'socket_transport_mode'; // 'auto' or 'ws' 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 /// Get reduced motion preference
static Future<bool> getReduceMotion() async { static Future<bool> getReduceMotion() async {
@@ -136,6 +138,7 @@ class SettingsService {
voiceHoldToTalk: await getVoiceHoldToTalk(), voiceHoldToTalk: await getVoiceHoldToTalk(),
voiceAutoSendFinal: await getVoiceAutoSendFinal(), voiceAutoSendFinal: await getVoiceAutoSendFinal(),
socketTransportMode: await getSocketTransportMode(), socketTransportMode: await getSocketTransportMode(),
quickPills: await getQuickPills(),
); );
} }
@@ -154,6 +157,7 @@ class SettingsService {
setVoiceHoldToTalk(settings.voiceHoldToTalk), setVoiceHoldToTalk(settings.voiceHoldToTalk),
setVoiceAutoSendFinal(settings.voiceAutoSendFinal), setVoiceAutoSendFinal(settings.voiceAutoSendFinal),
setSocketTransportMode(settings.socketTransportMode), setSocketTransportMode(settings.socketTransportMode),
setQuickPills(settings.quickPills),
]); ]);
} }
@@ -204,6 +208,21 @@ class SettingsService {
await prefs.setString(_socketTransportModeKey, mode); await prefs.setString(_socketTransportModeKey, mode);
} }
// Quick Pills (visibility)
static Future<List<String>> 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<void> setQuickPills(List<String> pills) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(_quickPillsKey, pills.take(2).toList());
}
/// Get effective animation duration considering all settings /// Get effective animation duration considering all settings
static Duration getEffectiveAnimationDuration( static Duration getEffectiveAnimationDuration(
BuildContext context, BuildContext context,
@@ -258,6 +277,7 @@ class AppSettings {
final bool voiceHoldToTalk; final bool voiceHoldToTalk;
final bool voiceAutoSendFinal; final bool voiceAutoSendFinal;
final String socketTransportMode; // 'auto' or 'ws' final String socketTransportMode; // 'auto' or 'ws'
final List<String> quickPills; // e.g., ['web','image']
const AppSettings({ const AppSettings({
this.reduceMotion = false, this.reduceMotion = false,
@@ -272,6 +292,7 @@ class AppSettings {
this.voiceHoldToTalk = false, this.voiceHoldToTalk = false,
this.voiceAutoSendFinal = false, this.voiceAutoSendFinal = false,
this.socketTransportMode = 'auto', this.socketTransportMode = 'auto',
this.quickPills = const [],
}); });
AppSettings copyWith({ AppSettings copyWith({
@@ -287,6 +308,7 @@ class AppSettings {
bool? voiceHoldToTalk, bool? voiceHoldToTalk,
bool? voiceAutoSendFinal, bool? voiceAutoSendFinal,
String? socketTransportMode, String? socketTransportMode,
List<String>? quickPills,
}) { }) {
return AppSettings( return AppSettings(
reduceMotion: reduceMotion ?? this.reduceMotion, reduceMotion: reduceMotion ?? this.reduceMotion,
@@ -301,6 +323,7 @@ class AppSettings {
voiceHoldToTalk: voiceHoldToTalk ?? this.voiceHoldToTalk, voiceHoldToTalk: voiceHoldToTalk ?? this.voiceHoldToTalk,
voiceAutoSendFinal: voiceAutoSendFinal ?? this.voiceAutoSendFinal, voiceAutoSendFinal: voiceAutoSendFinal ?? this.voiceAutoSendFinal,
socketTransportMode: socketTransportMode ?? this.socketTransportMode, socketTransportMode: socketTransportMode ?? this.socketTransportMode,
quickPills: quickPills ?? this.quickPills,
); );
} }
@@ -318,7 +341,8 @@ class AppSettings {
other.omitProviderInModelName == omitProviderInModelName && other.omitProviderInModelName == omitProviderInModelName &&
other.voiceLocaleId == voiceLocaleId && other.voiceLocaleId == voiceLocaleId &&
other.voiceHoldToTalk == voiceHoldToTalk && other.voiceHoldToTalk == voiceHoldToTalk &&
other.voiceAutoSendFinal == voiceAutoSendFinal; other.voiceAutoSendFinal == voiceAutoSendFinal &&
_listEquals(other.quickPills, quickPills);
// socketTransportMode intentionally not included in == to avoid frequent rebuilds // socketTransportMode intentionally not included in == to avoid frequent rebuilds
} }
@@ -337,10 +361,20 @@ class AppSettings {
voiceHoldToTalk, voiceHoldToTalk,
voiceAutoSendFinal, voiceAutoSendFinal,
socketTransportMode, socketTransportMode,
Object.hashAllUnordered(quickPills),
); );
} }
} }
bool _listEquals(List<String> a, List<String> 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 /// Provider for app settings
final appSettingsProvider = final appSettingsProvider =
StateNotifierProvider<AppSettingsNotifier, AppSettings>( StateNotifierProvider<AppSettingsNotifier, AppSettings>(
@@ -417,6 +451,13 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
await SettingsService.setSocketTransportMode(mode); await SettingsService.setSocketTransportMode(mode);
} }
Future<void> setQuickPills(List<String> 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<void> resetToDefaults() async { Future<void> resetToDefaults() async {
const defaultSettings = AppSettings(); const defaultSettings = AppSettings();
await SettingsService.saveSettings(defaultSettings); await SettingsService.saveSettings(defaultSettings);

View File

@@ -26,7 +26,15 @@ class SocketService {
_socket?.dispose(); _socket?.dispose();
} catch (_) {} } 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 path = '/ws/socket.io';
final builder = io.OptionBuilder() final builder = io.OptionBuilder()

View File

@@ -13,7 +13,9 @@ import 'dart:math' as math;
import '../providers/chat_providers.dart'; import '../providers/chat_providers.dart';
import '../../tools/widgets/unified_tools_modal.dart'; import '../../tools/widgets/unified_tools_modal.dart';
import '../../tools/providers/tools_providers.dart'; import '../../tools/providers/tools_providers.dart';
import '../../../core/models/tool.dart';
import '../../../core/providers/app_providers.dart'; import '../../../core/providers/app_providers.dart';
import '../../../core/services/settings_service.dart';
import '../../chat/services/voice_input_service.dart'; import '../../chat/services/voice_input_service.dart';
import '../../../shared/utils/platform_utils.dart'; import '../../../shared/utils/platform_utils.dart';
@@ -372,6 +374,15 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
final webSearchEnabled = ref.watch(webSearchEnabledProvider); final webSearchEnabled = ref.watch(webSearchEnabledProvider);
final imageGenEnabled = ref.watch(imageGenerationEnabledProvider); final imageGenEnabled = ref.watch(imageGenerationEnabledProvider);
final imageGenAvailable = ref.watch(imageGenerationAvailableProvider); final imageGenAvailable = ref.watch(imageGenerationAvailableProvider);
final selectedQuickPills =
ref.watch(appSettingsProvider.select((s) => s.quickPills));
final toolsAsync = ref.watch(toolsListProvider);
final List<Tool> availableTools = toolsAsync.maybeWhen<List<Tool>>(
data: (t) => t,
orElse: () => const <Tool>[],
);
final bool showWebPill = selectedQuickPills.contains('web');
final bool showImagePillPref = selectedQuickPills.contains('image');
final voiceAvailableAsync = ref.watch(voiceInputAvailableProvider); final voiceAvailableAsync = ref.watch(voiceInputAvailableProvider);
final bool voiceAvailable = voiceAvailableAsync.maybeWhen( final bool voiceAvailable = voiceAvailableAsync.maybeWhen(
data: (v) => v, data: (v) => v,
@@ -603,103 +614,158 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final double total = constraints.maxWidth; final double total = constraints.maxWidth;
const double toolsWidth = TouchTarget.comfortable; final bool showImage = imageGenAvailable && showImagePillPref;
const double gapBeforeTools = Spacing.xs; final bool showWeb = showWebPill;
final double gapBetweenPills = imageGenAvailable ? Spacing.xs : 0; // Tools button is always shown
final double toolsWidth = TouchTarget.comfortable;
final double gapBeforeTools = Spacing.xs;
final double availableForPills = final double availableForPills =
math.max(0.0, total - toolsWidth - gapBeforeTools); math.max(0.0, total - toolsWidth - gapBeforeTools);
// Measure natural widths (text + horizontal padding) // Compose selected pill entries in order
final List<Map<String, dynamic>> entries = [];
final textStyle = AppTypography.labelStyle; final textStyle = AppTypography.labelStyle;
const double horizontalPadding = Spacing.md * 2; const double horizontalPadding = Spacing.md * 2;
String webLabel = AppLocalizations.of(context)!.web; for (final id in selectedQuickPills) {
final webTp = TextPainter( if (id == 'web' && showWeb) {
text: TextSpan(text: webLabel, style: textStyle), final lbl = AppLocalizations.of(context)!.web;
final tp = TextPainter(
text: TextSpan(text: lbl, style: textStyle),
maxLines: 1, maxLines: 1,
textDirection: Directionality.of(context), textDirection: Directionality.of(context),
)..layout(); )..layout();
final double webNatural = webTp.width + horizontalPadding; entries.add({
'id': id,
double imageNatural = 0; 'label': lbl,
if (imageGenAvailable) { 'width': tp.width + horizontalPadding,
final imgLabel = AppLocalizations.of(context)!.imageGen; 'widgetBuilder': () => _buildPillButton(
final imgTp = TextPainter( icon: Platform.isIOS ? CupertinoIcons.search : Icons.search,
text: TextSpan(text: imgLabel, style: textStyle), label: lbl,
maxLines: 1,
textDirection: Directionality.of(context),
)..layout();
imageNatural = imgTp.width + horizontalPadding;
}
List<Widget> rowChildren = [];
Widget webPill = _buildPillButton(
icon: Platform.isIOS
? CupertinoIcons.search
: Icons.search,
label: webLabel,
isActive: webSearchEnabled, isActive: webSearchEnabled,
onTap: widget.enabled && !_isRecording onTap: widget.enabled && !_isRecording
? () { ? () {
ref.read( ref.read(webSearchEnabledProvider.notifier).state = !webSearchEnabled;
webSearchEnabledProvider.notifier,
).state = !webSearchEnabled;
} }
: null, : null,
); ),
});
if (!imageGenAvailable) { } else if (id == 'image' && showImage) {
if (webNatural <= availableForPills) { final lbl = AppLocalizations.of(context)!.imageGen;
rowChildren.add(webPill); final tp = TextPainter(
} else { text: TextSpan(text: lbl, style: textStyle),
rowChildren.add(Flexible(fit: FlexFit.loose, child: webPill)); maxLines: 1,
} textDirection: Directionality.of(context),
} else { )..layout();
Widget imagePill = _buildPillButton( entries.add({
icon: Platform.isIOS 'id': id,
? CupertinoIcons.photo 'label': lbl,
: Icons.image, 'width': tp.width + horizontalPadding,
label: AppLocalizations.of(context)!.imageGen, 'widgetBuilder': () => _buildPillButton(
icon: Platform.isIOS ? CupertinoIcons.photo : Icons.image,
label: lbl,
isActive: imageGenEnabled, isActive: imageGenEnabled,
onTap: widget.enabled && !_isRecording onTap: widget.enabled && !_isRecording
? () { ? () {
ref.read( ref
imageGenerationEnabledProvider.notifier, .read(imageGenerationEnabledProvider.notifier)
).state = !imageGenEnabled; .state = !imageGenEnabled;
} }
: null, : null,
); ),
});
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(const SizedBox(width: Spacing.xs))
..add(Flexible(fit: FlexFit.loose, child: imagePill));
} else if (imageNatural < availableForPills) {
// Keep image natural, let web take remaining
rowChildren
..add(Flexible(fit: FlexFit.loose, child: webPill))
..add(const SizedBox(width: Spacing.xs))
..add(imagePill);
} else { } else {
// Both too large: apportion space proportional to their natural widths // Tool ID from server
final int webFlex = math.max(1, webNatural.round()); Tool? tool;
final int imgFlex = math.max(1, imageNatural.round()); for (final t in availableTools) {
rowChildren if (t.id == id) { tool = t; break; }
..add(Flexible(fit: FlexFit.loose, flex: webFlex, child: webPill)) }
..add(const SizedBox(width: Spacing.xs)) if (tool != null) {
..add(Flexible(fit: FlexFit.loose, flex: imgFlex, child: imagePill)); 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<String>.from(
ref.read(selectedToolIdsProvider));
if (current.contains(id)) {
current.remove(id);
} else {
current.add(id);
}
ref.read(selectedToolIdsProvider.notifier).state = current;
}
: null,
),
});
}
} }
} }
// Append tools button at the end // Build rowChildren according to measured widths and available space
final List<Widget> 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: pill));
}
} else {
// 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;
if (combined <= availableForPills) {
rowChildren
..add(pill1)
..add(const SizedBox(width: Spacing.xs))
..add(pill2);
} else if (w1 < availableForPills) {
rowChildren
..add(pill1)
..add(const SizedBox(width: Spacing.xs))
..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 {
final int f1 = math.max(1, w1.round());
final int f2 = math.max(1, w2.round());
rowChildren
..add(Flexible(fit: FlexFit.loose, flex: f1, child: pill1))
..add(const SizedBox(width: Spacing.xs))
..add(Flexible(fit: FlexFit.loose, flex: f2, child: pill2));
}
}
// Append tools button at the end (always visible)
rowChildren rowChildren
..add(const SizedBox(width: Spacing.xs)) ..add(const SizedBox(width: Spacing.xs))
..add(_buildRoundButton( ..add(_buildRoundButton(

View File

@@ -6,6 +6,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/services/settings_service.dart'; import '../../../core/services/settings_service.dart';
import '../../../shared/theme/theme_extensions.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/widgets/conduit_components.dart';
import '../../../shared/utils/ui_utils.dart'; import '../../../shared/utils/ui_utils.dart';
import '../../../core/providers/app_providers.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 <Tool>[],
);
final allowed = <String>{
'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<String>.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<Widget> dynamicToolChips = ref
.watch(toolsListProvider)
.maybeWhen<List<Widget>>(
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 <Widget>[],
);
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), const SizedBox(height: Spacing.lg),
Text( Text(
AppLocalizations.of(context)!.realtime, AppLocalizations.of(context)!.realtime,