| 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 | } |