Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee0f354312 | |||
| bdaa5210f0 | |||
| ad20c9f9d4 | |||
| 48c134ce6a | |||
| 634b6d05cf | |||
| 00878a8a42 | |||
| ddbf867b03 | |||
| 51f1cffe79 | |||
| fa84e40efc |
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -59,22 +59,29 @@ class VoiceState {
|
||||
final VoiceMode mode;
|
||||
final bool voiceModeActive;
|
||||
final bool available;
|
||||
/// Normalized mic amplitude 0.0–1.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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,10 +99,23 @@ class VoiceNotifier extends Notifier<VoiceState> {
|
||||
StreamSubscription<Amplitude>? _amplitudeSubscription;
|
||||
|
||||
// Recording / silence detection
|
||||
//
|
||||
// Silence threshold is dynamic: we track the session peak dBFS and treat
|
||||
// "silent" as "current level is at least _dropFromPeakDb below peak."
|
||||
// This auto-calibrates to whatever mic + room the user is on rather than
|
||||
// assuming a fixed ambient level. Until the peak climbs above
|
||||
// _dynamicArmDb we fall back to a conservative static threshold so a
|
||||
// quiet room doesn't spin forever. A grace period at the start of the
|
||||
// recording gives the user time to begin speaking before silence checks
|
||||
// arm.
|
||||
int _recordingStartMs = 0;
|
||||
int _silenceMs = 0;
|
||||
static const _silenceThresholdDb = -40.0;
|
||||
static const _silenceDurationMs = 1500;
|
||||
double _peakDb = -100.0;
|
||||
static const _fallbackThresholdDb = -35.0;
|
||||
static const _dropFromPeakDb = 15.0;
|
||||
static const _dynamicArmDb = -20.0;
|
||||
static const _graceMs = 1500;
|
||||
static const _silenceDurationMs = 2000;
|
||||
static const _minRecordingMs = 300;
|
||||
|
||||
// Voice mode callbacks
|
||||
@@ -229,6 +249,7 @@ class VoiceNotifier extends Notifier<VoiceState> {
|
||||
if (!state.voiceModeActive) return;
|
||||
|
||||
_silenceMs = 0;
|
||||
_peakDb = -100.0;
|
||||
_recordingStartMs = DateTime.now().millisecondsSinceEpoch;
|
||||
state = state.copyWith(mode: VoiceMode.recording);
|
||||
|
||||
@@ -267,14 +288,33 @@ 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);
|
||||
}
|
||||
if (db > _peakDb) _peakDb = db;
|
||||
}
|
||||
|
||||
final elapsed =
|
||||
DateTime.now().millisecondsSinceEpoch - _recordingStartMs;
|
||||
// Grace period at the start — let the user begin speaking before we
|
||||
// start silence-counting. Also suppresses any false triggers while
|
||||
// the native recorder is still warming up.
|
||||
if (elapsed < _graceMs) return;
|
||||
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;
|
||||
// Dynamic threshold once we've seen real speech; static fallback
|
||||
// before that so a dead-silent session doesn't spin forever.
|
||||
final threshold =
|
||||
_peakDb > _dynamicArmDb ? _peakDb - _dropFromPeakDb : _fallbackThresholdDb;
|
||||
final isSilent = !validDb || db < threshold;
|
||||
|
||||
if (isSilent) {
|
||||
_silenceMs += 200;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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.0–1.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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user