Compare commits

...

7 Commits

Author SHA1 Message Date
bvandeusen ad20c9f9d4 Merge pull request 'Release v26.04.14.3 — Live amplitude mic pulse' (#23) from dev into main 2026-04-15 02:54:47 +00:00
bvandeusen 48c134ce6a feat(voice): pulse mic button with live amplitude
VoiceState now carries a normalized mic amplitude (0..1) updated
from the existing onAmplitudeChanged subscription, with a 0.02
change threshold so we don't spam rebuilds.

VoiceMicButton swaps the constant-rate AnimationController for an
AnimatedScale + animated glow driven by the live amplitude. 0.1
floor keeps the button breathing on silence; 120ms ease-out smooths
between 200ms samples.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 22:46:48 -04:00
bvandeusen 634b6d05cf Merge pull request 'Release v26.04.14.2 — News card title readability' (#22) from dev into main 2026-04-15 01:45:43 +00:00
bvandeusen 00878a8a42 fix(news): use onSurface for article titles instead of primary
Purple-on-black was eye-catching but hard to read. Switch title text
to onSurface for contrast; keep the underline in primary at 70% alpha
as the "this is a link" signal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 18:13:31 -04:00
bvandeusen ddbf867b03 Merge pull request 'Release v26.04.14.1' (#21) from dev into main 2026-04-14 12:00:30 +00:00
bvandeusen 51f1cffe79 ci: drop main-branch trigger so merge commits don't re-run dev CI
Mirrors the same fix applied to the fabledassistant repo: merges to
main only happen after dev already passed CI and tagged releases are
the sole trigger for a signed APK build, so re-running analyze+test on
the merge commit just burns runner time.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 07:55:55 -04:00
bvandeusen fa84e40efc feat(briefing): stall watchdog + pull-to-refresh; chat attaches to in-flight streams
Briefing chat now uses the same StreamIterator + 45s per-event timeout
as the main chat provider, so a dropped SSE socket no longer leaves
isBriefingStreaming stuck true. Adds refreshMessages() that unfreezes
state when the server-side message is complete, wired to a new
pull-to-refresh gesture and the app lifecycle-resume hook.

Chat provider gains attachToGeneration() — safe to call unconditionally
on screen init, so landing on a conversation that's already mid-stream
(e.g. the /news discuss button, which now creates a conv and auto-kicks
generation server-side) picks up live tokens instead of freezing on the
placeholder.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 07:55:47 -04:00
9 changed files with 343 additions and 169 deletions
+9 -4
View File
@@ -1,7 +1,12 @@
# CI runs first; build only proceeds if all checks pass.
#
# Push to dev or main: flutter analyze + flutter test
# Tag v* (release): gates + signed APK build + attach to Forgejo Release
# Push to dev: flutter analyze + flutter test
# Tag v* (release): gates + signed APK build + attach to Forgejo Release
#
# main pushes are NOT gated here: a merge to main only happens after
# dev has already passed CI, and the release tag is the sole trigger
# for a signed APK build. Re-running analyze+test on the merge commit
# just burns runner time without changing the outcome.
#
# To cut a release:
# Create a release via the Forgejo UI on main with a v* tag name.
@@ -20,7 +25,7 @@ name: CI & Build
on:
push:
branches: [dev, main]
branches: [dev]
tags: ["v*"]
# Cancel older runs on the same branch when a newer push lands. Tag runs
@@ -63,7 +68,7 @@ jobs:
build:
name: Build release APK
needs: [analyze]
# Only tag pushes produce a signed release build. dev/main pushes
# Only tag pushes produce a signed release build. dev pushes
# run the gates above and stop there.
if: startsWith(github.ref, 'refs/tags/')
runs-on: ci-runner
+1
View File
@@ -653,6 +653,7 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
VoiceMicButton(
mode: ref.watch(voiceProvider).mode,
voiceModeActive: ref.watch(voiceProvider).voiceModeActive,
amplitude: ref.watch(voiceProvider).amplitude,
onTap: _toggleCaptureMic,
),
IconButton(
+113 -102
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/api/chat_api.dart';
@@ -49,6 +51,40 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
await future;
}
/// Re-fetch the current briefing conversation and unfreeze a stuck
/// streaming state if the server-side message is already complete.
///
/// Same role as MessagesNotifier.refresh() in chat_provider: when an SSE
/// socket dies silently (mobile network handoff, app backgrounded mid-stream,
/// reverse proxy dropping idle sockets) the send loop never observes close
/// and [isBriefingStreamingProvider] stays stuck true. This is the manual
/// recovery path hit by pull-to-refresh, the AppBar refresh button, and the
/// lifecycle-resume hook.
Future<void> refreshMessages() async {
final current = state.value;
if (current == null) {
ref.invalidateSelf();
return;
}
try {
final fresh = await ref.read(briefingApiProvider).getToday();
state = AsyncData(fresh);
final messages = fresh.messages;
Message? lastAssistant;
for (var i = messages.length - 1; i >= 0; i--) {
if (messages[i].role == MessageRole.assistant) {
lastAssistant = messages[i];
break;
}
}
if (lastAssistant != null && lastAssistant.status != 'generating') {
ref.read(isBriefingStreamingProvider.notifier).state = false;
}
} catch (_) {
// Network hiccup — keep existing state; user can retry.
}
}
/// Inject a news article as context and trigger generation.
///
/// Mirrors sendReply() but calls the /discuss endpoint instead of
@@ -77,10 +113,71 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
rethrow;
}
// SSE stream (best-effort)
bool streamedContent = false;
final streamedContent = await _consumeStream(chatApi.streamGeneration(convId));
await _pollUntilComplete(convId, streamedContent);
}
/// Send a reply to today's briefing conversation.
///
/// Mirrors MessagesNotifier.sendMessage() in chat_provider with the same
/// stall-watchdog pattern:
/// 1. Optimistic UI update
/// 2. POST message to chat endpoint
/// 3. SSE stream with per-event timeout (stall watchdog)
/// 4. Poll until complete
Future<void> sendReply(String content) async {
final conv = state.value;
if (conv == null) return;
final convId = conv.id;
final chatApi = ref.read(chatApiProvider);
final previous = conv.messages;
final userMsg = Message(
conversationId: convId,
role: MessageRole.user,
content: content,
);
final placeholder = Message(
conversationId: convId,
role: MessageRole.assistant,
content: '',
status: 'generating',
);
state = AsyncData(conv.copyWith(messages: [...previous, userMsg, placeholder]));
ref.read(isBriefingStreamingProvider.notifier).state = true;
try {
await for (final event in chatApi.streamGeneration(convId)) {
await chatApi.sendMessage(convId, content);
} catch (e) {
state = AsyncData(conv.copyWith(messages: previous));
ref.read(isBriefingStreamingProvider.notifier).state = false;
rethrow;
}
final streamedContent = await _consumeStream(chatApi.streamGeneration(convId));
await _pollUntilComplete(convId, streamedContent);
}
/// Consume an SSE stream into the current briefing conversation state.
///
/// Uses a StreamIterator with a per-event timeout as a stall watchdog —
/// same rationale as MessagesNotifier.sendMessage() in chat_provider.dart.
/// Mobile networks occasionally drop SSE sockets silently: the TCP
/// connection is half-closed, Dio never sees the close, and `await for`
/// hangs forever with [isBriefingStreamingProvider] stuck true. If no event
/// arrives within the watchdog window we bail out and let the polling pass
/// below reconcile state from the server.
///
/// Returns whether any text content was actually streamed (the polling
/// pass uses this to decide whether it's safe to overwrite with a possibly
/// empty server-side row).
Future<bool> _consumeStream(Stream<ChatStreamEvent> stream) async {
const stallTimeout = Duration(seconds: 45);
bool streamedContent = false;
final iter = StreamIterator(stream);
try {
while (await iter.moveNext().timeout(stallTimeout)) {
final event = iter.current;
final current = state.value;
if (current == null) break;
final msgs = current.messages;
@@ -100,11 +197,22 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
messages: [...msgs.sublist(0, msgs.length - 1), updated]));
}
}
} on TimeoutException {
// Stall watchdog — no SSE event for stallTimeout. Fall through to
// polling so the UI eventually unfreezes even if the socket is dead.
} catch (_) {
// Fall through to polling.
// SSE failed — fall through to polling.
} finally {
await iter.cancel();
}
return streamedContent;
}
// Poll until complete (max 20 attempts, 2s apart)
/// Poll /api/briefing messages until the last assistant row is complete,
/// same reconcile pattern as MessagesNotifier.sendMessage(). Always clears
/// [isBriefingStreamingProvider] at the end so the input can't stay locked.
Future<void> _pollUntilComplete(int convId, bool streamedContent) async {
final briefingApi = ref.read(briefingApiProvider);
try {
for (var attempt = 0; attempt < 20; attempt++) {
if (attempt > 0) await Future.delayed(const Duration(seconds: 2));
@@ -136,101 +244,4 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
ref.read(isBriefingStreamingProvider.notifier).state = false;
}
}
/// Send a reply to today's briefing conversation.
///
/// Mirrors MessagesNotifier.sendMessage():
/// 1. Optimistic UI update
/// 2. POST message to chat endpoint
/// 3. SSE stream (best-effort)
/// 4. Poll until complete
Future<void> sendReply(String content) async {
final conv = state.value;
if (conv == null) return;
final convId = conv.id;
final chatApi = ref.read(chatApiProvider);
final previous = conv.messages;
final userMsg = Message(
conversationId: convId,
role: MessageRole.user,
content: content,
);
final placeholder = Message(
conversationId: convId,
role: MessageRole.assistant,
content: '',
status: 'generating',
);
state = AsyncData(conv.copyWith(messages: [...previous, userMsg, placeholder]));
ref.read(isBriefingStreamingProvider.notifier).state = true;
try {
await chatApi.sendMessage(convId, content);
} catch (e) {
state = AsyncData(conv.copyWith(messages: previous));
ref.read(isBriefingStreamingProvider.notifier).state = false;
rethrow;
}
// SSE stream (best-effort)
bool streamedContent = false;
try {
await for (final event in chatApi.streamGeneration(convId)) {
final current = state.value;
if (current == null) break;
final msgs = current.messages;
if (msgs.isEmpty) continue;
if (event is ChatTextChunk) {
streamedContent = true;
final updated =
msgs.last.copyWith(content: msgs.last.content + event.text);
state = AsyncData(current.copyWith(
messages: [...msgs.sublist(0, msgs.length - 1), updated]));
} else if (event is ChatToolCall) {
final last = msgs.last;
if (last.role != MessageRole.assistant) continue;
final nextCalls = [...?last.toolCalls, event.toolCall];
final updated = last.copyWith(toolCalls: nextCalls);
state = AsyncData(current.copyWith(
messages: [...msgs.sublist(0, msgs.length - 1), updated]));
}
}
} catch (_) {
// Fall through to polling.
}
// Poll until complete (max 20 attempts, 2s apart)
try {
for (var attempt = 0; attempt < 20; attempt++) {
if (attempt > 0) await Future.delayed(const Duration(seconds: 2));
final fresh = await ref.read(briefingApiProvider).getMessages(convId);
final done = fresh.any(
(m) => m.role == MessageRole.assistant && m.status != 'generating',
);
final hasContent = fresh.any(
(m) => m.role == MessageRole.assistant && m.content.isNotEmpty,
);
final current = state.value;
if (current != null && (!streamedContent || done || hasContent)) {
state = AsyncData(current.copyWith(messages: fresh));
}
if (done) break;
}
} catch (_) {
// Clear the generating placeholder so UI doesn't spin forever.
final current = state.value;
if (current != null) {
final msgs = current.messages;
if (msgs.isNotEmpty && msgs.last.status == 'generating') {
state = AsyncData(current.copyWith(messages: [
...msgs.sublist(0, msgs.length - 1),
msgs.last.copyWith(status: 'complete'),
]));
}
}
} finally {
ref.read(isBriefingStreamingProvider.notifier).state = false;
}
}
}
+114
View File
@@ -118,6 +118,120 @@ class MessagesNotifier extends AsyncNotifier<List<Message>> {
}
}
/// Attach to an already-running generation for this conversation.
///
/// Used when the chat screen lands on a conversation that was started by
/// something other than a direct user message — e.g. the /news discuss
/// button, which creates a conversation on the backend and auto-kicks a
/// generation before navigating. Without this the stream runs to
/// completion invisibly and the screen only shows the final persisted
/// message after a manual refresh.
///
/// Safe to call unconditionally on screen init: no-ops when there is no
/// generating assistant message. Mirrors the web chat store's
/// reconnectIfGenerating() helper.
Future<void> attachToGeneration() async {
final convId = _convId;
final repo = ref.read(chatRepositoryProvider);
// Make sure we're looking at fresh server state before deciding whether
// to attach. The provider's build() fetches once; if the conversation
// was seeded via a POST that happened between build() and this call the
// generating placeholder won't be in our in-memory list yet.
try {
final (_, fresh) = await repo.getMessages(convId);
state = AsyncData(fresh);
} catch (_) {
// If we can't load messages we can't attach either — bail cleanly.
return;
}
if (ref.read(isStreamingProvider(convId))) return;
final msgs = state.value ?? const <Message>[];
final hasGeneratingAssistant = msgs.any(
(m) => m.role == MessageRole.assistant && m.status == 'generating',
);
if (!hasGeneratingAssistant) return;
ref.read(isStreamingProvider(convId).notifier).state = true;
const stallTimeout = Duration(seconds: 45);
bool streamedContent = false;
final iter = StreamIterator(repo.streamGeneration(convId));
try {
while (await iter.moveNext().timeout(stallTimeout)) {
final event = iter.current;
if (event is ChatTextChunk) {
streamedContent = true;
ref.read(streamingStatusProvider(convId).notifier).state = '';
final cur = state.requireValue;
if (cur.isEmpty) continue;
// Route text chunks into the generating assistant message. The
// last message is usually the placeholder, but tool-call fan-in
// means we can't rely on that universally.
final idx = _findGeneratingAssistantIndex(cur);
if (idx < 0) continue;
final updated = cur[idx].copyWith(content: cur[idx].content + event.text);
state = AsyncData([
...cur.sublist(0, idx),
updated,
...cur.sublist(idx + 1),
]);
} else if (event is ChatStatusUpdate) {
ref.read(streamingStatusProvider(convId).notifier).state = event.status;
} else if (event is ChatToolCall) {
final cur = state.requireValue;
if (cur.isEmpty) continue;
final idx = _findGeneratingAssistantIndex(cur);
if (idx < 0) continue;
final nextCalls = [...?cur[idx].toolCalls, event.toolCall];
final updated = cur[idx].copyWith(toolCalls: nextCalls);
state = AsyncData([
...cur.sublist(0, idx),
updated,
...cur.sublist(idx + 1),
]);
}
}
} on TimeoutException {
// Stall — fall through to polling.
} catch (_) {
// Stream failed — fall through to polling.
} finally {
await iter.cancel();
}
try {
for (var attempt = 0; attempt < 20; attempt++) {
if (attempt > 0) await Future.delayed(const Duration(seconds: 2));
final (_, fresh) = await repo.getMessages(convId);
final done = fresh.any(
(m) => m.role == MessageRole.assistant && m.status != 'generating',
);
final polledHasContent = fresh.any(
(m) => m.role == MessageRole.assistant && m.content.isNotEmpty,
);
if (!streamedContent || done || polledHasContent) {
state = AsyncData(fresh);
}
if (done) break;
}
} catch (_) {
// Give up silently — user can pull-to-refresh.
} finally {
ref.read(isStreamingProvider(convId).notifier).state = false;
ref.read(streamingStatusProvider(convId).notifier).state = '';
}
}
int _findGeneratingAssistantIndex(List<Message> msgs) {
for (var i = msgs.length - 1; i >= 0; i--) {
final m = msgs[i];
if (m.role == MessageRole.assistant && m.status == 'generating') return i;
}
return -1;
}
Future<void> sendMessage(String content) async {
final convId = _convId;
final repo = ref.read(chatRepositoryProvider);
+21 -4
View File
@@ -59,22 +59,29 @@ class VoiceState {
final VoiceMode mode;
final bool voiceModeActive;
final bool available;
/// Normalized mic amplitude 0.01.0 while recording. Drives the live
/// pulse on VoiceMicButton so the user has obvious feedback that audio
/// is actually being picked up.
final double amplitude;
const VoiceState({
this.mode = VoiceMode.idle,
this.voiceModeActive = false,
this.available = true,
this.amplitude = 0.0,
});
VoiceState copyWith({
VoiceMode? mode,
bool? voiceModeActive,
bool? available,
double? amplitude,
}) =>
VoiceState(
mode: mode ?? this.mode,
voiceModeActive: voiceModeActive ?? this.voiceModeActive,
available: available ?? this.available,
amplitude: amplitude ?? this.amplitude,
);
}
@@ -267,14 +274,24 @@ class VoiceNotifier extends Notifier<VoiceState> {
void _onAmplitude(Amplitude event) {
if (!state.voiceModeActive) return;
final db = event.current;
final validDb = !(db.isNaN || db.isInfinite);
// Normalize dB to 0..1 for the pulse animation. -60dB is dead-quiet,
// 0dB is peak; we clamp and bias so the button visibly breathes even
// on soft speech without exploding on loud input.
if (validDb) {
final norm = ((db + 60.0) / 60.0).clamp(0.0, 1.0);
if ((norm - state.amplitude).abs() > 0.02) {
state = state.copyWith(amplitude: norm);
}
}
final elapsed =
DateTime.now().millisecondsSinceEpoch - _recordingStartMs;
if (elapsed < _minRecordingMs) return;
final db = event.current;
// Guard against NaN / ±Infinity which can arrive on some Android devices
// when the recorder is initialising. Treat invalid readings as silence.
final isSilent = db.isNaN || db.isInfinite || db < _silenceThresholdDb;
final isSilent = !validDb || db < _silenceThresholdDb;
if (isSilent) {
_silenceMs += 200;
+30 -4
View File
@@ -41,7 +41,14 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
final wasBackground = !_appInForeground;
_appInForeground = state == AppLifecycleState.resumed;
// On resume, force a message refresh. This also unfreezes a stuck
// streaming state if the SSE socket died while the app was backgrounded
// — silentRefresh won't do that because it guards on isStreaming.
if (_appInForeground && wasBackground && mounted) {
ref.read(briefingProvider.notifier).refreshMessages();
}
}
void _pollSilently() {
@@ -51,6 +58,18 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
ref.read(briefingProvider.notifier).silentRefresh();
}
Future<void> _pullToRefresh() async {
try {
await ref.read(briefingProvider.notifier).refreshMessages();
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not refresh.')),
);
}
}
}
@override
void dispose() {
_pollTimer?.cancel();
@@ -250,9 +269,14 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
return Column(
children: [
Expanded(
child: CustomScrollView(
controller: _scrollController,
slivers: [
child: RefreshIndicator(
onRefresh: _pullToRefresh,
child: CustomScrollView(
controller: _scrollController,
// AlwaysScrollable so pull-to-refresh fires even when
// the briefing is empty or shorter than the viewport.
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
if (conv.messages.isEmpty)
SliverFillRemaining(
child: Center(
@@ -293,7 +317,8 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
},
),
),
],
],
),
),
),
@@ -351,6 +376,7 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
VoiceMicButton(
mode: voiceState.mode,
voiceModeActive: voiceState.voiceModeActive,
amplitude: voiceState.amplitude,
onTap: _toggleVoiceMode,
),
const SizedBox(width: 6),
+11
View File
@@ -44,6 +44,16 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
// If we land on a conversation whose last assistant message is already
// mid-stream (e.g. the /news discuss button creates a conv and
// auto-kicks generation), attach to the running stream so the user sees
// live tokens instead of a frozen placeholder.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ref
.read(messagesProvider(widget.conversationId).notifier)
.attachToGeneration();
});
}
@override
@@ -257,6 +267,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
VoiceMicButton(
mode: voiceState.mode,
voiceModeActive: voiceState.voiceModeActive,
amplitude: voiceState.amplitude,
onTap: _toggleVoiceMode,
),
const SizedBox(width: 6),
+8 -4
View File
@@ -113,16 +113,20 @@ class NewsCard extends StatelessWidget {
],
),
const SizedBox(height: 4),
// Title — tappable if URL present
// Title — tappable if URL present. Text stays in onSurface for
// contrast; the primary-colored underline carries the "this is a
// link" signal without tanking readability.
GestureDetector(
onTap: item.url.isNotEmpty ? _openUrl : null,
child: Text(
item.title,
style: textTheme.bodyMedium?.copyWith(
style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: item.url.isNotEmpty ? scheme.primary : scheme.onSurface,
color: scheme.onSurface,
height: 1.3,
decoration: item.url.isNotEmpty ? TextDecoration.underline : null,
decorationColor: scheme.primary,
decorationColor: scheme.primary.withValues(alpha: 0.7),
decorationThickness: 1.5,
),
),
),
+36 -51
View File
@@ -5,51 +5,29 @@ import '../providers/voice_provider.dart';
/// Animated mic button that reflects the current [VoiceMode].
///
/// - idle: muted background, mic_none icon
/// - recording: red with pulsing shadow ring
/// - recording: red, pulses with live [amplitude] for real-time feedback
/// - transcribing: indigo with spinner
/// - playing: indigo with volume_up icon
class VoiceMicButton extends StatefulWidget {
class VoiceMicButton extends StatelessWidget {
final VoiceMode mode;
final bool voiceModeActive;
/// Live mic amplitude 0.01.0 while recording. Drives the button scale
/// and glow so the user has obvious feedback that audio is being picked
/// up. Ignored when not in [VoiceMode.recording].
final double amplitude;
final VoidCallback? onTap;
const VoiceMicButton({
super.key,
required this.mode,
required this.voiceModeActive,
this.amplitude = 0.0,
this.onTap,
});
@override
State<VoiceMicButton> createState() => _VoiceMicButtonState();
}
class _VoiceMicButtonState extends State<VoiceMicButton>
with SingleTickerProviderStateMixin {
late AnimationController _pulseController;
late Animation<double> _pulseAnimation;
@override
void initState() {
super.initState();
_pulseController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
)..repeat(reverse: true);
_pulseAnimation = Tween<double>(begin: 1.0, end: 1.25).animate(
CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_pulseController.dispose();
super.dispose();
}
Color _bgColor(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return switch (widget.mode) {
return switch (mode) {
VoiceMode.recording => const Color(0xFFEF4444),
VoiceMode.transcribing || VoiceMode.playing => cs.primary,
VoiceMode.idle => cs.surfaceContainerHighest,
@@ -58,11 +36,10 @@ class _VoiceMicButtonState extends State<VoiceMicButton>
Widget _icon(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final iconColor = widget.mode == VoiceMode.idle
? cs.onSurfaceVariant
: Colors.white;
final iconColor =
mode == VoiceMode.idle ? cs.onSurfaceVariant : Colors.white;
return switch (widget.mode) {
return switch (mode) {
VoiceMode.transcribing => SizedBox(
width: 18,
height: 18,
@@ -78,14 +55,14 @@ class _VoiceMicButtonState extends State<VoiceMicButton>
@override
Widget build(BuildContext context) {
final isRecording = widget.mode == VoiceMode.recording;
final isRecording = mode == VoiceMode.recording;
final button = Material(
color: _bgColor(context),
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: widget.onTap,
onTap: onTap,
child: SizedBox(
width: 40,
height: 40,
@@ -96,22 +73,30 @@ class _VoiceMicButtonState extends State<VoiceMicButton>
if (!isRecording) return button;
return AnimatedBuilder(
animation: _pulseAnimation,
builder: (_, child) => Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: const Color(0xFFEF4444).withValues(alpha: 0.35),
blurRadius: 8 * _pulseAnimation.value,
spreadRadius: 2 * _pulseAnimation.value,
),
],
),
child: child,
// Base pulse so silence still breathes (0.1 floor), scale + glow climb
// linearly with live amplitude.
final amp = amplitude.clamp(0.0, 1.0);
final pulse = 0.1 + amp * 0.9;
return AnimatedContainer(
duration: const Duration(milliseconds: 120),
curve: Curves.easeOut,
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: const Color(0xFFEF4444).withValues(alpha: 0.2 + pulse * 0.3),
blurRadius: 6 + pulse * 14,
spreadRadius: 1 + pulse * 5,
),
],
),
child: AnimatedScale(
scale: 1.0 + pulse * 0.18,
duration: const Duration(milliseconds: 120),
curve: Curves.easeOut,
child: button,
),
child: button,
);
}
}