@tduncan / Cortana / commits / 000a548

feat: add inline thinking mode selector to model tiles and update thinking chip UI

abdulmominsakib committed Jul 30, 2026 at 12:48 UTC 000a54868314de0dc0adf9de9c4fbf3ca66e7159
10 files changed +1135 -611
lib/features/chat/views/components/chat_input_bar.dart
+50 -40
@@ -1137,53 +1137,63 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
1137 // the model name gets.
1138 Row(
1139 crossAxisAlignment: CrossAxisAlignment.center,
1140 + mainAxisAlignment: MainAxisAlignment.spaceBetween,
1141 children: [
1141 - _buildAddButton(isConnected, theme),
1142 -
1143 - const SizedBox(width: 8),
1144 -
1145 - Flexible(
1146 - child: ModelChip(
1147 - model: selectedModel,
1148 - enabled: isConnected && widget.enabled,
1149 - onTap: () {
1150 - Haptics.vibrate(HapticsType.light);
1151 - showModalBottomSheet(
1152 - context: context,
1153 - isScrollControlled: true,
1154 - useSafeArea: true,
1155 - builder: (_) => const ModelPickerSheet(),
1156 - );
1157 - },
1142 + Expanded(
1143 + child: Row(
1144 + children: [
1145 + _buildAddButton(isConnected, theme),
1146 + const SizedBox(width: 4),
1147 + Flexible(
1148 + child: Wrap(
1149 + spacing: 6,
1150 + runSpacing: 4,
1151 + crossAxisAlignment: WrapCrossAlignment.center,
1152 + children: [
1153 + ModelChip(
1154 + model: selectedModel,
1155 + enabled: isConnected && widget.enabled,
1156 + isCompactFont:
1157 + selectedModel?.supportsReasoning == true,
1158 + onTap: () {
1159 + Haptics.vibrate(HapticsType.light);
1160 + showModalBottomSheet(
1161 + context: context,
1162 + isScrollControlled: true,
1163 + useSafeArea: true,
1164 + builder: (_) => const ModelPickerSheet(),
1165 + );
1166 + },
1167 + ),
1168 + if (selectedModel?.supportsReasoning == true)
1169 + ThinkingModeChip(
1170 + model: selectedModel,
1171 + isDark: isDark,
1172 + compact: true,
1173 + ),
1174 + ],
1175 + ),
1176 + ),
1177 + if (showRoleSwapButton) ...[
1178 + const SizedBox(width: 4),
1179 + _buildRoleSwapButton(theme),
1180 + ],
1181 + ],
1182 ),
1183 ),
1160 -
1161 - if (selectedModel?.supportsReasoning == true) ...[
1162 - const SizedBox(width: 6),
1163 - ThinkingModeChip(
1164 - model: selectedModel,
1165 - isDark: isDark,
1166 - compact: true,
1167 - ),
1168 - ],
1169 - if (showRoleSwapButton) ...[
1170 - const SizedBox(width: 4),
1171 - _buildRoleSwapButton(theme),
1172 - ],
1173 -
1174 - // Fixed gap so we never collapse the right-side cluster
1175 - // against the chips, even when the model name is short.
1176 - const SizedBox(width: 8),
1177 -
1178 - _buildMicButton(isListening, theme),
1179 -
1184 const SizedBox(width: 4),
1185
1182 - _buildVoiceModeButton(theme),
1186 + Row(
1187 + children: [
1188 + _buildMicButton(isListening, theme),
1189 +
1190 + const SizedBox(width: 6),
1191
1184 - const SizedBox(width: 8),
1192 + _buildVoiceModeButton(theme),
1193
1186 - _buildActionButton(canSend, theme),
1194 + _buildActionButton(canSend, theme),
1195 + ],
1196 + ),
1197 ],
1198 ),
1199 ],
lib/features/chat/views/components/model_chip.dart
+12 -4
@@ -17,11 +17,13 @@ class ModelChip extends StatelessWidget {
17 required this.model,
18 required this.onTap,
19 required this.enabled,
20 + this.isCompactFont = false,
21 });
22
23 final ModelInfo? model;
24 final VoidCallback onTap;
25 final bool enabled;
26 + final bool isCompactFont;
27
28 @override
29 Widget build(BuildContext context) {
@@ -44,6 +46,12 @@ class ModelChip extends StatelessWidget {
46
47 final label = hasModel ? model!.displayName : l10n.select_model_prompt;
48
49 + final fontSize = isCompactFont ? 11.0 : 13.0;
50 + final iconSize = isCompactFont ? 10.0 : 12.0;
51 + final padding = isCompactFont
52 + ? const EdgeInsetsDirectional.fromSTEB(8, 3, 6, 3)
53 + : const EdgeInsetsDirectional.fromSTEB(12, 6, 10, 6);
54 +
55 return Material(
56 color: Colors.transparent,
57 child: InkWell(
@@ -54,7 +62,7 @@ class ModelChip extends StatelessWidget {
62 // lets longer model names be readable while Flexible in the parent
63 // row still protects the fixed-size action buttons on the right.
64 constraints: const BoxConstraints(minWidth: 0, maxWidth: 280),
57 - padding: const EdgeInsetsDirectional.fromSTEB(12, 6, 10, 6),
65 + padding: padding,
66 decoration: BoxDecoration(
67 color: bg,
68 border: Border.all(color: borderColor),
@@ -69,16 +77,16 @@ class ModelChip extends StatelessWidget {
77 maxLines: 1,
78 overflow: TextOverflow.ellipsis,
79 style: TextStyle(
72 - fontSize: 13,
80 + fontSize: fontSize,
81 fontWeight: FontWeight.w600,
82 color: fg,
83 ),
84 ),
85 ),
78 - const SizedBox(width: 6),
86 + const SizedBox(width: 4),
87 HugeIcon(
88 icon: HugeIcons.strokeRoundedArrowDown01,
81 - size: 12,
89 + size: iconSize,
90 color: fg.withValues(alpha: 0.7),
91 ),
92 ],
lib/features/models/components/inline_thinking_selector.dart new
+166
@@ -0,0 +1,166 @@
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/l10n/app_localizations.dart';
8 +
9 +/// Inline widget rendered under a model tile in the selection bottom sheet.
10 +/// Shows direct level options (Off, Low, Medium, High) without needing a dropdown.
11 +class InlineThinkingSelector extends ConsumerWidget {
12 + const InlineThinkingSelector({
13 + super.key,
14 + required this.isDark,
15 + this.isSelected = false,
16 + this.onSelectModel,
17 + });
18 +
19 + final bool isDark;
20 + final bool isSelected;
21 +
22 + /// Optional callback to select/load the parent model if it's not already selected.
23 + final VoidCallback? onSelectModel;
24 +
25 + @override
26 + Widget build(BuildContext context, WidgetRef ref) {
27 + final l10n = AppLocalizations.of(context)!;
28 + final theme = Theme.of(context);
29 + final config = ref.watch(chatReasoningConfigProvider);
30 + final notifier = ref.read(chatReasoningConfigProvider.notifier);
31 +
32 + final accent = theme.colorScheme.primary;
33 +
34 + // Options: Off, Low, Medium, High
35 + final options = <_ThinkingOption>[
36 + _ThinkingOption(
37 + label: 'Off',
38 + isEnabled: false,
39 + effort: ReasoningEffort.low,
40 + ),
41 + _ThinkingOption(
42 + label: l10n.reasoning_effort_low,
43 + isEnabled: true,
44 + effort: ReasoningEffort.low,
45 + ),
46 + _ThinkingOption(
47 + label: l10n.reasoning_effort_medium,
48 + isEnabled: true,
49 + effort: ReasoningEffort.medium,
50 + ),
51 + _ThinkingOption(
52 + label: l10n.reasoning_effort_high,
53 + isEnabled: true,
54 + effort: ReasoningEffort.high,
55 + ),
56 + ];
57 +
58 + return Padding(
59 + padding: const EdgeInsets.only(top: 8),
60 + child: Row(
61 + children: [
62 + HugeIcon(
63 + icon: HugeIcons.strokeRoundedBrain,
64 + size: 14,
65 + color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
66 + ),
67 + const SizedBox(width: 6),
68 + Text(
69 + l10n.thinking_mode_title,
70 + style: TextStyle(
71 + fontSize: 11,
72 + fontWeight: FontWeight.w600,
73 + color: isDark
74 + ? AppColors.darkMutedText
75 + : AppColors.lightMutedText,
76 + ),
77 + ),
78 + const SizedBox(width: 8),
79 + Expanded(
80 + child: SingleChildScrollView(
81 + scrollDirection: Axis.horizontal,
82 + child: Row(
83 + children: options.map((opt) {
84 + final isActive = isSelected &&
85 + (opt.isEnabled == config.enabled) &&
86 + (!opt.isEnabled || opt.effort == config.effort);
87 +
88 + final chipBg = isActive
89 + ? accent.withValues(alpha: 0.2)
90 + : (isDark
91 + ? Colors.white.withValues(alpha: 0.05)
92 + : Colors.black.withValues(alpha: 0.04));
93 +
94 + final chipFg = isActive
95 + ? accent
96 + : (isDark
97 + ? AppColors.darkMutedText
98 + : AppColors.lightMutedText);
99 +
100 + final borderColor = isActive
101 + ? accent.withValues(alpha: 0.5)
102 + : Colors.transparent;
103 +
104 + return Padding(
105 + padding: const EdgeInsets.only(right: 4),
106 + child: Material(
107 + color: Colors.transparent,
108 + child: InkWell(
109 + onTap: () {
110 + if (onSelectModel != null && !isSelected) {
111 + onSelectModel!();
112 + }
113 + if (!opt.isEnabled) {
114 + notifier.setEnabled(false);
115 + } else {
116 + notifier.setEnabled(true);
117 + notifier.setEffort(opt.effort);
118 + }
119 + },
120 + borderRadius: BorderRadius.circular(6),
121 + child: AnimatedContainer(
122 + duration: const Duration(milliseconds: 150),
123 + padding: const EdgeInsets.symmetric(
124 + horizontal: 8,
125 + vertical: 3,
126 + ),
127 + decoration: BoxDecoration(
128 + color: chipBg,
129 + borderRadius: BorderRadius.circular(6),
130 + border: Border.all(color: borderColor, width: 1),
131 + ),
132 + child: Text(
133 + opt.label,
134 + style: TextStyle(
135 + fontSize: 10,
136 + fontWeight: isActive
137 + ? FontWeight.bold
138 + : FontWeight.w500,
139 + color: chipFg,
140 + ),
141 + ),
142 + ),
143 + ),
144 + ),
145 + );
146 + }).toList(),
147 + ),
148 + ),
149 + ),
150 + ],
151 + ),
152 + );
153 + }
154 +}
155 +
156 +class _ThinkingOption {
157 + const _ThinkingOption({
158 + required this.label,
159 + required this.isEnabled,
160 + required this.effort,
161 + });
162 +
163 + final String label;
164 + final bool isEnabled;
165 + final ReasoningEffort effort;
166 +}
lib/features/models/components/model_tile.dart
+127 -108
@@ -6,6 +6,8 @@ import 'package:localmind/features/models/data/models/model_info.dart';
6 import 'package:localmind/l10n/app_localizations.dart';
7 import 'metadata_chip.dart';
8
9 +import 'inline_thinking_selector.dart';
10 +
11 class ModelTile extends StatelessWidget {
12 const ModelTile({
13 super.key,
@@ -53,130 +55,147 @@ class ModelTile extends StatelessWidget {
55 ? Border.all(color: accent.withValues(alpha: 0.3))
56 : null,
57 ),
56 - child: Row(
58 + child: Column(
59 + mainAxisSize: MainAxisSize.min,
60 + crossAxisAlignment: CrossAxisAlignment.stretch,
61 children: [
58 - Expanded(
59 - child: Column(
60 - crossAxisAlignment: CrossAxisAlignment.start,
61 - children: [
62 - Row(
62 + Row(
63 + children: [
64 + Expanded(
65 + child: Column(
66 + crossAxisAlignment: CrossAxisAlignment.start,
67 children: [
64 - if (isFavorite) ...[
65 - HugeIcon(
66 - icon: HugeIcons.strokeRoundedStar,
67 - size: 16,
68 - color: Colors.amber[600],
69 - ),
70 - const SizedBox(width: 6),
71 - ],
72 - Expanded(
73 - child: Text(
74 - model.displayName,
68 + Row(
69 + children: [
70 + if (isFavorite) ...[
71 + HugeIcon(
72 + icon: HugeIcons.strokeRoundedStar,
73 + size: 16,
74 + color: Colors.amber[600],
75 + ),
76 + const SizedBox(width: 6),
77 + ],
78 + Expanded(
79 + child: Text(
80 + model.displayName,
81 + style: TextStyle(
82 + fontSize: 15,
83 + fontWeight: isSelected
84 + ? FontWeight.w600
85 + : FontWeight.w500,
86 + color: isDark ? Colors.white : Colors.black,
87 + ),
88 + maxLines: 1,
89 + overflow: TextOverflow.ellipsis,
90 + ),
91 + ),
92 + ],
93 + ),
94 + if (note != null && note!.isNotEmpty) ...[
95 + const SizedBox(height: 4),
96 + Text(
97 + note!,
98 style: TextStyle(
76 - fontSize: 15,
77 - fontWeight: isSelected
78 - ? FontWeight.w600
79 - : FontWeight.w500,
80 - color: isDark ? Colors.white : Colors.black,
99 + fontSize: 12,
100 + color: isDark
101 + ? AppColors.darkMutedText
102 + : AppColors.lightMutedText,
103 ),
82 - maxLines: 1,
104 + maxLines: 2,
105 overflow: TextOverflow.ellipsis,
106 ),
107 + ],
108 + const SizedBox(height: 4),
109 + Wrap(
110 + spacing: 6,
111 + runSpacing: 4,
112 + children: [
113 + if (model.parameterCountDisplay != null &&
114 + model.parameterCountDisplay!.isNotEmpty)
115 + MetadataChip(
116 + label: model.parameterCountDisplay!,
117 + isDark: isDark,
118 + ),
119 + if (model.quantization != null &&
120 + model.quantization!.isNotEmpty)
121 + MetadataChip(
122 + label: model.quantization!,
123 + isDark: isDark,
124 + ),
125 + if (model.formattedSize != null &&
126 + model.formattedSize!.isNotEmpty)
127 + MetadataChip(
128 + label: model.formattedSize!,
129 + isDark: isDark,
130 + ),
131 + if (model.contextLength != null)
132 + MetadataChip(
133 + label: l10n.context_chip(
134 + model.contextLength.toString(),
135 + ),
136 + isDark: isDark,
137 + ),
138 + ],
139 ),
86 - ],
87 - ),
88 - if (note != null && note!.isNotEmpty) ...[
89 - const SizedBox(height: 4),
90 - Text(
91 - note!,
92 - style: TextStyle(
93 - fontSize: 12,
94 - color: isDark
95 - ? AppColors.darkMutedText
96 - : AppColors.lightMutedText,
97 - ),
98 - maxLines: 2,
99 - overflow: TextOverflow.ellipsis,
100 - ),
101 - ],
102 - const SizedBox(height: 4),
103 - Wrap(
104 - spacing: 6,
105 - runSpacing: 4,
106 - children: [
107 - if (model.parameterCountDisplay != null &&
108 - model.parameterCountDisplay!.isNotEmpty)
109 - MetadataChip(
110 - label: model.parameterCountDisplay!,
111 - isDark: isDark,
112 - ),
113 - if (model.quantization != null &&
114 - model.quantization!.isNotEmpty)
115 - MetadataChip(
116 - label: model.quantization!,
117 - isDark: isDark,
118 - ),
119 - if (model.formattedSize != null &&
120 - model.formattedSize!.isNotEmpty)
121 - MetadataChip(
122 - label: model.formattedSize!,
123 - isDark: isDark,
124 - ),
125 - if (model.contextLength != null)
126 - MetadataChip(
127 - label: l10n.context_chip(
128 - model.contextLength.toString(),
129 - ),
140 + if (model.supportsReasoning)
141 + InlineThinkingSelector(
142 isDark: isDark,
143 + isSelected: isSelected,
144 + onSelectModel: onTap,
145 ),
146 ],
147 ),
148 + ),
149 + if ((model.serverType == ServerType.lmStudio ||
150 + model.serverType == ServerType.ollama) &&
151 + isLoaded) ...[
152 + IconButton(
153 + icon: HugeIcon(
154 + icon: HugeIcons.strokeRoundedPower,
155 + size: 18,
156 + color: Colors.red[400],
157 + ),
158 + onPressed: onUnload,
159 + padding: EdgeInsets.zero,
160 + constraints: const BoxConstraints(),
161 + tooltip: l10n.unload_from_server,
162 + ),
163 + const SizedBox(width: 8),
164 ],
135 - ),
165 + if (model.supportsVision ||
166 + model.supportsReasoning ||
167 + model.supportsToolUse) ...[
168 + const SizedBox(width: 8),
169 + _ModelCapabilityIcons(model: model, isDark: isDark),
170 + ],
171 + if (isSelected) ...[
172 + const SizedBox(width: 4),
173 + HugeIcon(
174 + icon: HugeIcons.strokeRoundedCheckmarkCircle01,
175 + color: accent,
176 + size: 22,
177 + ),
178 + ] else if (!isLoading)
179 + HugeIcon(
180 + icon: HugeIcons.strokeRoundedArrowRight01,
181 + size: 20,
182 + color: isDark
183 + ? AppColors.darkMutedText
184 + : AppColors.lightMutedText,
185 + ),
186 + ],
187 ),
137 - if ((model.serverType == ServerType.lmStudio ||
138 - model.serverType == ServerType.ollama) &&
139 - isLoaded) ...[
140 - IconButton(
141 - icon: HugeIcon(
142 - icon: HugeIcons.strokeRoundedPower,
143 - size: 18,
144 - color: Colors.red[400],
188 + if (isLoading) ...[
189 + const SizedBox(height: 8),
190 + ClipRRect(
191 + borderRadius: BorderRadius.circular(2),
192 + child: LinearProgressIndicator(
193 + minHeight: 3,
194 + backgroundColor: accent.withValues(alpha: 0.15),
195 + valueColor: AlwaysStoppedAnimation<Color>(accent),
196 ),
146 - onPressed: onUnload,
147 - padding: EdgeInsets.zero,
148 - constraints: const BoxConstraints(),
149 - tooltip: l10n.unload_from_server,
197 ),
151 - const SizedBox(width: 8),
198 ],
153 - if (model.supportsVision ||
154 - model.supportsReasoning ||
155 - model.supportsToolUse) ...[
156 - const SizedBox(width: 8),
157 - _ModelCapabilityIcons(model: model, isDark: isDark),
158 - ],
159 - if (isLoading)
160 - SizedBox(
161 - width: 22,
162 - height: 22,
163 - child: CircularProgressIndicator(strokeWidth: 2, color: accent),
164 - )
165 - else if (isSelected) ...[
166 - SizedBox(width: 4),
167 - HugeIcon(
168 - icon: HugeIcons.strokeRoundedCheckmarkCircle01,
169 - color: accent,
170 - size: 22,
171 - ),
172 - ] else
173 - HugeIcon(
174 - icon: HugeIcons.strokeRoundedArrowRight01,
175 - size: 20,
176 - color: isDark
177 - ? AppColors.darkMutedText
178 - : AppColors.lightMutedText,
179 - ),
199 ],
200 ),
201 ),
lib/features/models/components/thinking_mode_chip.dart
+31 -16
@@ -67,13 +67,18 @@ class ThinkingModeChip extends ConsumerWidget {
67 ? accent.withValues(alpha: 0.4)
68 : (isDark ? AppColors.darkBorder : AppColors.lightBorder);
69
70 - final hPad = compact ? 10.0 : 12.0;
71 - final vPad = compact ? 6.0 : 8.0;
70 + final hPad = compact ? 8.0 : 12.0;
71 + final vPad = compact ? 3.0 : 8.0;
72 final borderRadius = BorderRadius.circular(999);
73
74 - final labelText = fullWidth
75 - ? l10n.thinking_mode_title
76 - : l10n.think_button_label;
74 + final String labelText;
75 + if (fullWidth) {
76 + labelText = l10n.thinking_mode_title;
77 + } else if (config.enabled) {
78 + labelText = _effortShortLabel(l10n, config.effort);
79 + } else {
80 + labelText = 'Off';
81 + }
82
83 final toggleSide = InkWell(
84 onTap: () {
@@ -81,34 +86,39 @@ class ThinkingModeChip extends ConsumerWidget {
86 },
87 borderRadius: borderRadius,
88 child: Padding(
84 - padding: EdgeInsetsDirectional.fromSTEB(hPad, vPad, hPad, vPad),
89 + padding: EdgeInsetsDirectional.fromSTEB(
90 + hPad,
91 + vPad,
92 + (!fullWidth || !config.enabled) ? hPad : 4.0,
93 + vPad,
94 + ),
95 child: Row(
96 mainAxisSize: MainAxisSize.min,
97 children: [
98 HugeIcon(
99 icon: HugeIcons.strokeRoundedBrain,
90 - size: compact ? 14 : 16,
100 + size: compact ? 12 : 16,
101 color: fgColor,
102 ),
93 - SizedBox(width: compact ? 4 : 6),
103 + SizedBox(width: compact ? 3 : 6),
104 Flexible(
105 child: Text(
106 labelText,
107 maxLines: 1,
108 overflow: TextOverflow.ellipsis,
109 style: TextStyle(
100 - fontSize: compact ? 12 : 13,
110 + fontSize: compact ? 11 : 13,
111 fontWeight: FontWeight.w600,
112 color: fgColor,
113 ),
114 ),
115 ),
106 - if (config.enabled) ...[
107 - SizedBox(width: compact ? 4 : 6),
116 + if (fullWidth && config.enabled) ...[
117 + SizedBox(width: compact ? 3 : 6),
118 Container(
119 padding: EdgeInsets.symmetric(
110 - horizontal: compact ? 6 : 8,
111 - vertical: 2,
120 + horizontal: compact ? 5 : 8,
121 + vertical: compact ? 1 : 2,
122 ),
123 decoration: BoxDecoration(
124 color: accent.withValues(alpha: 0.18),
@@ -117,7 +127,7 @@ class ThinkingModeChip extends ConsumerWidget {
127 child: Text(
128 _effortShortLabel(l10n, config.effort),
129 style: TextStyle(
120 - fontSize: compact ? 10 : 11,
130 + fontSize: compact ? 9 : 11,
131 fontWeight: FontWeight.w700,
132 color: enabledFg,
133 ),
@@ -165,13 +175,18 @@ class ThinkingModeChip extends ConsumerWidget {
175 )
176 .toList(),
177 child: Padding(
168 - padding: EdgeInsetsDirectional.fromSTEB(2, vPad, hPad, vPad),
178 + padding: EdgeInsetsDirectional.fromSTEB(
179 + 0,
180 + vPad,
181 + compact ? 6.0 : hPad,
182 + vPad,
183 + ),
184 child: Row(
185 mainAxisSize: MainAxisSize.min,
186 children: [
187 HugeIcon(
188 icon: HugeIcons.strokeRoundedArrowDown01,
174 - size: compact ? 12 : 14,
189 + size: compact ? 10 : 14,
190 color: fgColor.withValues(alpha: 0.7),
191 ),
192 ],
lib/features/models/views/model_picker_sheet.dart
-12
@@ -21,7 +21,6 @@ import '../components/model_search_field.dart';
21 import '../components/model_sort_control.dart';
22 import '../components/no_server_state.dart';
23 import '../components/thinking_indicator.dart';
24 -import '../components/thinking_mode_chip.dart';
24 import '../providers/model_picker_providers.dart';
25
26 class ModelPickerSheet extends ConsumerStatefulWidget {
@@ -159,17 +158,6 @@ class _ModelPickerSheetState extends ConsumerState<ModelPickerSheet> {
158 ),
159 ),
160 ),
162 - // Thinking-mode selector surfaces only when the currently
163 - // selected model can actually reason. Renders nothing
164 - // otherwise so it never adds dead space.
165 - if (selectedModel?.supportsReasoning == true) ...[
166 - const SizedBox(height: 12),
167 - ThinkingModeChip(
168 - model: selectedModel,
169 - isDark: isDark,
170 - fullWidth: true,
171 - ),
172 - ],
161 const SizedBox(height: 12),
162 Row(
163 children: [
lib/features/on_device/components/model_card.dart
+49 -38
@@ -429,47 +429,58 @@ class _DownloadedActions extends ConsumerWidget {
429 Widget build(BuildContext context, WidgetRef ref) {
430 final isLoaded = engineState.loadedModelId == model.id;
431
432 - return Row(
432 + return Column(
433 + mainAxisSize: MainAxisSize.min,
434 + crossAxisAlignment: CrossAxisAlignment.stretch,
435 children: [
434 - HugeIcon(icon:
435 - isLoaded ? HugeIcons.strokeRoundedCheckmarkCircle01 : HugeIcons.strokeRoundedCheckmarkCircle01,
436 - color: isLoaded ? Colors.green : Colors.grey,
437 - size: 18,
438 - ),
439 - const SizedBox(width: 4),
440 - Text(
441 - isLoaded ? l10n.loaded_status : l10n.downloaded,
442 - style: theme.textTheme.bodySmall?.copyWith(
443 - color: isLoaded ? Colors.green : Colors.grey,
444 - fontWeight: FontWeight.w600,
445 - ),
436 + Row(
437 + children: [
438 + HugeIcon(icon:
439 + isLoaded ? HugeIcons.strokeRoundedCheckmarkCircle01 : HugeIcons.strokeRoundedCheckmarkCircle01,
440 + color: isLoaded ? Colors.green : Colors.grey,
441 + size: 18,
442 + ),
443 + const SizedBox(width: 4),
444 + Text(
445 + isLoaded ? l10n.loaded_status : l10n.downloaded,
446 + style: theme.textTheme.bodySmall?.copyWith(
447 + color: isLoaded ? Colors.green : Colors.grey,
448 + fontWeight: FontWeight.w600,
449 + ),
450 + ),
451 + const Spacer(),
452 + if (isLoaded)
453 + ShadButton.outline(
454 + size: ShadButtonSize.sm,
455 + onPressed: () =>
456 + ref.read(onDeviceEngineProvider.notifier).unloadModel(),
457 + child: Text(l10n.unload),
458 + )
459 + else if (!isLoading)
460 + ShadButton.outline(
461 + size: ShadButtonSize.sm,
462 + onPressed: () => _loadModel(context, ref),
463 + child: Text(l10n.load),
464 + ),
465 + const SizedBox(width: 8),
466 + ShadButton.outline(
467 + size: ShadButtonSize.sm,
468 + onPressed: () => _deleteModel(context, ref),
469 + child: Text(l10n.delete),
470 + ),
471 + ],
472 ),
447 - const Spacer(),
448 - if (isLoading)
449 - const SizedBox(
450 - width: 20,
451 - height: 20,
452 - child: CircularProgressIndicator(strokeWidth: 2),
453 - )
454 - else if (isLoaded)
455 - ShadButton.outline(
456 - size: ShadButtonSize.sm,
457 - onPressed: () =>
458 - ref.read(onDeviceEngineProvider.notifier).unloadModel(),
459 - child: Text(l10n.unload),
460 - )
461 - else
462 - ShadButton.outline(
463 - size: ShadButtonSize.sm,
464 - onPressed: () => _loadModel(context, ref),
465 - child: Text(l10n.load),
473 + if (isLoading) ...[
474 + const SizedBox(height: 8),
475 + ClipRRect(
476 + borderRadius: BorderRadius.circular(2),
477 + child: LinearProgressIndicator(
478 + minHeight: 3,
479 + backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.15),
480 + valueColor: AlwaysStoppedAnimation<Color>(theme.colorScheme.primary),
481 + ),
482 ),
467 - const SizedBox(width: 8),
468 - ShadButton.outline(
469 - size: ShadButtonSize.sm,
470 - onPressed: () => _deleteModel(context, ref),
471 - child: Text(l10n.delete),
472 - ),
483 + ],
484 ],
485 );
486 }
lib/features/on_device/components/on_device_picker_section.dart
+253 -227
@@ -19,6 +19,7 @@ import 'package:localmind/features/on_device/providers/foreground_download_provi
19 import 'package:localmind/l10n/app_localizations.dart';
20 import 'package:localmind/core/routes/app_routes.dart';
21 import 'package:localmind/features/models/components/metadata_chip.dart';
22 +import 'package:localmind/features/models/components/inline_thinking_selector.dart';
23
24 class OnDevicePickerSection extends ConsumerWidget {
25 const OnDevicePickerSection({
@@ -230,257 +231,282 @@ class _OnDeviceModelTile extends ConsumerWidget {
231 ? Border.all(color: accent.withValues(alpha: 0.3))
232 : null,
233 ),
233 - child: Row(
234 + child: Column(
235 + mainAxisSize: MainAxisSize.min,
236 + crossAxisAlignment: CrossAxisAlignment.stretch,
237 children: [
235 - Expanded(
236 - child: Column(
237 - crossAxisAlignment: CrossAxisAlignment.start,
238 - children: [
239 - Row(
238 + Row(
239 + children: [
240 + Expanded(
241 + child: Column(
242 + crossAxisAlignment: CrossAxisAlignment.start,
243 children: [
241 - Expanded(
242 - child: Text(
243 - model.name,
244 - style: TextStyle(
245 - fontSize: 15,
246 - fontWeight: isSelected
247 - ? FontWeight.w600
248 - : FontWeight.w500,
249 - color: isDownloaded
250 - ? (isDark ? Colors.white : Colors.black)
251 - : (isDark
252 - ? AppColors.darkMutedText
253 - : AppColors.lightMutedText),
254 - ),
255 - maxLines: 1,
256 - overflow: TextOverflow.ellipsis,
257 - ),
258 - ),
259 - if (model.isRecommended)
260 - Container(
261 - padding: const EdgeInsets.symmetric(
262 - horizontal: 6,
263 - vertical: 2,
264 - ),
265 - decoration: BoxDecoration(
266 - color: accent.withValues(alpha: 0.1),
267 - borderRadius: BorderRadius.circular(4),
244 + Row(
245 + children: [
246 + Expanded(
247 + child: Text(
248 + model.name,
249 + style: TextStyle(
250 + fontSize: 15,
251 + fontWeight: isSelected
252 + ? FontWeight.w600
253 + : FontWeight.w500,
254 + color: isDownloaded
255 + ? (isDark ? Colors.white : Colors.black)
256 + : (isDark
257 + ? AppColors.darkMutedText
258 + : AppColors.lightMutedText),
259 + ),
260 + maxLines: 1,
261 + overflow: TextOverflow.ellipsis,
262 + ),
263 ),
269 - child: Text(
270 - l10n.recommended,
271 - style: TextStyle(
272 - fontSize: 9,
273 - fontWeight: FontWeight.bold,
274 - color: accent,
264 + if (model.isRecommended)
265 + Container(
266 + padding: const EdgeInsets.symmetric(
267 + horizontal: 6,
268 + vertical: 2,
269 + ),
270 + decoration: BoxDecoration(
271 + color: accent.withValues(alpha: 0.1),
272 + borderRadius: BorderRadius.circular(4),
273 + ),
274 + child: Text(
275 + l10n.recommended,
276 + style: TextStyle(
277 + fontSize: 9,
278 + fontWeight: FontWeight.bold,
279 + color: accent,
280 + ),
281 + ),
282 ),
283 + if (model.isImported)
284 + Container(
285 + margin: const EdgeInsets.only(left: 6),
286 + padding: const EdgeInsets.symmetric(
287 + horizontal: 6,
288 + vertical: 2,
289 + ),
290 + decoration: BoxDecoration(
291 + color: Colors.blue.withValues(alpha: 0.1),
292 + borderRadius: BorderRadius.circular(4),
293 + ),
294 + child: Text(
295 + model.importedSourceLabel ?? 'Imported',
296 + style: const TextStyle(
297 + fontSize: 9,
298 + fontWeight: FontWeight.bold,
299 + color: Colors.blue,
300 + ),
301 + ),
302 + ),
303 + ],
304 + ),
305 + if (!Platform.isIOS &&
306 + deviceMemory != null &&
307 + deviceMemory!.isOversized(model.minRamMb))
308 + Padding(
309 + padding: const EdgeInsets.only(top: 2),
310 + child: Row(
311 + children: [
312 + const HugeIcon(icon:
313 + HugeIcons.strokeRoundedAlertCircle,
314 + color: Colors.orange,
315 + size: 12,
316 + ),
317 + const SizedBox(width: 4),
318 + Text(
319 + l10n.may_be_large,
320 + style: TextStyle(
321 + fontSize: 10,
322 + color: Colors.orange,
323 + fontWeight: FontWeight.bold,
324 + ),
325 + ),
326 + ],
327 ),
328 ),
278 - if (model.isImported)
279 - Container(
280 - margin: const EdgeInsets.only(left: 6),
281 - padding: const EdgeInsets.symmetric(
282 - horizontal: 6,
283 - vertical: 2,
284 - ),
285 - decoration: BoxDecoration(
286 - color: Colors.blue.withValues(alpha: 0.1),
287 - borderRadius: BorderRadius.circular(4),
288 - ),
289 - child: Text(
290 - model.importedSourceLabel ?? 'Imported',
291 - style: const TextStyle(
292 - fontSize: 9,
293 - fontWeight: FontWeight.bold,
294 - color: Colors.blue,
295 - ),
329 + if (isDownloading || isPaused)
330 + Padding(
331 + padding: const EdgeInsets.only(top: 6),
332 + child: Column(
333 + crossAxisAlignment: CrossAxisAlignment.start,
334 + children: [
335 + LinearProgressIndicator(
336 + value: downloadProgress?.progress ?? 0.0,
337 + backgroundColor: isDark
338 + ? AppColors.darkBorder
339 + : AppColors.lightBorder,
340 + ),
341 + const SizedBox(height: 2),
342 + Text(
343 + isPaused
344 + ? l10n.paused_progress(
345 + ((downloadProgress?.progress ?? 0) * 100)
346 + .toStringAsFixed(0),
347 + )
348 + : '${l10n.downloading_status} ${((downloadProgress?.progress ?? 0) * 100).toStringAsFixed(0)}%',
349 + style: TextStyle(
350 + fontSize: 10,
351 + color: isDark
352 + ? AppColors.darkMutedText
353 + : AppColors.lightMutedText,
354 + ),
355 + ),
356 + ],
357 ),
358 ),
298 - ],
299 - ),
300 - if (!Platform.isIOS &&
301 - deviceMemory != null &&
302 - deviceMemory!.isOversized(model.minRamMb))
303 - Padding(
304 - padding: const EdgeInsets.only(top: 2),
305 - child: Row(
359 + const SizedBox(height: 4),
360 + Wrap(
361 + spacing: 6,
362 + runSpacing: 4,
363 children: [
307 - const HugeIcon(icon:
308 - HugeIcons.strokeRoundedAlertCircle,
309 - color: Colors.orange,
310 - size: 12,
364 + MetadataChip(
365 + label: model.fileSizeFormatted,
366 + isDark: isDark,
367 ),
312 - const SizedBox(width: 4),
313 - Text(
314 - l10n.may_be_large,
315 - style: TextStyle(
316 - fontSize: 10,
317 - color: Colors.orange,
318 - fontWeight: FontWeight.bold,
368 + if (model.format == OnDeviceModelFormat.gguf)
369 + MetadataChip(
370 + label: l10n.gguf_format_label,
371 + isDark: isDark,
372 ),
320 - ),
321 - ],
322 - ),
323 - ),
324 - if (isDownloading || isPaused)
325 - Padding(
326 - padding: const EdgeInsets.only(top: 6),
327 - child: Column(
328 - crossAxisAlignment: CrossAxisAlignment.start,
329 - children: [
330 - LinearProgressIndicator(
331 - value: downloadProgress?.progress ?? 0.0,
332 - backgroundColor: isDark
333 - ? AppColors.darkBorder
334 - : AppColors.lightBorder,
335 - ),
336 - const SizedBox(height: 2),
337 - Text(
338 - isPaused
339 - ? l10n.paused_progress(
340 - ((downloadProgress?.progress ?? 0) * 100)
341 - .toStringAsFixed(0),
342 - )
343 - : '${l10n.downloading_status} ${((downloadProgress?.progress ?? 0) * 100).toStringAsFixed(0)}%',
344 - style: TextStyle(
345 - fontSize: 10,
346 - color: isDark
347 - ? AppColors.darkMutedText
348 - : AppColors.lightMutedText,
373 + if (model.runtime == OnDeviceModelRuntime.llamaCpp)
374 + MetadataChip(label: 'llama.cpp', isDark: isDark),
375 + if (!model.isImported)
376 + MetadataChip(label: model.license, isDark: isDark),
377 + if (!model.isImported)
378 + MetadataChip(
379 + label: model.parameterLabel,
380 + isDark: isDark,
381 ),
350 - ),
382 ],
383 ),
353 - ),
354 - const SizedBox(height: 4),
355 - Wrap(
356 - spacing: 6,
357 - runSpacing: 4,
358 - children: [
359 - MetadataChip(
360 - label: model.fileSizeFormatted,
361 - isDark: isDark,
362 - ),
363 - if (model.format == OnDeviceModelFormat.gguf)
364 - MetadataChip(
365 - label: l10n.gguf_format_label,
366 - isDark: isDark,
367 - ),
368 - if (model.runtime == OnDeviceModelRuntime.llamaCpp)
369 - MetadataChip(label: 'llama.cpp', isDark: isDark),
370 - if (!model.isImported)
371 - MetadataChip(label: model.license, isDark: isDark),
372 - if (!model.isImported)
373 - MetadataChip(
374 - label: model.parameterLabel,
384 + if (model.supportsThinking)
385 + InlineThinkingSelector(
386 isDark: isDark,
387 + isSelected: isSelected,
388 + onSelectModel: () {
389 + if (isLoaded) {
390 + _selectModel(context, ref);
391 + } else if (isDownloaded &&
392 + !isCurrentlyLoading &&
393 + !isDownloading) {
394 + _loadModel(context, ref);
395 + }
396 + },
397 ),
398 ],
399 ),
400 + ),
401 + const SizedBox(width: 8),
402 + if (isLoaded) ...[
403 + Container(
404 + width: 10,
405 + height: 10,
406 + decoration: const BoxDecoration(
407 + color: AppColors.darkAccent,
408 + shape: BoxShape.circle,
409 + ),
410 + ),
411 + const SizedBox(width: 8),
412 + _IconButton(
413 + icon: HugeIcon(icon:
414 + HugeIcons.strokeRoundedPower,
415 + size: 18,
416 + color: Colors.red[400],
417 + ),
418 + tooltip: l10n.unload,
419 + onPressed: () => _unloadModel(context, ref),
420 + ),
421 + ] else if (isDownloaded && !isCurrentlyLoading) ...[
422 + _IconButton(
423 + icon: HugeIcon(icon: HugeIcons.strokeRoundedPlay, size: 20, color: accent),
424 + tooltip: l10n.load,
425 + onPressed: () => _loadModel(context, ref),
426 + ),
427 + const SizedBox(width: 4),
428 + _IconButton(
429 + icon: HugeIcon(icon:
430 + HugeIcons.strokeRoundedDelete01,
431 + size: 18,
432 + color: isDark
433 + ? AppColors.darkMutedText
434 + : AppColors.lightMutedText,
435 + ),
436 + tooltip: l10n.delete,
437 + onPressed: () => _deleteModel(context, ref),
438 + ),
439 + ] else if (isDownloading) ...[
440 + _IconButton(
441 + icon: HugeIcon(icon:
442 + HugeIcons.strokeRoundedCancel01,
443 + size: 16,
444 + color: isDark
445 + ? AppColors.darkMutedText
446 + : AppColors.lightMutedText,
447 + ),
448 + tooltip: l10n.cancel,
449 + onPressed: () => ref
450 + .read(foregroundDownloadNotifierProvider.notifier)
451 + .cancelDownload(model.id),
452 + ),
453 + ] else if (!isCurrentlyLoading) ...[
454 + _IconButton(
455 + icon: HugeIcon(icon:
456 + HugeIcons.strokeRoundedCloudDownload,
457 + size: 18,
458 + color: isDark
459 + ? AppColors.darkMutedText
460 + : AppColors.lightMutedText,
461 + ),
462 + tooltip: l10n.download,
463 + onPressed: () async {
464 + final result = await ref
465 + .read(foregroundDownloadNotifierProvider.notifier)
466 + .startDownload(model.id);
467 + if (result == 'missing_huggingface_token' &&
468 + context.mounted) {
469 + ScaffoldMessenger.of(context).showSnackBar(
470 + SnackBar(
471 + content: Text(l10n.model_missing_huggingface_token),
472 + duration: const Duration(seconds: 6),
473 + action: SnackBarAction(
474 + label: l10n.settings_title,
475 + onPressed: () => context.push(AppRoutes.settings),
476 + ),
477 + ),
478 + );
479 + }
480 + },
481 + ),
482 + const SizedBox(width: 4),
483 + Text(
484 + l10n.not_downloaded,
485 + style: TextStyle(
486 + fontSize: 11,
487 + color: isDark
488 + ? AppColors.darkMutedText
489 + : AppColors.lightMutedText,
490 + ),
491 + ),
492 ],
380 - ),
493 + if (isDownloaded && isSelected && isLoaded) ...[
494 + const SizedBox(width: 4),
495 + HugeIcon(icon: HugeIcons.strokeRoundedCheckmarkCircle01, color: accent, size: 22),
496 + ],
497 + ],
498 ),
382 - const SizedBox(width: 8),
499 if (isCurrentlyLoading) ...[
384 - const SizedBox(
385 - width: 16,
386 - height: 16,
387 - child: CircularProgressIndicator(strokeWidth: 2),
388 - ),
389 - ] else if (isLoaded) ...[
390 - Container(
391 - width: 10,
392 - height: 10,
393 - decoration: const BoxDecoration(
394 - color: AppColors.darkAccent,
395 - shape: BoxShape.circle,
396 - ),
397 - ),
398 - const SizedBox(width: 8),
399 - _IconButton(
400 - icon: HugeIcon(icon:
401 - HugeIcons.strokeRoundedPower,
402 - size: 18,
403 - color: Colors.red[400],
404 - ),
405 - tooltip: l10n.unload,
406 - onPressed: () => _unloadModel(context, ref),
407 - ),
408 - ] else if (isDownloaded) ...[
409 - _IconButton(
410 - icon: HugeIcon(icon: HugeIcons.strokeRoundedPlay, size: 20, color: accent),
411 - tooltip: l10n.load,
412 - onPressed: () => _loadModel(context, ref),
413 - ),
414 - const SizedBox(width: 4),
415 - _IconButton(
416 - icon: HugeIcon(icon:
417 - HugeIcons.strokeRoundedDelete01,
418 - size: 18,
419 - color: isDark
420 - ? AppColors.darkMutedText
421 - : AppColors.lightMutedText,
422 - ),
423 - tooltip: l10n.delete,
424 - onPressed: () => _deleteModel(context, ref),
425 - ),
426 - ] else if (isDownloading) ...[
427 - _IconButton(
428 - icon: HugeIcon(icon:
429 - HugeIcons.strokeRoundedCancel01,
430 - size: 16,
431 - color: isDark
432 - ? AppColors.darkMutedText
433 - : AppColors.lightMutedText,
434 - ),
435 - tooltip: l10n.cancel,
436 - onPressed: () => ref
437 - .read(foregroundDownloadNotifierProvider.notifier)
438 - .cancelDownload(model.id),
439 - ),
440 - ] else ...[
441 - _IconButton(
442 - icon: HugeIcon(icon:
443 - HugeIcons.strokeRoundedCloudDownload,
444 - size: 18,
445 - color: isDark
446 - ? AppColors.darkMutedText
447 - : AppColors.lightMutedText,
448 - ),
449 - tooltip: l10n.download,
450 - onPressed: () async {
451 - final result = await ref
452 - .read(foregroundDownloadNotifierProvider.notifier)
453 - .startDownload(model.id);
454 - if (result == 'missing_huggingface_token' &&
455 - context.mounted) {
456 - ScaffoldMessenger.of(context).showSnackBar(
457 - SnackBar(
458 - content: Text(l10n.model_missing_huggingface_token),
459 - duration: const Duration(seconds: 6),
460 - action: SnackBarAction(
461 - label: l10n.settings_title,
462 - onPressed: () => context.push(AppRoutes.settings),
463 - ),
464 - ),
465 - );
466 - }
467 - },
468 - ),
469 - const SizedBox(width: 4),
470 - Text(
471 - l10n.not_downloaded,
472 - style: TextStyle(
473 - fontSize: 11,
474 - color: isDark
475 - ? AppColors.darkMutedText
476 - : AppColors.lightMutedText,
500 + const SizedBox(height: 8),
501 + ClipRRect(
502 + borderRadius: BorderRadius.circular(2),
503 + child: LinearProgressIndicator(
504 + minHeight: 3,
505 + backgroundColor: accent.withValues(alpha: 0.15),
506 + valueColor: AlwaysStoppedAnimation<Color>(accent),
507 ),
508 ),
509 ],
480 - if (isDownloaded && isSelected && isLoaded) ...[
481 - const SizedBox(width: 4),
482 - HugeIcon(icon: HugeIcons.strokeRoundedCheckmarkCircle01, color: accent, size: 22),
483 - ],
510 ],
511 ),
512 ),
lib/features/voice_mode/views/components/voice_visualizer.dart
+345 -141
@@ -1,28 +1,27 @@
1 import 'dart:math' as math;
2 +import 'dart:ui';
3
4 import 'package:flutter/material.dart';
5
6 import '../../providers/voice_mode_provider.dart';
7 import '../../voice_mode_palette.dart';
8
8 -/// Real-time waveform visualizer.
9 +/// Real-time glowing orb visualizer with organic wave rings.
10 ///
10 -/// While [VoiceModePhase.listening] the bars animate from the live
11 -/// microphone level. While [VoiceModePhase.speaking] (AI playback) we
12 -/// drive a procedural envelope because we can't read amplitude from the
13 -/// TTS audio stream.
11 +/// Displays a 3D gradient spherical orb surrounded by fluid, morphing wave
12 +/// layers that pulse and rotate in response to speech (listening/speaking).
13 +/// Features frame-by-frame smoothing to prevent flickering during listening.
14 class VoiceVisualizer extends StatefulWidget {
15 final VoiceModePhase phase;
16 final double size;
17
18 - /// Current microphone input level in 0..1 (only meaningful while
19 - /// listening).
18 + /// Current microphone input level in 0..1 (listening phase).
19 final double micLevel;
20
21 const VoiceVisualizer({
22 super.key,
23 required this.phase,
25 - this.size = 220,
24 + this.size = 240,
25 this.micLevel = 0,
26 });
27
@@ -32,17 +31,15 @@ class VoiceVisualizer extends StatefulWidget {
31
32 class _VoiceVisualizerState extends State<VoiceVisualizer>
33 with SingleTickerProviderStateMixin {
35 - /// Always running — even during listening we need per-frame ticks so the
36 - /// procedural noise modulation keeps moving while the user speaks. During
37 - /// speaking the same controller drives the AI envelope.
34 late final AnimationController _ticker;
35 + double _smoothedMicLevel = 0.0;
36
37 @override
38 void initState() {
39 super.initState();
40 _ticker = AnimationController(
41 vsync: this,
45 - duration: const Duration(milliseconds: 1800),
42 + duration: const Duration(milliseconds: 4000),
43 )..repeat();
44 }
45
@@ -62,11 +59,18 @@ class _VoiceVisualizerState extends State<VoiceVisualizer>
59 child: AnimatedBuilder(
60 animation: _ticker,
61 builder: (context, _) {
62 + // Smooth the mic level frame-by-frame to eliminate jitter/flicker.
63 + final targetMic = widget.phase == VoiceModePhase.listening
64 + ? widget.micLevel.clamp(0.0, 1.0)
65 + : 0.0;
66 + _smoothedMicLevel =
67 + lerpDouble(_smoothedMicLevel, targetMic, 0.14) ?? 0.0;
68 +
69 return CustomPaint(
66 - painter: _WaveformPainter(
70 + painter: _OrbWavePainter(
71 phase: widget.phase,
68 - micLevel: widget.micLevel.clamp(0.0, 1.0),
69 - aiValue: _ticker.value,
72 + micLevel: _smoothedMicLevel,
73 + progress: _ticker.value,
74 isDark: isDark,
75 ),
76 );
@@ -76,174 +80,374 @@ class _VoiceVisualizerState extends State<VoiceVisualizer>
80 }
81 }
82
79 -class _WaveformPainter extends CustomPainter {
80 - static const _barCount = 28;
81 -
83 +class _OrbWavePainter extends CustomPainter {
84 final VoiceModePhase phase;
85 final double micLevel;
84 - final double aiValue;
86 + final double progress;
87 final bool isDark;
88
87 - // Pre-allocated scratch values to keep the per-frame loop tight.
88 - static final Paint _barPaint = Paint()..strokeCap = StrokeCap.round;
89 - static final Paint _haloPaint = Paint()
90 - ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 28);
91 - static final Paint _corePaint = Paint()
92 - ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6);
93 -
94 - _WaveformPainter({
89 + _OrbWavePainter({
90 required this.phase,
91 required this.micLevel,
97 - required this.aiValue,
92 + required this.progress,
93 required this.isDark,
94 });
95
96 @override
97 void paint(Canvas canvas, Size size) {
98 final center = Offset(size.width / 2, size.height / 2);
99 + final baseRadius = size.width * 0.28;
100 + final time = progress * 2 * math.pi;
101 +
102 + final amp = _computeAmplitude();
103 final accent = VoiceModePalette.accentFor(phase, isDark: isDark);
104
106 - // The halo keeps the surface feeling alive even when there's no audio.
107 - _paintHalo(canvas, center, size, accent);
105 + // 1. Paint background ambient glow halo
106 + _paintHalo(canvas, center, baseRadius, amp, accent);
107 +
108 + // 2. Paint outer organic translucent wave rings
109 + _paintOuterWaveRings(canvas, center, baseRadius, time, amp);
110
111 + // 3. Paint main 3D gradient orb core with inner fluid waves & highlights
112 + _paintOrbCore(canvas, center, baseRadius, time, amp);
113 + }
114 +
115 + double _computeAmplitude() {
116 switch (phase) {
117 case VoiceModePhase.listening:
111 - _paintBars(canvas, size, accent, amp: micLevel);
118 + return micLevel;
119 case VoiceModePhase.speaking:
113 - _paintBars(canvas, size, accent, amp: _aiEnvelope());
120 + // Speech rhythm simulation with dual sine waves
121 + final wave = 0.5 + 0.5 * math.sin(progress * 2 * math.pi * 3.5);
122 + final breath = 0.4 + 0.6 * math.sin(progress * 2 * math.pi * 1.2);
123 + return (wave * breath).clamp(0.1, 1.0);
124 case VoiceModePhase.processing:
115 - _paintProcessingBars(canvas, size, accent);
125 + return 0.2 + 0.15 * math.sin(progress * 2 * math.pi * 2.0);
126 case VoiceModePhase.idle:
117 - _paintBars(canvas, size, accent, amp: 0.06 + 0.04 * aiValue);
127 + return 0.06 + 0.04 * math.sin(progress * 2 * math.pi);
128 case VoiceModePhase.error:
119 - _paintBars(canvas, size, accent, amp: 0.05);
129 + return 0.08 + 0.04 * math.sin(progress * 2 * math.pi * 1.5);
130 }
131 }
132
123 - void _paintBars(
133 + void _paintHalo(
134 Canvas canvas,
125 - Size size,
126 - Color color, {
127 - required double amp,
128 - }) {
129 - const half = _barCount ~/ 2;
130 - final width = size.width;
131 - final height = size.height;
132 - final centerY = height / 2;
133 - final maxBarHeight = height * 0.42;
134 - final minBarHeight = height * 0.04;
135 - final barWidth = (width / (_barCount * 2)).clamp(2.0, 4.0);
136 - final gap = (width - barWidth * _barCount * 2) / (_barCount * 2 + 1);
137 - final baseX = centerY - (half * (barWidth + gap)) + gap / 2;
138 - final stride = barWidth + gap;
139 - final t = aiValue * 2 * math.pi;
140 - final ampScale = 0.45 + amp * 1.35;
141 -
142 - _barPaint.strokeWidth = barWidth;
143 -
144 - for (int i = 0; i < _barCount; i++) {
145 - final dist = ((i - half).abs() / half).clamp(0.0, 1.0);
146 - final envelope = math.cos(dist * math.pi / 2);
147 - final noise = _waveNoise(i - half, phase, t);
148 - final normalised = (envelope * ampScale * (0.6 + 0.4 * noise))
149 - .clamp(0.0, 1.0);
150 - final barHeight = minBarHeight + (maxBarHeight - minBarHeight) * normalised;
151 -
152 - _barPaint.color = color.withValues(alpha: 0.35 + 0.55 * normalised);
153 - canvas.drawLine(
154 - Offset(baseX + i * stride, centerY - barHeight / 2),
155 - Offset(baseX + i * stride, centerY + barHeight / 2),
156 - _barPaint,
157 - );
158 - }
159 -
160 - _corePaint.color = color.withValues(alpha: 0.6 + 0.4 * amp);
161 - canvas.drawCircle(
162 - Offset(width / 2, height / 2),
163 - 4 + 6 * amp,
164 - _corePaint,
165 - );
135 + Offset center,
136 + double baseRadius,
137 + double amp,
138 + Color accent,
139 + ) {
140 + final haloRadius = baseRadius * (1.4 + 0.3 * amp);
141 + final isError = phase == VoiceModePhase.error;
142 + final haloPaint = Paint()
143 + ..maskFilter = MaskFilter.blur(BlurStyle.normal, baseRadius * 0.6)
144 + ..shader = RadialGradient(
145 + colors: isError
146 + ? [
147 + const Color(0xFFEF4444).withValues(alpha: isDark ? 0.25 : 0.20),
148 + const Color(0xFFF87171).withValues(alpha: 0.12),
149 + const Color(0xFFFECDD3).withValues(alpha: 0.06),
150 + Colors.transparent,
151 + ]
152 + : [
153 + accent.withValues(alpha: isDark ? 0.35 : 0.28),
154 + const Color(0xFFE879F9).withValues(alpha: 0.20),
155 + const Color(0xFF38BDF8).withValues(alpha: 0.12),
156 + Colors.transparent,
157 + ],
158 + stops: const [0.0, 0.4, 0.7, 1.0],
159 + ).createShader(Rect.fromCircle(center: center, radius: haloRadius));
160 +
161 + canvas.drawCircle(center, haloRadius, haloPaint);
162 }
163
168 - void _paintProcessingBars(Canvas canvas, Size size, Color color) {
169 - const half = _barCount ~/ 2;
170 - final centerY = size.height / 2;
171 - final maxBarHeight = size.height * 0.28;
172 - final minBarHeight = size.height * 0.03;
173 - final barWidth = (size.width / (_barCount * 2)).clamp(2.0, 4.0);
174 - final gap = (size.width - barWidth * _barCount * 2) / (_barCount * 2 + 1);
175 - final baseX = centerY - (half * (barWidth + gap)) + gap / 2;
176 - final stride = barWidth + gap;
177 -
178 - _barPaint.strokeWidth = barWidth;
179 -
180 - for (int i = 0; i < _barCount; i++) {
181 - final dist = ((i - half).abs() / half).clamp(0.0, 1.0);
182 - final progress = ((aiValue + dist * 0.6) % 1.0);
183 - final wave = math.sin(progress * math.pi);
184 - final h = minBarHeight + (maxBarHeight - minBarHeight) * wave;
185 -
186 - _barPaint.color = color.withValues(alpha: 0.25 + 0.55 * wave);
187 - canvas.drawLine(
188 - Offset(baseX + i * stride, centerY - h / 2),
189 - Offset(baseX + i * stride, centerY + h / 2),
190 - _barPaint,
191 - );
164 + void _paintOuterWaveRings(
165 + Canvas canvas,
166 + Offset center,
167 + double baseRadius,
168 + double time,
169 + double amp,
170 + ) {
171 + final isError = phase == VoiceModePhase.error;
172 +
173 + // 3 distinct wave rings with different rotation speeds, harmonic frequencies, and soft pastel gradients
174 + final rings = [
175 + _RingConfig(
176 + baseScale: 1.12,
177 + waveAmp: 14.0 * (0.3 + 0.7 * amp),
178 + frequency: 3,
179 + rotSpeed: 0.6,
180 + timeScale: 1.2,
181 + colors: isError
182 + ? [
183 + const Color(0xFFFECDD3).withValues(alpha: 0.40),
184 + const Color(0xFFF87171).withValues(alpha: 0.30),
185 + const Color(0xFFEF4444).withValues(alpha: 0.18),
186 + ]
187 + : [
188 + const Color(0xFF38BDF8).withValues(alpha: 0.45),
189 + const Color(0xFFE879F9).withValues(alpha: 0.35),
190 + const Color(0xFF818CF8).withValues(alpha: 0.25),
191 + ],
192 + strokeColor: isError
193 + ? const Color(0xFFF87171).withValues(alpha: 0.50)
194 + : const Color(0xFF38BDF8).withValues(alpha: 0.6),
195 + ),
196 + _RingConfig(
197 + baseScale: 1.22,
198 + waveAmp: 18.0 * (0.3 + 0.7 * amp),
199 + frequency: 2,
200 + rotSpeed: -0.8,
201 + timeScale: 1.6,
202 + colors: isError
203 + ? [
204 + const Color(0xFFFCA5A5).withValues(alpha: 0.35),
205 + const Color(0xFFEF4444).withValues(alpha: 0.25),
206 + const Color(0xFFDC2626).withValues(alpha: 0.15),
207 + ]
208 + : [
209 + const Color(0xFFF472B6).withValues(alpha: 0.40),
210 + const Color(0xFFA78BFA).withValues(alpha: 0.30),
211 + const Color(0xFF34D399).withValues(alpha: 0.20),
212 + ],
213 + strokeColor: isError
214 + ? const Color(0xFFFCA5A5).withValues(alpha: 0.45)
215 + : const Color(0xFFF472B6).withValues(alpha: 0.55),
216 + ),
217 + _RingConfig(
218 + baseScale: 1.34,
219 + waveAmp: 22.0 * (0.2 + 0.8 * amp),
220 + frequency: 4,
221 + rotSpeed: 0.4,
222 + timeScale: 0.9,
223 + colors: isError
224 + ? [
225 + const Color(0xFFF87171).withValues(alpha: 0.30),
226 + const Color(0xFFFECDD3).withValues(alpha: 0.22),
227 + const Color(0xFFEF4444).withValues(alpha: 0.12),
228 + ]
229 + : [
230 + const Color(0xFF818CF8).withValues(alpha: 0.35),
231 + const Color(0xFF38BDF8).withValues(alpha: 0.30),
232 + const Color(0xFFFFAEE2).withValues(alpha: 0.20),
233 + ],
234 + strokeColor: isError
235 + ? const Color(0xFFF87171).withValues(alpha: 0.40)
236 + : const Color(0xFF818CF8).withValues(alpha: 0.5),
237 + ),
238 + ];
239 +
240 + for (final ring in rings) {
241 + final path = Path();
242 + const points = 72;
243 + final ringRadius = baseRadius * ring.baseScale;
244 +
245 + for (int i = 0; i <= points; i++) {
246 + final theta = (i / points) * 2 * math.pi;
247 + final rotAngle = time * ring.rotSpeed;
248 + final angle = theta + rotAngle;
249 +
250 + final wave1 = math.sin(theta * ring.frequency + time * ring.timeScale);
251 + final wave2 = math.cos(
252 + theta * (ring.frequency + 1) - time * ring.timeScale * 0.7,
253 + );
254 + final r = ringRadius + (wave1 * 0.6 + wave2 * 0.4) * ring.waveAmp;
255 +
256 + final x = center.dx + r * math.cos(angle);
257 + final y = center.dy + r * math.sin(angle);
258 +
259 + if (i == 0) {
260 + path.moveTo(x, y);
261 + } else {
262 + path.lineTo(x, y);
263 + }
264 + }
265 + path.close();
266 +
267 + // Draw translucent gradient fill
268 + final bounds = path.getBounds();
269 + final fillPaint = Paint()
270 + ..style = PaintingStyle.fill
271 + ..shader = LinearGradient(
272 + begin: Alignment.topLeft,
273 + end: Alignment.bottomRight,
274 + colors: ring.colors,
275 + ).createShader(bounds);
276 + canvas.drawPath(path, fillPaint);
277 +
278 + // Draw glowing stroke outline
279 + final strokePaint = Paint()
280 + ..style = PaintingStyle.stroke
281 + ..strokeWidth = 1.6
282 + ..color = ring.strokeColor;
283 + canvas.drawPath(path, strokePaint);
284 }
193 -
194 - final breath = 0.5 + 0.5 * math.sin(aiValue * 2 * math.pi);
195 - _corePaint
196 - ..color = color.withValues(alpha: 0.5 + 0.4 * breath)
197 - ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8);
198 - canvas.drawCircle(
199 - Offset(size.width / 2, size.height / 2),
200 - 6 + 4 * breath,
201 - _corePaint,
202 - );
203 - // Restore the original blur radius for the next caller.
204 - _corePaint.maskFilter = const MaskFilter.blur(BlurStyle.normal, 6);
285 }
286
207 - void _paintHalo(
287 + void _paintOrbCore(
288 Canvas canvas,
289 Offset center,
210 - Size size,
211 - Color color,
290 + double baseRadius,
291 + double time,
292 + double amp,
293 ) {
213 - _haloPaint.color = color.withValues(alpha: isDark ? 0.18 : 0.12);
214 - canvas.drawCircle(center, size.width * 0.32, _haloPaint);
294 + final orbRadius = baseRadius * (0.95 + 0.15 * amp);
295 + final orbRect = Rect.fromCircle(center: center, radius: orbRadius);
296 + final isError = phase == VoiceModePhase.error;
297 +
298 + // Primary 3D gradient sphere shader with reduced opacity for error mode
299 + final orbGradient = RadialGradient(
300 + center: const Alignment(-0.35, -0.45),
301 + radius: 1.15,
302 + colors: isError
303 + ? [
304 + const Color(0xFFFECDD3).withValues(alpha: 0.65), // Translucent soft rose top
305 + const Color(0xFFF87171).withValues(alpha: 0.55), // Coral red
306 + const Color(0xFFEF4444).withValues(alpha: 0.45), // Soft red
307 + const Color(0xFFDC2626).withValues(alpha: 0.35), // Translucent crimson edge
308 + ]
309 + : const [
310 + Color(0xFFFFAEE2), // Light peach pink top highlight
311 + Color(0xFFF472B6), // Vibrant pink
312 + Color(0xFFE879F9), // Soft orchid / magenta
313 + Color(0xFFA78BFA), // Rich lavender purple
314 + Color(0xFF38BDF8), // Electric cyan bottom
315 + Color(0xFF0284C7), // Deep blue edge
316 + ],
317 + stops: isError
318 + ? const [0.0, 0.35, 0.70, 1.0]
319 + : const [0.0, 0.22, 0.45, 0.68, 0.88, 1.0],
320 + );
321 +
322 + final orbPaint = Paint()
323 + ..shader = orbGradient.createShader(orbRect);
324 +
325 + // Save layer for clipping inner liquid waves
326 + canvas.save();
327 + final orbPath = Path()..addOval(orbRect);
328 + canvas.clipPath(orbPath);
329 +
330 + // Draw main gradient sphere body
331 + canvas.drawCircle(center, orbRadius, orbPaint);
332 +
333 + // Draw inner morphing liquid wave shimmer
334 + _paintInnerLiquidWaves(canvas, center, orbRadius, time, amp);
335 +
336 + // Draw top specular 3D light reflection
337 + final specularPaint = Paint()
338 + ..shader = RadialGradient(
339 + center: const Alignment(-0.45, -0.55),
340 + radius: 0.65,
341 + colors: [
342 + Colors.white.withValues(alpha: 0.65),
343 + Colors.white.withValues(alpha: 0.20),
344 + Colors.white.withValues(alpha: 0.0),
345 + ],
346 + stops: const [0.0, 0.45, 1.0],
347 + ).createShader(orbRect);
348 +
349 + canvas.drawCircle(center, orbRadius, specularPaint);
350 +
351 + canvas.restore(); // Restore clip
352 +
353 + // Crisp outer edge glow ring
354 + final borderPaint = Paint()
355 + ..style = PaintingStyle.stroke
356 + ..strokeWidth = 1.8
357 + ..shader = LinearGradient(
358 + begin: Alignment.topLeft,
359 + end: Alignment.bottomRight,
360 + colors: isError
361 + ? [
362 + Colors.white.withValues(alpha: 0.60),
363 + const Color(0xFFF87171).withValues(alpha: 0.45),
364 + const Color(0xFFEF4444).withValues(alpha: 0.30),
365 + Colors.white.withValues(alpha: 0.20),
366 + ]
367 + : [
368 + Colors.white.withValues(alpha: 0.70),
369 + const Color(0xFF38BDF8).withValues(alpha: 0.50),
370 + const Color(0xFFE879F9).withValues(alpha: 0.40),
371 + Colors.white.withValues(alpha: 0.20),
372 + ],
373 + ).createShader(orbRect);
374 +
375 + canvas.drawCircle(center, orbRadius, borderPaint);
376 }
377
217 - /// Two-sine envelope that mimics natural speech rhythm.
218 - double _waveNoise(int barIndex, VoiceModePhase phase, double t) {
219 - final phaseOffset = barIndex * 0.45;
220 - switch (phase) {
221 - case VoiceModePhase.listening:
222 - return 0.5 + 0.5 * math.sin(t * 0.6 + phaseOffset);
223 - case VoiceModePhase.speaking:
224 - final a = math.sin(t * 1.3 + phaseOffset);
225 - final b = math.sin(t * 2.7 + phaseOffset * 1.7);
226 - return 0.5 + 0.35 * a + 0.25 * b;
227 - case VoiceModePhase.idle:
228 - return 0.5 + 0.5 * math.sin(t + phaseOffset);
229 - case VoiceModePhase.error:
230 - case VoiceModePhase.processing:
231 - return 0.5;
378 + void _paintInnerLiquidWaves(
379 + Canvas canvas,
380 + Offset center,
381 + double radius,
382 + double time,
383 + double amp,
384 + ) {
385 + final isError = phase == VoiceModePhase.error;
386 + final wavePath = Path();
387 + final yOffset = center.dy + radius * 0.15 * math.sin(time * 1.5);
388 + final waveHeight = radius * (0.25 + 0.3 * amp);
389 +
390 + wavePath.moveTo(center.dx - radius, center.dy + radius);
391 + wavePath.lineTo(center.dx - radius, yOffset);
392 +
393 + const steps = 40;
394 + for (int i = 0; i <= steps; i++) {
395 + final x = center.dx - radius + (i / steps) * (2 * radius);
396 + final normalizedX = (i / steps) * 2 * math.pi;
397 + final y = yOffset +
398 + math.sin(normalizedX * 2 + time * 2.5) * waveHeight * 0.5 +
399 + math.cos(normalizedX * 3 - time * 1.8) * waveHeight * 0.3;
400 + wavePath.lineTo(x, y);
401 }
233 - }
402
235 - double _aiEnvelope() {
236 - final t = aiValue;
237 - final wave = 0.55 + 0.45 * math.sin(t * 2 * math.pi * 4);
238 - final breath = 0.4 + 0.6 * math.sin(t * 2 * math.pi);
239 - return (wave * breath).clamp(0.05, 1.0);
403 + wavePath.lineTo(center.dx + radius, center.dy + radius);
404 + wavePath.close();
405 +
406 + final liquidPaint = Paint()
407 + ..shader = LinearGradient(
408 + begin: Alignment.topCenter,
409 + end: Alignment.bottomCenter,
410 + colors: isError
411 + ? [
412 + const Color(0xFFF87171).withValues(alpha: 0.30),
413 + const Color(0xFFFECDD3).withValues(alpha: 0.20),
414 + Colors.transparent,
415 + ]
416 + : [
417 + const Color(0xFF38BDF8).withValues(alpha: 0.35),
418 + const Color(0xFFE879F9).withValues(alpha: 0.25),
419 + Colors.transparent,
420 + ],
421 + ).createShader(Rect.fromCircle(center: center, radius: radius));
422 +
423 + canvas.drawPath(wavePath, liquidPaint);
424 }
425
426 @override
243 - bool shouldRepaint(covariant _WaveformPainter oldDelegate) {
427 + bool shouldRepaint(covariant _OrbWavePainter oldDelegate) {
428 return phase != oldDelegate.phase ||
429 micLevel != oldDelegate.micLevel ||
246 - aiValue != oldDelegate.aiValue ||
430 + progress != oldDelegate.progress ||
431 isDark != oldDelegate.isDark;
432 }
433 +}
434 +
435 +class _RingConfig {
436 + final double baseScale;
437 + final double waveAmp;
438 + final int frequency;
439 + final double rotSpeed;
440 + final double timeScale;
441 + final List<Color> colors;
442 + final Color strokeColor;
443 +
444 + _RingConfig({
445 + required this.baseScale,
446 + required this.waveAmp,
447 + required this.frequency,
448 + required this.rotSpeed,
449 + required this.timeScale,
450 + required this.colors,
451 + required this.strokeColor,
452 + });
453 }
\ No newline at end of file
lib/features/voice_mode/views/voice_mode_overlay.dart
+102 -25
@@ -188,31 +188,12 @@ class _VoiceModeOverlayState extends ConsumerState<VoiceModeOverlay> {
188 // rebuilds on streamed tokens or mic-level changes.
189 Positioned.fill(
190 child: RepaintBoundary(
191 - child: Stack(
192 - children: [
193 - AnimatedContainer(
194 - duration: const Duration(milliseconds: 600),
195 - decoration: BoxDecoration(
196 - gradient: RadialGradient(
197 - center: const Alignment(0, -0.2),
198 - radius: 1.1,
199 - colors: [
200 - accent.withValues(alpha: isDark ? 0.18 : 0.22),
201 - bgColor,
202 - ],
203 - stops: const [0.0, 0.85],
204 - ),
205 - ),
206 - ),
207 - Positioned.fill(
208 - child: BackdropFilter(
209 - filter: ImageFilter.blur(sigmaX: 40, sigmaY: 40),
210 - child: Container(
211 - color: bgColor.withValues(alpha: 0.55),
212 - ),
213 - ),
214 - ),
215 - ],
191 + child: _PulsingBackgroundGlow(
192 + phase: state.phase,
193 + micLevel: state.micLevel,
194 + isDark: isDark,
195 + accent: accent,
196 + bgColor: bgColor,
197 ),
198 ),
199 ),
@@ -375,3 +356,99 @@ class _CloseButton extends StatelessWidget {
356 );
357 }
358 }
359 +
360 +class _PulsingBackgroundGlow extends StatefulWidget {
361 + final VoiceModePhase phase;
362 + final double micLevel;
363 + final bool isDark;
364 + final Color accent;
365 + final Color bgColor;
366 +
367 + const _PulsingBackgroundGlow({
368 + required this.phase,
369 + required this.micLevel,
370 + required this.isDark,
371 + required this.accent,
372 + required this.bgColor,
373 + });
374 +
375 + @override
376 + State<_PulsingBackgroundGlow> createState() => _PulsingBackgroundGlowState();
377 +}
378 +
379 +class _PulsingBackgroundGlowState extends State<_PulsingBackgroundGlow>
380 + with SingleTickerProviderStateMixin {
381 + late final AnimationController _ticker;
382 +
383 + @override
384 + void initState() {
385 + super.initState();
386 + _ticker = AnimationController(
387 + vsync: this,
388 + duration: const Duration(milliseconds: 3000),
389 + )..repeat(reverse: true);
390 + }
391 +
392 + @override
393 + void dispose() {
394 + _ticker.dispose();
395 + super.dispose();
396 + }
397 +
398 + @override
399 + Widget build(BuildContext context) {
400 + return AnimatedBuilder(
401 + animation: _ticker,
402 + builder: (context, _) {
403 + final breath = _ticker.value;
404 + final double pulse;
405 +
406 + switch (widget.phase) {
407 + case VoiceModePhase.listening:
408 + pulse = 0.15 + 0.5 * widget.micLevel.clamp(0.0, 1.0) + 0.1 * breath;
409 + case VoiceModePhase.speaking:
410 + pulse = 0.20 + 0.25 * breath;
411 + case VoiceModePhase.error:
412 + pulse = 0.18 + 0.22 * breath;
413 + case VoiceModePhase.processing:
414 + pulse = 0.16 + 0.18 * breath;
415 + case VoiceModePhase.idle:
416 + pulse = 0.06 + 0.06 * breath;
417 + }
418 +
419 + final baseAlpha = widget.isDark ? 0.12 : 0.16;
420 + final alpha = (baseAlpha + pulse * 0.16).clamp(0.05, 0.40);
421 + final radius = 0.95 + pulse * 0.35;
422 +
423 + return Stack(
424 + children: [
425 + AnimatedContainer(
426 + duration: const Duration(milliseconds: 450),
427 + decoration: BoxDecoration(
428 + gradient: RadialGradient(
429 + center: const Alignment(0, -0.2),
430 + radius: radius,
431 + colors: [
432 + widget.accent.withValues(alpha: alpha),
433 + widget.accent.withValues(alpha: alpha * 0.35),
434 + widget.bgColor,
435 + ],
436 + stops: const [0.0, 0.55, 0.95],
437 + ),
438 + ),
439 + ),
440 + Positioned.fill(
441 + child: BackdropFilter(
442 + filter: ImageFilter.blur(sigmaX: 45, sigmaY: 45),
443 + child: Container(
444 + color: widget.bgColor.withValues(alpha: 0.50),
445 + ),
446 + ),
447 + ),
448 + ],
449 + );
450 + },
451 + );
452 + }
453 +}
454 +