5 files changed
+715
-341
assets/shaders/orb.frag
new
+141
@@ -0,0 +1,141 @@
1
+#version 460 core
2
+
3
+precision highp float;
4
+
5
+#include <flutter/runtime_effect.glsl>
6
+
7
+out vec4 fragColor;
8
+
9
+uniform vec2 uResolution;
10
+uniform float uTime;
11
+uniform float uMicLevel;
12
+uniform float uPhase; // 0=idle, 1=listening, 2=processing, 3=speaking, 4=error
13
+uniform vec4 uAccentColor;
14
+uniform vec4 uSecondaryColor;
15
+
16
+// 3D Simplex-style noise helper
17
+vec3 hash3(vec3 p) {
18
+ p = vec3(dot(p, vec3(127.1, 311.7, 74.7)),
19
+ dot(p, vec3(269.5, 183.3, 246.1)),
20
+ dot(p, vec3(113.5, 271.9, 124.6)));
21
+ return -1.0 + 2.0 * fract(sin(p) * 43758.5453123);
22
+}
23
+
24
+float noise3d(vec3 p) {
25
+ vec3 i = floor(p);
26
+ vec3 f = fract(p);
27
+ vec3 u = f * f * (3.0 - 2.0 * f);
28
+
29
+ return mix(mix(mix(dot(hash3(i + vec3(0,0,0)), f - vec3(0,0,0)),
30
+ dot(hash3(i + vec3(1,0,0)), f - vec3(1,0,0)), u.x),
31
+ mix(dot(hash3(i + vec3(0,1,0)), f - vec3(0,1,0)),
32
+ dot(hash3(i + vec3(1,1,0)), f - vec3(1,1,0)), u.x), u.y),
33
+ mix(mix(dot(hash3(i + vec3(0,0,1)), f - vec3(0,0,1)),
34
+ dot(hash3(i + vec3(1,0,1)), f - vec3(1,0,1)), u.x),
35
+ mix(dot(hash3(i + vec3(0,1,1)), f - vec3(0,1,1)),
36
+ dot(hash3(i + vec3(1,1,1)), f - vec3(1,1,1)), u.x), u.y), u.z);
37
+}
38
+
39
+float fbm3d(vec3 p) {
40
+ float val = 0.0;
41
+ float amp = 0.5;
42
+ vec3 q = p;
43
+ for (int i = 0; i < 4; i++) {
44
+ val += amp * noise3d(q);
45
+ q *= 2.03;
46
+ amp *= 0.5;
47
+ }
48
+ return val;
49
+}
50
+
51
+void main() {
52
+ vec2 fragCoord = FlutterFragCoord().xy;
53
+ vec2 st = (fragCoord - uResolution * 0.5) / min(uResolution.x, uResolution.y);
54
+ float dist = length(st);
55
+
56
+ // Smooth outer boundary mask (no box artifact)
57
+ float radialMask = smoothstep(0.48, 0.36, dist);
58
+
59
+ float speed = (uPhase == 2.0) ? 2.2 : (uPhase == 3.0 ? 1.6 : 1.0);
60
+ float time = uTime * speed;
61
+
62
+ // Dynamic sphere radius based on mic level & phase
63
+ float baseRadius = 0.30;
64
+ if (uPhase == 1.0) { // Listening
65
+ baseRadius += 0.08 * uMicLevel;
66
+ } else if (uPhase == 2.0) { // Processing pulse
67
+ baseRadius += 0.03 * sin(time * 6.0);
68
+ } else if (uPhase == 3.0) { // Speaking
69
+ baseRadius += 0.05 * sin(time * 4.0) * cos(time * 2.0);
70
+ } else if (uPhase == 4.0) { // Error
71
+ baseRadius = 0.25;
72
+ }
73
+
74
+ vec3 col = vec3(0.0);
75
+ float alpha = 0.0;
76
+
77
+ float rNorm = dist / baseRadius;
78
+
79
+ if (rNorm <= 1.05) {
80
+ // Calculate 3D sphere surface normal
81
+ float pX = st.x / baseRadius;
82
+ float pY = st.y / baseRadius;
83
+ float r2 = clamp(pX * pX + pY * pY, 0.0, 1.0);
84
+ float pZ = sqrt(1.0 - r2);
85
+
86
+ vec3 N = vec3(pX, pY, pZ);
87
+
88
+ // 3D rotation matrix for swirling surface coordinates
89
+ float rotAngle = time * 0.7;
90
+ float cosA = cos(rotAngle);
91
+ float sinA = sin(rotAngle);
92
+ vec3 rotN = vec3(
93
+ N.x * cosA - N.z * sinA,
94
+ N.y * cosA + N.x * sinA * 0.3,
95
+ N.x * sinA + N.z * cosA
96
+ );
97
+
98
+ // 3D curved swirl ribbons wrapping along sphere surface
99
+ float swirlTheta = atan(rotN.z, rotN.x);
100
+ float noiseVal = fbm3d(rotN * 3.0 + vec3(0.0, 0.0, time * 0.4));
101
+
102
+ float ribbonPattern = sin(rotN.y * 14.0 + swirlTheta * 4.0 + time * 2.0 + noiseVal * 4.0);
103
+ float silkRibbons = smoothstep(-0.25, 0.75, ribbonPattern);
104
+
105
+ // Soft internal nebula glow
106
+ float innerGlow = pow(pZ, 0.85);
107
+
108
+ // Fresnel rim reflection (translucent crystal edge)
109
+ float fresnel = pow(1.0 - pZ, 2.2);
110
+
111
+ // Soft top-left specular highlight
112
+ vec3 lightDir = normalize(vec3(-0.35, -0.45, 0.82));
113
+ float spec = pow(max(0.0, dot(N, lightDir)), 14.0);
114
+
115
+ // Color blending for silky crystal orb
116
+ vec3 coreBase = mix(uAccentColor.rgb, uSecondaryColor.rgb, clamp(rotN.y * 0.5 + 0.5, 0.0, 1.0));
117
+ vec3 pinkHighlight = vec3(0.96, 0.65, 0.98); // Soft pink center glow
118
+ vec3 whiteSilk = vec3(0.96, 0.95, 1.0); // Pearlescent silk bands
119
+
120
+ vec3 surfaceColor = mix(coreBase, pinkHighlight, innerGlow * 0.45);
121
+ surfaceColor = mix(surfaceColor, whiteSilk, silkRibbons * 0.42);
122
+ surfaceColor += whiteSilk * spec * 0.65; // Specular sheen
123
+ surfaceColor = mix(surfaceColor, whiteSilk, fresnel * 0.50); // Rim glow
124
+
125
+ // Antialiased sphere edge transition
126
+ float edgeAlpha = smoothstep(1.05, 0.92, rNorm);
127
+
128
+ col = surfaceColor * edgeAlpha;
129
+ alpha = edgeAlpha * 0.92;
130
+ }
131
+
132
+ // Soft Atmospheric Halo Glow around the crystal sphere
133
+ float haloDist = max(0.0, rNorm - 0.85);
134
+ float outerGlow = exp(-haloDist * 4.5) * 0.55 * radialMask;
135
+ vec3 haloColor = mix(uAccentColor.rgb, vec3(0.92, 0.88, 1.0), 0.3) * outerGlow;
136
+
137
+ vec3 finalColor = col + haloColor;
138
+ float finalAlpha = clamp(alpha + outerGlow, 0.0, 1.0) * radialMask;
139
+
140
+ fragColor = vec4(finalColor * finalAlpha, finalAlpha);
141
+}
lib/features/voice_mode/views/components/voice_visualizer.dart
+366
-193
@@ -6,11 +6,15 @@ import 'package:flutter/material.dart';
6
import '../../providers/voice_mode_provider.dart';
7
import '../../voice_mode_palette.dart';
8
9
-/// Real-time glowing orb visualizer with organic wave rings.
9
+/// Premium 3D glowing orb visualizer with hardware-accelerated GLSL shader
10
+/// support and high-performance multi-layer CustomPainter fallback.
11
///
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.
12
+/// Features:
13
+/// - 4-tier layered glow architecture with additive (`BlendMode.plus`) wave intersections.
14
+/// - Fluid cubic Bezier wave morphing driven by asynchronous harmonic rotation.
15
+/// - Exponential moving average audio reactivity smoothing for jitter-free response.
16
+/// - Smooth phase state transition cross-fading (450ms color & physics interpolation).
17
+/// - Physics-based state behaviors: elastic snap for processing, deflation + shake for error.
18
class VoiceVisualizer extends StatefulWidget {
19
final VoiceModePhase phase;
20
final double size;
@@ -30,22 +34,63 @@ class VoiceVisualizer extends StatefulWidget {
34
}
35
36
class _VoiceVisualizerState extends State<VoiceVisualizer>
33
- with SingleTickerProviderStateMixin {
37
+ with TickerProviderStateMixin {
38
late final AnimationController _ticker;
39
+ late final AnimationController _transitionController;
40
+
41
+ VoiceModePhase _oldPhase = VoiceModePhase.idle;
42
+ VoiceModePhase _currentPhase = VoiceModePhase.idle;
43
+
44
double _smoothedMicLevel = 0.0;
45
+ FragmentProgram? _shaderProgram;
46
47
@override
48
void initState() {
49
super.initState();
50
+ _oldPhase = widget.phase;
51
+ _currentPhase = widget.phase;
52
+
53
_ticker = AnimationController(
54
vsync: this,
55
duration: const Duration(milliseconds: 4000),
56
)..repeat();
57
+
58
+ _transitionController = AnimationController(
59
+ vsync: this,
60
+ duration: const Duration(milliseconds: 450),
61
+ value: 1.0,
62
+ );
63
+
64
+ _loadShader();
65
+ }
66
+
67
+ Future<void> _loadShader() async {
68
+ try {
69
+ final program = await FragmentProgram.fromAsset('assets/shaders/orb.frag');
70
+ if (mounted) {
71
+ setState(() {
72
+ _shaderProgram = program;
73
+ });
74
+ }
75
+ } catch (_) {
76
+ // Shader unavailable or uncompiled on target platform; falls back gracefully to CustomPainter.
77
+ }
78
+ }
79
+
80
+ @override
81
+ void didUpdateWidget(covariant VoiceVisualizer oldWidget) {
82
+ super.didUpdateWidget(oldWidget);
83
+ if (oldWidget.phase != widget.phase) {
84
+ _oldPhase = oldWidget.phase;
85
+ _currentPhase = widget.phase;
86
+ _transitionController.forward(from: 0.0);
87
+ }
88
}
89
90
@override
91
void dispose() {
92
_ticker.dispose();
93
+ _transitionController.dispose();
94
super.dispose();
95
}
96
@@ -57,21 +102,29 @@ class _VoiceVisualizerState extends State<VoiceVisualizer>
102
width: widget.size,
103
height: widget.size,
104
child: AnimatedBuilder(
60
- animation: _ticker,
105
+ animation: Listenable.merge([_ticker, _transitionController]),
106
builder: (context, _) {
107
// Smooth the mic level frame-by-frame to eliminate jitter/flicker.
108
final targetMic = widget.phase == VoiceModePhase.listening
109
? widget.micLevel.clamp(0.0, 1.0)
110
: 0.0;
111
_smoothedMicLevel =
67
- lerpDouble(_smoothedMicLevel, targetMic, 0.14) ?? 0.0;
112
+ lerpDouble(_smoothedMicLevel, targetMic, 0.12) ?? 0.0;
113
+
114
+ final transitionProgress = CurvedAnimation(
115
+ parent: _transitionController,
116
+ curve: Curves.easeInOutCubic,
117
+ ).value;
118
119
return CustomPaint(
120
painter: _OrbWavePainter(
71
- phase: widget.phase,
121
+ oldPhase: _oldPhase,
122
+ targetPhase: _currentPhase,
123
+ transitionProgress: transitionProgress,
124
micLevel: _smoothedMicLevel,
125
progress: _ticker.value,
126
isDark: isDark,
127
+ shaderProgram: _shaderProgram,
128
),
129
);
130
},
@@ -81,295 +134,417 @@ class _VoiceVisualizerState extends State<VoiceVisualizer>
134
}
135
136
class _OrbWavePainter extends CustomPainter {
84
- final VoiceModePhase phase;
137
+ final VoiceModePhase oldPhase;
138
+ final VoiceModePhase targetPhase;
139
+ final double transitionProgress;
140
final double micLevel;
141
final double progress;
142
final bool isDark;
143
+ final FragmentProgram? shaderProgram;
144
145
_OrbWavePainter({
90
- required this.phase,
146
+ required this.oldPhase,
147
+ required this.targetPhase,
148
+ required this.transitionProgress,
149
required this.micLevel,
150
required this.progress,
151
required this.isDark,
152
+ this.shaderProgram,
153
});
154
155
@override
156
void paint(Canvas canvas, Size size) {
98
- final center = Offset(size.width / 2, size.height / 2);
99
- final baseRadius = size.width * 0.28;
157
+ final rawCenter = Offset(size.width / 2, size.height / 2);
158
final time = progress * 2 * math.pi;
159
102
- final amp = _computeAmplitude();
103
- final accent = VoiceModePalette.accentFor(phase, isDark: isDark);
160
+ // 1. Calculate Phase Colors with Smooth 450ms Cross-Fade
161
+ final oldAccent = VoiceModePalette.accentFor(oldPhase, isDark: isDark);
162
+ final targetAccent = VoiceModePalette.accentFor(targetPhase, isDark: isDark);
163
+ final accent = Color.lerp(oldAccent, targetAccent, transitionProgress)!;
164
+
165
+ final oldSecondary = VoiceModePalette.secondaryFor(oldPhase, isDark: isDark);
166
+ final targetSecondary = VoiceModePalette.secondaryFor(targetPhase, isDark: isDark);
167
+ final secondary = Color.lerp(oldSecondary, targetSecondary, transitionProgress)!;
168
+
169
+ // 2. Physics & Scale State Changes
170
+ final oldAmp = _computeAmplitudeFor(oldPhase, progress, micLevel);
171
+ final targetAmp = _computeAmplitudeFor(targetPhase, progress, micLevel);
172
+ final amp = lerpDouble(oldAmp, targetAmp, transitionProgress)!;
173
+
174
+ // Base radius scale factor
175
+ double scaleFactor = 1.0;
176
+ Offset center = rawCenter;
177
+
178
+ // Error Deflation & Horizontal Shake Oscillation
179
+ if (targetPhase == VoiceModePhase.error || oldPhase == VoiceModePhase.error) {
180
+ final errorWeight = targetPhase == VoiceModePhase.error
181
+ ? transitionProgress
182
+ : (1.0 - transitionProgress);
183
+ scaleFactor = lerpDouble(1.0, 0.84, errorWeight)!;
184
+ final shake = math.sin(time * 7.5) * 6.5 * errorWeight;
185
+ center = Offset(rawCenter.dx + shake, rawCenter.dy);
186
+ }
187
+
188
+ // Thinking / Processing Inward Elastic Snap Pulse
189
+ if (targetPhase == VoiceModePhase.processing && transitionProgress < 1.0) {
190
+ final elasticSnap = math.sin(transitionProgress * math.pi) * 0.14;
191
+ scaleFactor -= elasticSnap;
192
+ }
193
105
- // 1. Paint background ambient glow halo
106
- _paintHalo(canvas, center, baseRadius, amp, accent);
194
+ final baseRadius = (size.width * 0.27) * scaleFactor;
195
+
196
+ // 3. Render GPU Shader if Available
197
+ if (shaderProgram != null) {
198
+ final shader = shaderProgram!.fragmentShader();
199
+ shader.setFloat(0, size.width);
200
+ shader.setFloat(1, size.height);
201
+ shader.setFloat(2, time);
202
+ shader.setFloat(3, micLevel);
203
+ shader.setFloat(4, _phaseToDouble(targetPhase));
204
+
205
+ shader.setFloat(5, accent.r);
206
+ shader.setFloat(6, accent.g);
207
+ shader.setFloat(7, accent.b);
208
+ shader.setFloat(8, accent.a);
209
+
210
+ shader.setFloat(9, secondary.r);
211
+ shader.setFloat(10, secondary.g);
212
+ shader.setFloat(11, secondary.b);
213
+ shader.setFloat(12, secondary.a);
214
+
215
+ final shaderPaint = Paint()..shader = shader;
216
+ canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), shaderPaint);
217
+ return;
218
+ }
219
108
- // 2. Paint outer organic translucent wave rings
109
- _paintOuterWaveRings(canvas, center, baseRadius, time, amp);
220
+ // 4. CustomPainter Rendering Pipeline
221
+ // A. Multi-Tier Layered Ambient Glow Halos
222
+ _paintMultiLayerHalo(canvas, center, baseRadius, time, amp, accent, secondary);
223
+
224
+ // B. Outer Organic Translucent Wave Rings with Additive Blend Modes
225
+ _paintAdditiveOuterWaveRings(
226
+ canvas,
227
+ center,
228
+ baseRadius,
229
+ time,
230
+ amp,
231
+ accent,
232
+ secondary,
233
+ size,
234
+ );
235
+
236
+ // C. Central 3D Gradient Orb Core with specular light & inner liquid shimmer
237
+ _paint3DOrbCore(canvas, center, baseRadius, time, amp, accent, secondary);
238
+ }
239
111
- // 3. Paint main 3D gradient orb core with inner fluid waves & highlights
112
- _paintOrbCore(canvas, center, baseRadius, time, amp);
240
+ double _phaseToDouble(VoiceModePhase phase) {
241
+ switch (phase) {
242
+ case VoiceModePhase.idle:
243
+ return 0.0;
244
+ case VoiceModePhase.listening:
245
+ return 1.0;
246
+ case VoiceModePhase.processing:
247
+ return 2.0;
248
+ case VoiceModePhase.speaking:
249
+ return 3.0;
250
+ case VoiceModePhase.error:
251
+ return 4.0;
252
+ }
253
}
254
115
- double _computeAmplitude() {
255
+ double _computeAmplitudeFor(VoiceModePhase phase, double p, double mic) {
256
switch (phase) {
257
case VoiceModePhase.listening:
118
- return micLevel;
258
+ return mic;
259
case VoiceModePhase.speaking:
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);
260
+ final wave = 0.5 + 0.5 * math.sin(p * 2 * math.pi * 3.8);
261
+ final breath = 0.45 + 0.55 * math.sin(p * 2 * math.pi * 1.4);
262
+ return (wave * breath).clamp(0.12, 1.0);
263
case VoiceModePhase.processing:
125
- return 0.2 + 0.15 * math.sin(progress * 2 * math.pi * 2.0);
264
+ return 0.22 + 0.18 * math.sin(p * 2 * math.pi * 3.0);
265
case VoiceModePhase.idle:
127
- return 0.06 + 0.04 * math.sin(progress * 2 * math.pi);
266
+ return 0.06 + 0.04 * math.sin(p * 2 * math.pi);
267
case VoiceModePhase.error:
129
- return 0.08 + 0.04 * math.sin(progress * 2 * math.pi * 1.5);
268
+ return 0.08 + 0.05 * math.sin(p * 2 * math.pi * 1.8);
269
}
270
}
271
133
- void _paintHalo(
272
+ void _paintMultiLayerHalo(
273
Canvas canvas,
274
Offset center,
275
double baseRadius,
276
+ double time,
277
double amp,
278
Color accent,
279
+ Color secondary,
280
) {
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)
281
+ final breathingGlow = 0.35 + 0.15 * math.sin(time * 0.7);
282
+
283
+ // Layer 1: Atmospheric Wide Ambient Halo
284
+ final wideRadius = baseRadius * (1.75 + 0.35 * amp);
285
+ final widePaint = Paint()
286
+ ..maskFilter = MaskFilter.blur(BlurStyle.normal, baseRadius * 0.85)
287
+ ..shader = RadialGradient(
288
+ colors: [
289
+ accent.withValues(alpha: (isDark ? 0.28 : 0.22) * breathingGlow),
290
+ secondary.withValues(alpha: 0.14 * breathingGlow),
291
+ accent.withValues(alpha: 0.06),
292
+ Colors.transparent,
293
+ ],
294
+ stops: const [0.0, 0.45, 0.75, 1.0],
295
+ ).createShader(Rect.fromCircle(center: center, radius: wideRadius));
296
+ canvas.drawCircle(center, wideRadius, widePaint);
297
+
298
+ // Layer 2: Saturated Mid-Glow Aura
299
+ final midRadius = baseRadius * (1.4 + 0.25 * amp);
300
+ final midPaint = Paint()
301
+ ..maskFilter = MaskFilter.blur(BlurStyle.normal, baseRadius * 0.48)
302
+ ..shader = RadialGradient(
303
+ colors: [
304
+ secondary.withValues(alpha: isDark ? 0.38 : 0.30),
305
+ accent.withValues(alpha: 0.22),
306
+ Colors.transparent,
307
+ ],
308
+ stops: const [0.0, 0.55, 1.0],
309
+ ).createShader(Rect.fromCircle(center: center, radius: midRadius));
310
+ canvas.drawCircle(center, midRadius, midPaint);
311
+
312
+ // Layer 3: Tight High-Intensity Inner Core Glow
313
+ final tightRadius = baseRadius * (1.15 + 0.15 * amp);
314
+ final tightPaint = Paint()
315
+ ..maskFilter = MaskFilter.blur(BlurStyle.normal, baseRadius * 0.22)
316
..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);
317
+ colors: [
318
+ Colors.white.withValues(alpha: 0.40),
319
+ accent.withValues(alpha: 0.35),
320
+ Colors.transparent,
321
+ ],
322
+ stops: const [0.0, 0.4, 1.0],
323
+ ).createShader(Rect.fromCircle(center: center, radius: tightRadius));
324
+ canvas.drawCircle(center, tightRadius, tightPaint);
325
}
326
164
- void _paintOuterWaveRings(
327
+ void _paintAdditiveOuterWaveRings(
328
Canvas canvas,
329
Offset center,
330
double baseRadius,
331
double time,
332
double amp,
333
+ Color accent,
334
+ Color secondary,
335
+ Size canvasSize,
336
) {
171
- final isError = phase == VoiceModePhase.error;
172
-
173
- // 3 distinct wave rings with different rotation speeds, harmonic frequencies, and soft pastel gradients
337
+ // 4 Asynchronous Counter-Rotating Organic Wave Rings
338
final rings = [
339
_RingConfig(
176
- baseScale: 1.12,
177
- waveAmp: 14.0 * (0.3 + 0.7 * amp),
340
+ baseScale: 1.14,
341
+ waveAmp: 15.0 * (0.3 + 0.7 * amp),
342
frequency: 3,
179
- rotSpeed: 0.6,
343
+ rotSpeed: 0.65,
344
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),
345
+ colors: [
346
+ secondary.withValues(alpha: 0.45),
347
+ accent.withValues(alpha: 0.35),
348
+ const Color(0xFF818CF8).withValues(alpha: 0.20),
349
+ ],
350
+ strokeColor: secondary.withValues(alpha: 0.65),
351
),
352
_RingConfig(
197
- baseScale: 1.22,
198
- waveAmp: 18.0 * (0.3 + 0.7 * amp),
353
+ baseScale: 1.25,
354
+ waveAmp: 19.0 * (0.3 + 0.7 * amp),
355
frequency: 2,
200
- rotSpeed: -0.8,
356
+ rotSpeed: -0.90,
357
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),
358
+ colors: [
359
+ accent.withValues(alpha: 0.40),
360
+ secondary.withValues(alpha: 0.30),
361
+ const Color(0xFFF472B6).withValues(alpha: 0.18),
362
+ ],
363
+ strokeColor: accent.withValues(alpha: 0.60),
364
),
365
_RingConfig(
218
- baseScale: 1.34,
219
- waveAmp: 22.0 * (0.2 + 0.8 * amp),
366
+ baseScale: 1.36,
367
+ waveAmp: 23.0 * (0.2 + 0.8 * amp),
368
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),
369
+ rotSpeed: 0.45,
370
+ timeScale: 0.95,
371
+ colors: [
372
+ const Color(0xFF38BDF8).withValues(alpha: 0.35),
373
+ accent.withValues(alpha: 0.28),
374
+ secondary.withValues(alpha: 0.15),
375
+ ],
376
+ strokeColor: const Color(0xFF38BDF8).withValues(alpha: 0.55),
377
+ ),
378
+ _RingConfig(
379
+ baseScale: 1.48,
380
+ waveAmp: 27.0 * (0.15 + 0.85 * amp),
381
+ frequency: 5,
382
+ rotSpeed: -0.30,
383
+ timeScale: 1.3,
384
+ colors: [
385
+ secondary.withValues(alpha: 0.30),
386
+ accent.withValues(alpha: 0.22),
387
+ Colors.transparent,
388
+ ],
389
+ strokeColor: secondary.withValues(alpha: 0.45),
390
),
391
];
392
393
+ // Save Layer with BlendMode.plus for Additive Wave Intersections
394
+ final layerRect = Rect.fromLTWH(0, 0, canvasSize.width, canvasSize.height);
395
+ canvas.saveLayer(layerRect, Paint());
396
+
397
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();
398
+ final path = _generateOrganicCubicBezierPath(
399
+ center,
400
+ baseRadius * ring.baseScale,
401
+ ring.waveAmp,
402
+ ring.frequency,
403
+ ring.rotSpeed,
404
+ ring.timeScale,
405
+ time,
406
+ );
407
267
- // Draw translucent gradient fill
408
final bounds = path.getBounds();
409
final fillPaint = Paint()
410
..style = PaintingStyle.fill
411
+ ..blendMode = BlendMode.plus
412
..shader = LinearGradient(
413
begin: Alignment.topLeft,
414
end: Alignment.bottomRight,
415
colors: ring.colors,
275
- ).createShader(bounds);
416
+ ).createShader(bounds.isEmpty ? layerRect : bounds);
417
canvas.drawPath(path, fillPaint);
418
278
- // Draw glowing stroke outline
419
final strokePaint = Paint()
420
..style = PaintingStyle.stroke
281
- ..strokeWidth = 1.6
421
+ ..strokeWidth = 1.8
422
..color = ring.strokeColor;
423
canvas.drawPath(path, strokePaint);
424
}
425
+
426
+ canvas.restore(); // Restore layer
427
}
428
287
- void _paintOrbCore(
429
+ Path _generateOrganicCubicBezierPath(
430
+ Offset center,
431
+ double ringRadius,
432
+ double waveAmp,
433
+ int frequency,
434
+ double rotSpeed,
435
+ double timeScale,
436
+ double time,
437
+ ) {
438
+ final path = Path();
439
+ const count = 16; // Control point anchors along full circle
440
+ final points = <Offset>[];
441
+
442
+ for (int i = 0; i < count; i++) {
443
+ final theta = (i / count) * 2 * math.pi;
444
+ final angle = theta + (time * rotSpeed);
445
+
446
+ final w1 = math.sin(theta * frequency + time * timeScale);
447
+ final w2 = math.cos(theta * (frequency + 1) - time * timeScale * 0.75);
448
+ final r = ringRadius + (w1 * 0.6 + w2 * 0.4) * waveAmp;
449
+
450
+ final x = center.dx + r * math.cos(angle);
451
+ final y = center.dy + r * math.sin(angle);
452
+ points.add(Offset(x, y));
453
+ }
454
+
455
+ // Smooth Cubic Bezier Loop through Control Points
456
+ path.moveTo(points[0].dx, points[0].dy);
457
+
458
+ for (int i = 0; i < count; i++) {
459
+ final p0 = points[(i - 1 + count) % count];
460
+ final p1 = points[i];
461
+ final p2 = points[(i + 1) % count];
462
+ final p3 = points[(i + 2) % count];
463
+
464
+ // Catmull-Rom to Cubic Bezier Conversion for Smooth Liquid Curve
465
+ final control1 = p1 + (p2 - p0) * 0.1666;
466
+ final control2 = p2 - (p3 - p1) * 0.1666;
467
+
468
+ path.cubicTo(
469
+ control1.dx, control1.dy,
470
+ control2.dx, control2.dy,
471
+ p2.dx, p2.dy,
472
+ );
473
+ }
474
+
475
+ path.close();
476
+ return path;
477
+ }
478
+
479
+ void _paint3DOrbCore(
480
Canvas canvas,
481
Offset center,
482
double baseRadius,
483
double time,
484
double amp,
485
+ Color accent,
486
+ Color secondary,
487
) {
488
final orbRadius = baseRadius * (0.95 + 0.15 * amp);
489
final orbRect = Rect.fromCircle(center: center, radius: orbRadius);
296
- final isError = phase == VoiceModePhase.error;
490
298
- // Primary 3D gradient sphere shader with reduced opacity for error mode
491
+ final gradientStops = VoiceModePalette.sphereGradientFor(targetPhase, isDark: isDark);
492
final orbGradient = RadialGradient(
493
center: const Alignment(-0.35, -0.45),
494
radius: 1.15,
302
- colors: isError
495
+ colors: gradientStops.length >= 4
496
? [
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
497
+ Color.lerp(gradientStops[0], accent, 0.1)!,
498
+ gradientStops[1],
499
+ gradientStops[2],
500
+ gradientStops[3],
501
]
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],
502
+ : [accent, secondary],
503
+ stops: const [0.0, 0.32, 0.68, 1.0],
504
);
505
322
- final orbPaint = Paint()
323
- ..shader = orbGradient.createShader(orbRect);
506
+ final orbPaint = Paint()..shader = orbGradient.createShader(orbRect);
507
508
// Save layer for clipping inner liquid waves
509
canvas.save();
510
final orbPath = Path()..addOval(orbRect);
511
canvas.clipPath(orbPath);
512
330
- // Draw main gradient sphere body
513
+ // Main 3D gradient sphere body
514
canvas.drawCircle(center, orbRadius, orbPaint);
515
333
- // Draw inner morphing liquid wave shimmer
334
- _paintInnerLiquidWaves(canvas, center, orbRadius, time, amp);
516
+ // Inner morphing liquid wave shimmer
517
+ _paintInnerLiquidWaves(canvas, center, orbRadius, time, amp, accent, secondary);
518
336
- // Draw top specular 3D light reflection
519
+ // Specular 3D light reflection highlight
520
final specularPaint = Paint()
521
..shader = RadialGradient(
522
center: const Alignment(-0.45, -0.55),
523
radius: 0.65,
524
colors: [
342
- Colors.white.withValues(alpha: 0.65),
343
- Colors.white.withValues(alpha: 0.20),
525
+ Colors.white.withValues(alpha: 0.68),
526
+ Colors.white.withValues(alpha: 0.22),
527
Colors.white.withValues(alpha: 0.0),
528
],
529
stops: const [0.0, 0.45, 1.0],
530
).createShader(orbRect);
531
532
canvas.drawCircle(center, orbRadius, specularPaint);
350
-
533
canvas.restore(); // Restore clip
534
353
- // Crisp outer edge glow ring
535
+ // Crisp outer edge glow rim ring
536
final borderPaint = Paint()
537
..style = PaintingStyle.stroke
538
..strokeWidth = 1.8
539
..shader = LinearGradient(
540
begin: Alignment.topLeft,
541
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
- ],
542
+ colors: [
543
+ Colors.white.withValues(alpha: 0.75),
544
+ accent.withValues(alpha: 0.55),
545
+ secondary.withValues(alpha: 0.40),
546
+ Colors.white.withValues(alpha: 0.25),
547
+ ],
548
).createShader(orbRect);
549
550
canvas.drawCircle(center, orbRadius, borderPaint);
@@ -381,11 +556,12 @@ class _OrbWavePainter extends CustomPainter {
556
double radius,
557
double time,
558
double amp,
559
+ Color accent,
560
+ Color secondary,
561
) {
385
- final isError = phase == VoiceModePhase.error;
562
final wavePath = Path();
563
final yOffset = center.dy + radius * 0.15 * math.sin(time * 1.5);
388
- final waveHeight = radius * (0.25 + 0.3 * amp);
564
+ final waveHeight = radius * (0.25 + 0.35 * amp);
565
566
wavePath.moveTo(center.dx - radius, center.dy + radius);
567
wavePath.lineTo(center.dx - radius, yOffset);
@@ -407,17 +583,11 @@ class _OrbWavePainter extends CustomPainter {
583
..shader = LinearGradient(
584
begin: Alignment.topCenter,
585
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
- ],
586
+ colors: [
587
+ secondary.withValues(alpha: 0.35),
588
+ accent.withValues(alpha: 0.20),
589
+ Colors.transparent,
590
+ ],
591
).createShader(Rect.fromCircle(center: center, radius: radius));
592
593
canvas.drawPath(wavePath, liquidPaint);
@@ -425,10 +595,13 @@ class _OrbWavePainter extends CustomPainter {
595
596
@override
597
bool shouldRepaint(covariant _OrbWavePainter oldDelegate) {
428
- return phase != oldDelegate.phase ||
598
+ return oldPhase != oldDelegate.oldPhase ||
599
+ targetPhase != oldDelegate.targetPhase ||
600
+ transitionProgress != oldDelegate.transitionProgress ||
601
micLevel != oldDelegate.micLevel ||
602
progress != oldDelegate.progress ||
431
- isDark != oldDelegate.isDark;
603
+ isDark != oldDelegate.isDark ||
604
+ shaderProgram != oldDelegate.shaderProgram;
605
}
606
}
607
lib/features/voice_mode/views/voice_mode_overlay.dart
+144
-148
@@ -1,6 +1,7 @@
1
import 'dart:ui';
2
3
import 'package:flutter/material.dart';
4
+import 'package:flutter/services.dart';
5
import 'package:flutter_riverpod/flutter_riverpod.dart';
6
import 'package:go_router/go_router.dart';
7
import 'package:haptic_feedback/haptic_feedback.dart';
@@ -18,14 +19,8 @@ import 'components/voice_visualizer.dart';
19
20
/// Full-screen voice-to-voice conversation overlay.
21
///
21
-/// Presents a real-time waveform visualizer and auto-scrolling transcript
22
-/// across Listen → Process → Speak phases, looping hands-free when
23
-/// auto-listen is enabled.
24
-///
25
-/// Usage:
26
-/// ```dart
27
-/// VoiceModeOverlay.show(context);
28
-/// ```
22
+/// Features a real-time glowing 3D crystal visualizer and auto-scrolling transcript
23
+/// set against a sleek dark obsidian atmospheric backdrop in both light and dark modes.
24
class VoiceModeOverlay extends ConsumerStatefulWidget {
25
const VoiceModeOverlay({super.key});
26
@@ -92,7 +87,7 @@ class _VoiceModeOverlayState extends ConsumerState<VoiceModeOverlay> {
87
}
88
}
89
95
- Widget _buildTtsChip(BuildContext context, ThemeData theme, bool isDark) {
90
+ Widget _buildTtsChip(BuildContext context, ThemeData theme) {
91
final settings = ref.watch(settingsProvider);
92
final String label;
93
@@ -120,14 +115,10 @@ class _VoiceModeOverlayState extends ConsumerState<VoiceModeOverlay> {
115
child: Container(
116
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
117
decoration: BoxDecoration(
123
- color: isDark
124
- ? Colors.white.withValues(alpha: 0.06)
125
- : Colors.black.withValues(alpha: 0.05),
118
+ color: Colors.white.withValues(alpha: 0.08),
119
borderRadius: BorderRadius.circular(999),
120
border: Border.all(
128
- color: isDark
129
- ? Colors.white.withValues(alpha: 0.10)
130
- : Colors.black.withValues(alpha: 0.08),
121
+ color: Colors.white.withValues(alpha: 0.14),
122
),
123
),
124
child: Row(
@@ -136,7 +127,7 @@ class _VoiceModeOverlayState extends ConsumerState<VoiceModeOverlay> {
127
HugeIcon(
128
icon: HugeIcons.strokeRoundedVolumeHigh,
129
size: 13,
139
- color: theme.colorScheme.primary,
130
+ color: Colors.white.withValues(alpha: 0.90),
131
),
132
const SizedBox(width: 6),
133
Text(
@@ -145,18 +136,14 @@ class _VoiceModeOverlayState extends ConsumerState<VoiceModeOverlay> {
136
fontSize: 12,
137
fontWeight: FontWeight.w600,
138
letterSpacing: 0.1,
148
- color: isDark
149
- ? Colors.white.withValues(alpha: 0.85)
150
- : Colors.black87,
139
+ color: Colors.white.withValues(alpha: 0.90),
140
),
141
),
142
const SizedBox(width: 2),
143
Icon(
144
Icons.chevron_right_rounded,
145
size: 16,
157
- color: isDark
158
- ? Colors.white.withValues(alpha: 0.45)
159
- : Colors.black.withValues(alpha: 0.4),
146
+ color: Colors.white.withValues(alpha: 0.50),
147
),
148
],
149
),
@@ -168,122 +155,133 @@ class _VoiceModeOverlayState extends ConsumerState<VoiceModeOverlay> {
155
Widget build(BuildContext context) {
156
final state = ref.watch(voiceModeProvider);
157
final theme = Theme.of(context);
171
- final isDark = theme.brightness == Brightness.dark;
172
-
173
- final bgColor = isDark ? const Color(0xFF0B0B10) : const Color(0xFFF7F7FB);
158
175
- final accent = VoiceModePalette.accentFor(state.phase, isDark: isDark);
159
+ // Voice mode presents a dark obsidian atmospheric theme in both light and dark modes
160
+ const bgColor = Color(0xFF090A10);
161
+ final accent = VoiceModePalette.accentFor(state.phase, isDark: true);
162
+ final secondary = VoiceModePalette.secondaryFor(state.phase, isDark: true);
163
164
return PopScope(
165
canPop: false,
166
onPopInvokedWithResult: (didPop, _) {
167
if (!didPop) _endSession();
168
},
182
- child: Scaffold(
183
- backgroundColor: Colors.transparent,
184
- body: Stack(
185
- children: [
186
- // RepaintBoundary so the gradient + blur aren't re-rasterised
187
- // when the lower subtree (transcript/visualizer/controls)
188
- // rebuilds on streamed tokens or mic-level changes.
189
- Positioned.fill(
190
- child: RepaintBoundary(
191
- child: _PulsingBackgroundGlow(
192
- phase: state.phase,
193
- micLevel: state.micLevel,
194
- isDark: isDark,
195
- accent: accent,
196
- bgColor: bgColor,
169
+ child: AnnotatedRegion<SystemUiOverlayStyle>(
170
+ value: const SystemUiOverlayStyle(
171
+ statusBarColor: Colors.transparent,
172
+ statusBarIconBrightness: Brightness.light, // White icons on Android
173
+ statusBarBrightness: Brightness.dark, // White text/icons on iOS
174
+ systemNavigationBarColor: Colors.transparent,
175
+ systemNavigationBarIconBrightness: Brightness.light,
176
+ ),
177
+ child: Theme(
178
+ data: theme.copyWith(
179
+ brightness: Brightness.dark,
180
+ colorScheme: const ColorScheme.dark(),
181
+ ),
182
+ child: Scaffold(
183
+ backgroundColor: bgColor,
184
+ body: Stack(
185
+ children: [
186
+ // Dynamic pulsing ambient background glow
187
+ Positioned.fill(
188
+ child: RepaintBoundary(
189
+ child: _PulsingBackgroundGlow(
190
+ phase: state.phase,
191
+ micLevel: state.micLevel,
192
+ accent: accent,
193
+ secondary: secondary,
194
+ bgColor: bgColor,
195
+ ),
196
+ ),
197
),
198
- ),
199
- ),
198
201
- SafeArea(
202
- child: Column(
203
- children: [
204
- Padding(
205
- padding: const EdgeInsets.fromLTRB(20, 12, 12, 8),
206
- child: Row(
207
- children: [
208
- Container(
209
- width: 8,
210
- height: 8,
211
- decoration: BoxDecoration(
212
- shape: BoxShape.circle,
213
- color: accent,
214
- boxShadow: [
215
- BoxShadow(
216
- color: accent.withValues(alpha: 0.6),
217
- blurRadius: 8,
218
- spreadRadius: 1,
199
+ SafeArea(
200
+ child: Column(
201
+ children: [
202
+ Padding(
203
+ padding: const EdgeInsets.fromLTRB(20, 12, 12, 8),
204
+ child: Row(
205
+ children: [
206
+ Container(
207
+ width: 8,
208
+ height: 8,
209
+ decoration: BoxDecoration(
210
+ shape: BoxShape.circle,
211
+ color: accent,
212
+ boxShadow: [
213
+ BoxShadow(
214
+ color: accent.withValues(alpha: 0.7),
215
+ blurRadius: 10,
216
+ spreadRadius: 2,
217
+ ),
218
+ ],
219
),
220
- ],
221
- ),
222
- ),
223
- const SizedBox(width: 10),
224
- Text(
225
- 'Voice Mode',
226
- style: TextStyle(
227
- fontSize: 15,
228
- fontWeight: FontWeight.w600,
229
- letterSpacing: 0.2,
230
- color: isDark
231
- ? Colors.white.withValues(alpha: 0.85)
232
- : Colors.black.withValues(alpha: 0.8),
233
- ),
220
+ ),
221
+ const SizedBox(width: 10),
222
+ Text(
223
+ 'Voice Mode',
224
+ style: TextStyle(
225
+ fontSize: 15,
226
+ fontWeight: FontWeight.w600,
227
+ letterSpacing: 0.2,
228
+ color: Colors.white.withValues(alpha: 0.90),
229
+ ),
230
+ ),
231
+ const Spacer(),
232
+ _buildTtsChip(context, theme),
233
+ const SizedBox(width: 4),
234
+ _CloseButton(onPressed: _endSession),
235
+ ],
236
),
235
- const Spacer(),
236
- _buildTtsChip(context, theme, isDark),
237
- const SizedBox(width: 4),
238
- _CloseButton(isDark: isDark, onPressed: _endSession),
239
- ],
240
- ),
241
- ),
242
-
243
- const Spacer(flex: 2),
237
+ ),
238
245
- GestureDetector(
246
- onTap: _handleCenterAction,
247
- child: VoiceVisualizer(
248
- phase: state.phase,
249
- micLevel: state.micLevel,
250
- size: 240,
251
- ),
252
- ),
239
+ const Spacer(flex: 2),
240
254
- const SizedBox(height: 28),
241
+ GestureDetector(
242
+ onTap: _handleCenterAction,
243
+ child: VoiceVisualizer(
244
+ phase: state.phase,
245
+ micLevel: state.micLevel,
246
+ size: 240,
247
+ ),
248
+ ),
249
256
- Padding(
257
- padding: const EdgeInsets.symmetric(horizontal: 32),
258
- child: _StreamingTranscript(
259
- phase: state.phase,
260
- transcript: state.transcript,
261
- response: state.response,
262
- error: state.error,
263
- ),
264
- ),
250
+ const SizedBox(height: 28),
251
266
- const Spacer(flex: 3),
267
-
268
- VoiceModeControls(
269
- phase: state.phase,
270
- autoListen: state.autoListen,
271
- isMuted: state.isMuted,
272
- onEnd: _endSession,
273
- onToggleMute: () {
274
- ref.read(voiceModeProvider.notifier).toggleMute();
275
- },
276
- onToggleAutoListen: () {
277
- ref.read(voiceModeProvider.notifier).toggleAutoListen();
278
- },
279
- onTapCenter: _handleCenterAction,
252
+ Padding(
253
+ padding: const EdgeInsets.symmetric(horizontal: 32),
254
+ child: _StreamingTranscript(
255
+ phase: state.phase,
256
+ transcript: state.transcript,
257
+ response: state.response,
258
+ error: state.error,
259
+ ),
260
+ ),
261
+
262
+ const Spacer(flex: 3),
263
+
264
+ VoiceModeControls(
265
+ phase: state.phase,
266
+ autoListen: state.autoListen,
267
+ isMuted: state.isMuted,
268
+ onEnd: _endSession,
269
+ onToggleMute: () {
270
+ ref.read(voiceModeProvider.notifier).toggleMute();
271
+ },
272
+ onToggleAutoListen: () {
273
+ ref.read(voiceModeProvider.notifier).toggleAutoListen();
274
+ },
275
+ onTapCenter: _handleCenterAction,
276
+ ),
277
+
278
+ const SizedBox(height: 18),
279
+ ],
280
),
281
-
282
- const SizedBox(height: 18),
283
- ],
284
- ),
281
+ ),
282
+ ],
283
),
286
- ],
284
+ ),
285
),
286
),
287
);
@@ -291,9 +289,7 @@ class _VoiceModeOverlayState extends ConsumerState<VoiceModeOverlay> {
289
}
290
291
/// Subscribes to the chat streaming message and feeds its content into
294
-/// [VoiceTranscript] during the processing phase. Lives in its own
295
-/// Consumer so streamed tokens only rebuild this subtree, not the
296
-/// gradient/BackdropFilter above it.
292
+/// [VoiceTranscript] during the processing phase.
293
class _StreamingTranscript extends ConsumerWidget {
294
final VoiceModePhase phase;
295
final String transcript;
@@ -323,10 +319,9 @@ class _StreamingTranscript extends ConsumerWidget {
319
}
320
321
class _CloseButton extends StatelessWidget {
326
- final bool isDark;
322
final VoidCallback onPressed;
323
329
- const _CloseButton({required this.isDark, required this.onPressed});
324
+ const _CloseButton({required this.onPressed});
325
326
@override
327
Widget build(BuildContext context) {
@@ -340,16 +335,12 @@ class _CloseButton extends StatelessWidget {
335
height: 36,
336
decoration: BoxDecoration(
337
shape: BoxShape.circle,
343
- color: isDark
344
- ? Colors.white.withValues(alpha: 0.06)
345
- : Colors.black.withValues(alpha: 0.05),
338
+ color: Colors.white.withValues(alpha: 0.08),
339
),
340
child: Icon(
341
Icons.close_rounded,
342
size: 18,
350
- color: isDark
351
- ? Colors.white.withValues(alpha: 0.7)
352
- : Colors.black.withValues(alpha: 0.55),
343
+ color: Colors.white.withValues(alpha: 0.80),
344
),
345
),
346
),
@@ -360,15 +351,15 @@ class _CloseButton extends StatelessWidget {
351
class _PulsingBackgroundGlow extends StatefulWidget {
352
final VoiceModePhase phase;
353
final double micLevel;
363
- final bool isDark;
354
final Color accent;
355
+ final Color secondary;
356
final Color bgColor;
357
358
const _PulsingBackgroundGlow({
359
required this.phase,
360
required this.micLevel,
370
- required this.isDark,
361
required this.accent,
362
+ required this.secondary,
363
required this.bgColor,
364
});
365
@@ -385,7 +376,7 @@ class _PulsingBackgroundGlowState extends State<_PulsingBackgroundGlow>
376
super.initState();
377
_ticker = AnimationController(
378
vsync: this,
388
- duration: const Duration(milliseconds: 3000),
379
+ duration: const Duration(milliseconds: 3200),
380
)..repeat(reverse: true);
381
}
382
@@ -405,43 +396,49 @@ class _PulsingBackgroundGlowState extends State<_PulsingBackgroundGlow>
396
397
switch (widget.phase) {
398
case VoiceModePhase.listening:
408
- pulse = 0.15 + 0.5 * widget.micLevel.clamp(0.0, 1.0) + 0.1 * breath;
399
+ pulse = 0.18 + 0.45 * widget.micLevel.clamp(0.0, 1.0) + 0.12 * breath;
400
case VoiceModePhase.speaking:
410
- pulse = 0.20 + 0.25 * breath;
401
+ pulse = 0.22 + 0.28 * breath;
402
case VoiceModePhase.error:
412
- pulse = 0.18 + 0.22 * breath;
403
+ pulse = 0.18 + 0.20 * breath;
404
case VoiceModePhase.processing:
405
pulse = 0.16 + 0.18 * breath;
406
case VoiceModePhase.idle:
407
pulse = 0.06 + 0.06 * breath;
408
}
409
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;
410
+ final alpha = (0.16 + pulse * 0.18).clamp(0.08, 0.45);
411
+ final radius = 0.85 + pulse * 0.40;
412
413
return Stack(
414
children: [
415
+ // Deep obsidian background base
416
+ Container(color: widget.bgColor),
417
+
418
+ // Top ambient gradient light pool
419
AnimatedContainer(
420
duration: const Duration(milliseconds: 450),
421
decoration: BoxDecoration(
422
gradient: RadialGradient(
429
- center: const Alignment(0, -0.2),
423
+ center: const Alignment(0, -0.25),
424
radius: radius,
425
colors: [
432
- widget.accent.withValues(alpha: alpha),
433
- widget.accent.withValues(alpha: alpha * 0.35),
426
+ widget.accent.withValues(alpha: alpha * 1.1),
427
+ widget.secondary.withValues(alpha: alpha * 0.45),
428
+ widget.bgColor.withValues(alpha: 0.85),
429
widget.bgColor,
430
],
436
- stops: const [0.0, 0.55, 0.95],
431
+ stops: const [0.0, 0.45, 0.78, 1.0],
432
),
433
),
434
),
435
+
436
+ // Soft blur overlay for smooth diffusion
437
Positioned.fill(
438
child: BackdropFilter(
442
- filter: ImageFilter.blur(sigmaX: 45, sigmaY: 45),
439
+ filter: ImageFilter.blur(sigmaX: 55, sigmaY: 55),
440
child: Container(
444
- color: widget.bgColor.withValues(alpha: 0.50),
441
+ color: Colors.black.withValues(alpha: 0.15),
442
),
443
),
444
),
@@ -451,4 +448,3 @@ class _PulsingBackgroundGlowState extends State<_PulsingBackgroundGlow>
448
);
449
}
450
}
454
-
lib/features/voice_mode/voice_mode_palette.dart
+60
@@ -25,6 +25,66 @@ class VoiceModePalette {
25
}
26
}
27
28
+ /// Secondary accent color for multi-spectral gradient shaders & wave rings.
29
+ static Color secondaryFor(VoiceModePhase phase, {required bool isDark}) {
30
+ switch (phase) {
31
+ case VoiceModePhase.listening:
32
+ return isDark ? const Color(0xFF38BDF8) : const Color(0xFF0284C7);
33
+ case VoiceModePhase.processing:
34
+ return isDark ? const Color(0xFFE879F9) : const Color(0xFFC084FC);
35
+ case VoiceModePhase.speaking:
36
+ return isDark ? const Color(0xFFF472B6) : const Color(0xFFDB2777);
37
+ case VoiceModePhase.idle:
38
+ return isDark ? const Color(0xFF64748B) : const Color(0xFF475569);
39
+ case VoiceModePhase.error:
40
+ return isDark ? const Color(0xFFF87171) : const Color(0xFFDC2626);
41
+ }
42
+ }
43
+
44
+ /// Full 3D sphere gradient stops for a given phase.
45
+ static List<Color> sphereGradientFor(VoiceModePhase phase, {required bool isDark}) {
46
+ final accent = accentFor(phase, isDark: isDark);
47
+ final secondary = secondaryFor(phase, isDark: isDark);
48
+
49
+ switch (phase) {
50
+ case VoiceModePhase.listening:
51
+ return [
52
+ const Color(0xFFA7F3D0), // Soft mint top highlight
53
+ accent, // Emerald / Teal accent
54
+ secondary, // Cyan blue
55
+ const Color(0xFF0284C7), // Deep cyan edge
56
+ ];
57
+ case VoiceModePhase.processing:
58
+ return [
59
+ const Color(0xFFF0ABFC), // Bright violet light top
60
+ accent, // Deep purple
61
+ secondary, // Magenta shimmer
62
+ const Color(0xFF4C1D95), // Deep dark indigo edge
63
+ ];
64
+ case VoiceModePhase.speaking:
65
+ return [
66
+ const Color(0xFFFFAEE2), // Light peach pink top
67
+ const Color(0xFFF472B6), // Pink
68
+ accent, // Indigo / Purple
69
+ secondary, // Deep rose / cyan bottom
70
+ ];
71
+ case VoiceModePhase.idle:
72
+ return [
73
+ const Color(0xFFCBD5E1), // Cool grey light
74
+ accent, // Slate blue
75
+ secondary, // Dark slate
76
+ const Color(0xFF334155), // Charcoal edge
77
+ ];
78
+ case VoiceModePhase.error:
79
+ return [
80
+ const Color(0xFFFECDD3), // Soft rose pink highlight
81
+ const Color(0xFFF87171), // Vibrant coral red
82
+ accent, // Crimson red
83
+ const Color(0xFF991B1B), // Dark burgundy edge
84
+ ];
85
+ }
86
+ }
87
+
88
/// Subtle background used behind the centred phase icon / pill.
89
static Color subtleBg(BuildContext context, {double opacity = 0.08}) {
90
final isDark = Theme.of(context).brightness == Brightness.dark;
pubspec.yaml
+4
@@ -118,6 +118,10 @@ flutter:
118
- assets/google_fonts/
119
- assets/images/
120
- assets/sounds/
121
+
122
+ shaders:
123
+ - assets/shaders/orb.frag
124
+
125
# An image asset can refer to one or more resolution-specific "variants", see
126
# https://flutter.dev/to/resolution-aware-images
127