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
+