main
dart 254 lines 8.81 KB
Raw
1 import 'package:flutter/material.dart';
2 import 'package:flutter_riverpod/flutter_riverpod.dart';
3 import 'package:hugeicons/hugeicons.dart';
4
5 import 'package:localmind/core/theme/colors.dart';
6 import 'package:localmind/features/chat/providers/chat_reasoning_providers.dart';
7 import 'package:localmind/features/models/data/models/model_info.dart';
8 import 'package:localmind/l10n/app_localizations.dart';
9
10 /// Compact, two-part chip used to toggle and configure the reasoning
11 /// ("thinking") mode for the actively selected model.
12 ///
13 /// • Left side: tap toggles thinking on / off.
14 /// • Right side: opens a popup to choose the effort level (Low / Medium /
15 /// High). The picker collapses to a single tap-toggle button when
16 /// thinking is disabled, since effort is irrelevant in that state.
17 ///
18 /// Rendered as a [ConsumerWidget] so both surfaces (the model picker
19 /// sheet and the chat input bar) stay perfectly in sync with the
20 /// underlying [chatReasoningConfigProvider].
21 class ThinkingModeChip extends ConsumerWidget {
22 const ThinkingModeChip({
23 super.key,
24 required this.model,
25 required this.isDark,
26 this.compact = false,
27 this.fullWidth = false,
28 });
29
30 /// When `null` or a model that doesn't support reasoning is passed the
31 /// chip returns [SizedBox.shrink] so callers can drop it into any layout
32 /// without conditionals.
33 final ModelInfo? model;
34
35 /// Theme context used for the on- / off-state colors.
36 final bool isDark;
37
38 /// When true, uses a slightly tighter horizontal padding so the chip
39 /// fits comfortably inside the chat input bar's action row.
40 final bool compact;
41
42 /// When true, the chip will occupy the full available width instead of
43 /// hugging its contents. Used by the model picker header so the chip
44 /// sits flush under the title.
45 final bool fullWidth;
46
47 @override
48 Widget build(BuildContext context, WidgetRef ref) {
49 final supportsReasoning = model?.supportsReasoning ?? false;
50 if (!supportsReasoning) return const SizedBox.shrink();
51
52 final l10n = AppLocalizations.of(context)!;
53 final theme = Theme.of(context);
54 final config = ref.watch(chatReasoningConfigProvider);
55 final notifier = ref.read(chatReasoningConfigProvider.notifier);
56
57 // Mandatory-reasoning models can't be turned off, so the toggle side is
58 // non-interactive and the effort picker stays visible. Binary on/off
59 // models (e.g. LM Studio ["off","on"]) have no granular choice, so the
60 // picker is hidden and the chip is a pure toggle (the request layer
61 // sends literal "on").
62 final canDisable = !(model?.reasoningMandatory ?? false);
63 final hasGranularChoice = hasGranularReasoningChoice(
64 model?.supportedReasoningEfforts,
65 );
66 final showPicker = (config.enabled || !canDisable) && hasGranularChoice;
67
68 final accent = theme.colorScheme.primary;
69 final enabledFg = accent;
70 final disabledFg = isDark
71 ? AppColors.darkMutedText
72 : AppColors.lightMutedText;
73 final fgColor = config.enabled ? enabledFg : disabledFg;
74 final bgColor = config.enabled
75 ? accent.withValues(alpha: 0.15)
76 : theme.colorScheme.onSurface.withValues(alpha: 0.06);
77 final borderColor = config.enabled
78 ? accent.withValues(alpha: 0.4)
79 : (isDark ? AppColors.darkBorder : AppColors.lightBorder);
80
81 final hPad = compact ? 8.0 : 12.0;
82 final vPad = compact ? 3.0 : 8.0;
83 final borderRadius = BorderRadius.circular(999);
84
85 final String labelText;
86 if (fullWidth) {
87 labelText = l10n.thinking_mode_title;
88 } else if (config.enabled || !canDisable) {
89 labelText = hasGranularChoice
90 ? _effortShortLabel(l10n, config.effort)
91 : l10n.model_reasoning_on;
92 } else {
93 labelText = l10n.reasoning_effort_off;
94 }
95
96 final toggleSide = InkWell(
97 onTap: canDisable
98 ? () {
99 notifier.setEnabled(!config.enabled);
100 }
101 : null,
102 borderRadius: borderRadius,
103 child: Padding(
104 padding: EdgeInsetsDirectional.fromSTEB(
105 hPad,
106 vPad,
107 (!fullWidth || !config.enabled) ? hPad : 4.0,
108 vPad,
109 ),
110 child: Row(
111 mainAxisSize: MainAxisSize.min,
112 children: [
113 HugeIcon(
114 icon: HugeIcons.strokeRoundedBrain,
115 size: compact ? 12 : 16,
116 color: fgColor,
117 ),
118 SizedBox(width: compact ? 3 : 6),
119 Flexible(
120 child: Text(
121 labelText,
122 maxLines: 1,
123 overflow: TextOverflow.ellipsis,
124 style: TextStyle(
125 fontSize: compact ? 11 : 13,
126 fontWeight: FontWeight.w600,
127 color: fgColor,
128 ),
129 ),
130 ),
131 if (fullWidth && showPicker) ...[
132 SizedBox(width: compact ? 3 : 6),
133 Container(
134 padding: EdgeInsets.symmetric(
135 horizontal: compact ? 5 : 8,
136 vertical: compact ? 1 : 2,
137 ),
138 decoration: BoxDecoration(
139 color: accent.withValues(alpha: 0.18),
140 borderRadius: BorderRadius.circular(999),
141 ),
142 child: Text(
143 _effortShortLabel(l10n, config.effort),
144 style: TextStyle(
145 fontSize: compact ? 9 : 11,
146 fontWeight: FontWeight.w700,
147 color: enabledFg,
148 ),
149 ),
150 ),
151 ],
152 ],
153 ),
154 ),
155 );
156
157 // Right-side effort picker. Hidden when thinking is off (and the model
158 // allows turning it off) because the choice has no effect there — the
159 // entire chip is then a single tap toggle.
160 Widget pickerSide = const SizedBox.shrink();
161 if (showPicker) {
162 pickerSide = PopupMenuButton<ReasoningEffort>(
163 tooltip: '',
164 padding: EdgeInsets.zero,
165 position: PopupMenuPosition.under,
166 onSelected: (effort) {
167 notifier.setEnabled(true);
168 notifier.setEffort(effort);
169 },
170 itemBuilder: (context) =>
171 effortsForModel(model?.supportedReasoningEfforts)
172 .map(
173 (effort) => PopupMenuItem(
174 value: effort,
175 child: Row(
176 mainAxisSize: MainAxisSize.min,
177 children: [
178 HugeIcon(
179 icon: effort == config.effort
180 ? HugeIcons.strokeRoundedTick01
181 : HugeIcons.strokeRoundedSquare01,
182 size: 16,
183 color: effort == config.effort
184 ? accent
185 : Colors.transparent,
186 ),
187 const SizedBox(width: 8),
188 Text(_effortShortLabel(l10n, effort)),
189 ],
190 ),
191 ),
192 )
193 .toList(),
194 child: Padding(
195 padding: EdgeInsetsDirectional.fromSTEB(
196 0,
197 vPad,
198 compact ? 6.0 : hPad,
199 vPad,
200 ),
201 child: Row(
202 mainAxisSize: MainAxisSize.min,
203 children: [
204 HugeIcon(
205 icon: HugeIcons.strokeRoundedArrowDown01,
206 size: compact ? 10 : 14,
207 color: fgColor.withValues(alpha: 0.7),
208 ),
209 ],
210 ),
211 ),
212 );
213 }
214
215 final chip = Material(
216 color: Colors.transparent,
217 child: DecoratedBox(
218 decoration: BoxDecoration(
219 color: bgColor,
220 border: Border.all(color: borderColor),
221 borderRadius: borderRadius,
222 ),
223 child: Row(
224 mainAxisSize: fullWidth ? MainAxisSize.max : MainAxisSize.min,
225 mainAxisAlignment: fullWidth
226 ? MainAxisAlignment.spaceBetween
227 : MainAxisAlignment.start,
228 children: [
229 // Toggle side: flexes to absorb available width when [fullWidth]
230 // is true, but stays hugging its content otherwise.
231 if (fullWidth) Expanded(child: toggleSide) else toggleSide,
232 pickerSide,
233 ],
234 ),
235 ),
236 );
237
238 return fullWidth ? SizedBox(width: double.infinity, child: chip) : chip;
239 }
240
241 static String _effortShortLabel(
242 AppLocalizations l10n,
243 ReasoningEffort effort,
244 ) {
245 return switch (effort) {
246 ReasoningEffort.minimal => l10n.reasoning_effort_minimal,
247 ReasoningEffort.low => l10n.reasoning_effort_low,
248 ReasoningEffort.medium => l10n.reasoning_effort_medium,
249 ReasoningEffort.high => l10n.reasoning_effort_high,
250 ReasoningEffort.xhigh => l10n.reasoning_effort_xhigh,
251 ReasoningEffort.max => l10n.reasoning_effort_max,
252 };
253 }
254 }