@tduncan / Cortana / commits / 1896b93

feat: implement high-performance GLSL 3D orb shader for voice visualizer with smooth cross-phase transitions

abdulmominsakib committed Jul 30, 2026 at 13:01 UTC 1896b93d078873b1026325d55c8b7f1b306abc03
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