feat: customize quick pills
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user