Compare commits

...

28 Commits

Author SHA1 Message Date
bvandeusen 3c9602c7c9 Merge pull request 'feat: voice fixes, background updates, rss_enabled gating, chat images' (#27) from dev into main 2026-04-18 03:36:14 +00:00
bvandeusen aba0ca6256 feat: gate News tab and briefing news cards on server rss_enabled setting
Fetch server-side settings on app launch via new serverSettingsProvider.
Hide News from bottom sheet and NavigationRail when RSS is disabled.
Skip news card rendering in briefing messages when disabled.
Refactor tab navigation to use dynamic tabs list and route-based refresh.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 14:29:05 -04:00
bvandeusen 70a3279192 feat: background update download, snackbar prompt, chat image rendering
Update provider: auto-downloads APK in background after finding a newer
version, prompts via snackbar only when ready, cleans up old APKs on
startup. Replaces modal dialog with dismissible snackbar.

Chat bubble: resolve relative image URLs (/api/images/{id}) against
server base URL with auth cookies so search_images results render on
the phone app.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 12:38:48 -04:00
bvandeusen fc6c9648f9 fix(voice): eliminate dual-recorder conflict and defunct element crashes
VadHandler is now the sole mic owner — removed separate AudioRecorder that
caused audio focus contention on Android. Audio from onSpeechEnd is encoded
as WAV for Whisper. Provider switched to autoDispose to match widget lifecycle,
preventing defunct element assertions. Recording UI deferred until mic is open.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 08:04:03 -04:00
bvandeusen 413b82f724 Merge pull request 'Release v26.04.16.1 — Silero VAD voice detection' (#26) from dev into main 2026-04-17 01:00:54 +00:00
bvandeusen 5f11b344a3 feat(voice): replace amplitude silence detection with Silero VAD
Use VadHandler from the vad package (Silero VAD v5 ONNX) for speech
detection instead of amplitude thresholds. VAD runs its own PCM
stream while the file recorder captures AAC-LC for Whisper. Grace
period starts on speech-start, auto-stop on speech-end after grace.
No-speech guard shows error on manual stop without detected speech.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 20:57:52 -04:00
bvandeusen 8959b62abe chore: upgrade record to v6, add vad package
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 20:55:18 -04:00
bvandeusen c967a49e5a Release v26.04.15.1 — Tablet landscape improvements 2026-04-16 05:52:49 +00:00
bvandeusen 58d4cfab4d feat: tablet landscape improvements
Shell: move Projects/News/Calendar into ShellRoute so the NavigationRail
persists across all screens. Show all 6 nav destinations on tablet
instead of 3+More overflow. Phone bottom nav unchanged.

Chat: master-detail layout on tablet — conversations list (320px) with
inline chat panel. Tapping a conversation updates state instead of
pushing a new route.

Knowledge: responsive grid (2 cols portrait, 3 cols landscape) with
card layout showing icon, title, body preview, and tags. Fix snippet
data — read json['snippet'] which the API actually sends. Bump snippet
length from 120 to 200 chars. Tasks now show description as snippet.

News: responsive grid with the same breakpoints. Larger snippet
(5 lines) in grid mode via snippetMaxLines parameter.

Briefing: centered reading column (maxWidth 700) on wide screens,
matching the web UI layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 01:31:52 -04:00
bvandeusen ee0f354312 Merge pull request 'Release v26.04.15.1 — Dynamic voice silence threshold' (#24) from dev into main 2026-04-15 04:38:25 +00:00
bvandeusen bdaa5210f0 feat(voice): dynamic silence threshold
The previous -40 dBFS static threshold sat right on top of typical
phone mic ambient, so silence detection rarely fired and the user
always had to tap stop manually.

Silence threshold is now dynamic: track the session peak dBFS and
treat "silent" as 15 dB below peak. Auto-calibrates per mic and
environment rather than assuming a fixed ambient level.

- Grace period (1500 ms) at start so the user has time to begin
  speaking before checks arm.
- Static -35 dB fallback until the peak clears -20 dB so a dead-
  silent session doesn't spin forever.
- Silence duration bumped 1500 → 2000 ms for breathing room.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 00:28:04 -04:00
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
bvandeusen 1c4e3c018b Merge pull request 'Release v26.04.13.2' (#20) from dev into main 2026-04-13 23:00:36 +00:00
bvandeusen 75b7d6d0fe feat(chat): stall watchdog, pull-to-refresh, and unfreeze on refresh
Mobile chat occasionally froze with the input disabled and nothing
progressing — the underlying cause is SSE sockets dropping silently on
mobile network handoffs / proxy idle timeouts. Dio never observes the
close, so the send loop's `await for` hangs indefinitely with
isStreaming=true.

Three changes:
- Wrap the SSE stream in a StreamIterator with a 45s per-event timeout
  as a stall watchdog. On timeout, break out and let the polling pass
  reconcile state from the server.
- Add pull-to-refresh on the chat message list and an AppBar refresh
  button with inline spinner feedback. Works on both empty and
  populated states.
- MessagesNotifier.refresh() now also clears isStreaming /
  streamingStatus when the server reports the latest assistant message
  as complete — so a stuck UI unfreezes the moment the user pulls down
  or taps refresh, even if the original SSE loop is still hanging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 18:13:46 -04:00
bvandeusen 8ea244ecaa feat(chat): tap tool-call chip to open the created note/task/event
Chips now show the entity title ("Created note: Grocery List") and
deep-link to the matching screen when tapped — notes to the detail
screen, tasks to the edit screen, projects to the project tasks view,
events to the calendar. Read-only tools and errors stay non-tappable.

Closes the main goal behind the mobile chip work: seeing that a note
was created and jumping straight to it without backing out to the
library.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 18:06:03 -04:00
bvandeusen a3fe0b4b61 feat(chat): render tool-call chips and live peek status in mobile bubbles
The mobile app was receiving SSE status events but showing the peek text
only while the assistant bubble was empty — as soon as the first token
arrived, the status line was replaced by streaming content and any tool
calls fired mid-turn left no trace. Tool call SSE events were also being
dropped by the parser, and Message.fromJson never read the persisted
tool_calls array, so chips never rendered after reload either.

Parse tool_call SSE frames into a new ChatToolCall event, carry tool
calls on Message, and update the chat and briefing streaming loops to
append chips to the in-flight assistant message as they arrive. Rework
ChatMessageBubble to show a chip row + rolling peek status line above
any streamed text, matching the web ToolCallCard/status indicator
behaviour across chat, briefing, and briefing history surfaces.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 17:48:16 -04:00
bvandeusen 6771ec5e81 Merge pull request 'Release v26.04.13.1' (#19) from dev into main 2026-04-13 05:13:44 +00:00
bvandeusen 4240c90d55 fix(voice,knowledge): mic recording + pagination + STT-only mode
Voice: recreate AudioRecorder each session to avoid stale native
state, improve permission handling (re-check after settings), show
feedback after 3 consecutive empty transcripts, allow STT-only mode
when TTS is unavailable.

Knowledge: hydrate IDs immediately after loading more pages so
scroll-based pagination doesn't stall at the bottom.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 11:34:00 -04:00
bvandeusen 36644cf8a5 ci: switch to ci-runner base image
Update runs-on from py3.12-node22 to ci-runner to match the new
shared runner base image with uv, ruff, jq, and tzdata baked in.
2026-04-12 00:00:40 -04:00
bvandeusen cb5ce44bbe ci: gate on dev/main pushes, pin Flutter, extract release script
- trigger: add branches: [dev, main] so analyze+test run on every
  push, not just release tags. Build job stays tag-gated via
  if: startsWith(github.ref, 'refs/tags/')
- pin Flutter to 3.41.6 instead of floating :stable for reproducible
  release builds
- concurrency: cancel in-progress non-tag runs
- permissions: contents: read default, contents: write scoped to build
- extract release publish logic to scripts/publish_apk_release.sh so
  it's testable locally (bash -x with env vars) and the YAML stays
  readable. Adds set -euo pipefail + curl -f so failures surface
  instead of getting swallowed by || echo "skipped"
- drop the broken artifact upload step (silently swallowed errors)
2026-04-11 16:11:50 -04:00
bvandeusen 356709856f Merge pull request 'fix(test): CalendarEvent test expects local time after toLocal() change' (#18) from dev into main 2026-04-08 21:22:44 +00:00
bvandeusen 6e067f99ef fix(test): update CalendarEvent test to expect local time after toLocal() change 2026-04-08 17:16:23 -04:00
32 changed files with 1743 additions and 668 deletions
+43 -57
View File
@@ -1,6 +1,12 @@
# CI runs only on release tags.
# CI runs first; build only proceeds if all checks pass.
#
# Tag v*: analyze + test → build APK → 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.
@@ -11,19 +17,39 @@
# commands directly instead.
#
# Required secrets (repo → Settings → Secrets → Actions):
# RELEASE_TOKEN — Forgejo PAT with write:repository scope
# RELEASE_TOKEN — Forgejo PAT with write:repository scope
# RELEASE_KEYSTORE_BASE64 — base64 of the signing keystore
# RELEASE_KEYSTORE_PASSWORD — keystore + key password
# RELEASE_KEY_ALIAS — key alias within the keystore
name: CI & Build
on:
push:
branches: [dev]
tags: ["v*"]
# Cancel older runs on the same branch when a newer push lands. Tag runs
# are never cancelled so a release build can't kill itself mid-flight.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
# Least-privilege default. The build job upgrades to contents: write
# so it can attach the APK to a Forgejo release.
permissions:
contents: read
jobs:
analyze:
name: Analyze & test
runs-on: py3.12-node22
runs-on: ci-runner
container:
image: ghcr.io/cirruslabs/flutter:stable
# Pinned to a specific Flutter version for reproducible builds.
# Floating :stable means a random Flutter minor bump could change
# analyzer output or break the build without any commit landing.
# Bump this line (and verify locally with `flutter --version`)
# when you intentionally want a newer Flutter.
image: ghcr.io/cirruslabs/flutter:3.41.6
steps:
- name: Checkout
run: |
@@ -42,9 +68,14 @@ jobs:
build:
name: Build release APK
needs: [analyze]
runs-on: py3.12-node22
# 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
container:
image: ghcr.io/cirruslabs/flutter:stable
image: ghcr.io/cirruslabs/flutter:3.41.6
permissions:
contents: write
steps:
- name: Checkout
run: |
@@ -74,57 +105,12 @@ jobs:
--build-name="$BUILD_NAME" \
--build-number="$BUILD_NUMBER"
- name: Set artifact name
id: artifact
run: |
echo "name=fabledapp-${{ github.ref_name }}-${{ github.sha }}" >> $GITHUB_OUTPUT
- name: Upload artifact to Forgejo
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
APK: build/app/outputs/flutter-apk/app-release.apk
API: https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledApp
ARTIFACT_NAME: ${{ steps.artifact.outputs.name }}
run: |
# Upload the APK as a workflow artifact via the Forgejo API.
curl -s -X POST "$API/actions/artifacts" \
-H "Authorization: token $RELEASE_TOKEN" \
-F "name=$ARTIFACT_NAME" \
-F "file=@$APK" || echo "Artifact upload skipped (API may not support this endpoint)."
- name: Publish Forgejo release
# Release-publish logic lives in a shell script so it's
# testable locally (bash -x scripts/publish_apk_release.sh
# with env vars set) instead of trapped in YAML.
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
TAG: ${{ github.ref_name }}
API: https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledApp
run: |
# Look for an existing release (created via the UI or a prior run).
EXISTING=$(curl -s \
"$API/releases/tags/$TAG" \
-H "Authorization: token $RELEASE_TOKEN")
RELEASE_ID=$(echo "$EXISTING" | grep -oE '"id":[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+')
if [ -n "$RELEASE_ID" ]; then
echo "Found existing release $TAG (id $RELEASE_ID), attaching APK..."
else
echo "No existing release found, creating $TAG..."
RESPONSE=$(curl -s -X POST "$API/releases" \
-H "Authorization: token $RELEASE_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"$TAG\", \"name\": \"$TAG\", \"body\": \"\"}")
RELEASE_ID=$(echo "$RESPONSE" | grep -oE '"id":[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+')
if [ -z "$RELEASE_ID" ]; then
echo "Failed to create release. API response:"
echo "$RESPONSE"
exit 1
fi
echo "Release created with id $RELEASE_ID."
fi
curl -s -X POST "$API/releases/$RELEASE_ID/assets" \
-H "Authorization: token $RELEASE_TOKEN" \
-F "attachment=@build/app/outputs/flutter-apk/app-release.apk"
echo "Done — $TAG is live at:"
echo "https://git.fabledsword.com/bvandeusen/FabledApp/releases/tag/$TAG"
APK: build/app/outputs/flutter-apk/app-release.apk
run: bash scripts/publish_apk_release.sh
+87 -122
View File
@@ -159,20 +159,20 @@ final routerProvider = Provider<GoRouter>((ref) {
path: Routes.conversations,
builder: (_, _) => const ConversationsTabScreen(),
),
GoRoute(
path: Routes.projects,
builder: (_, _) => const ProjectsScreen(),
),
GoRoute(
path: Routes.news,
builder: (_, _) => const NewsScreen(),
),
GoRoute(
path: Routes.calendar,
builder: (_, _) => const CalendarScreen(),
),
],
),
GoRoute(
path: Routes.projects,
builder: (_, _) => const ProjectsScreen(),
),
GoRoute(
path: Routes.news,
builder: (_, _) => const NewsScreen(),
),
GoRoute(
path: Routes.calendar,
builder: (_, _) => const CalendarScreen(),
),
],
);
});
@@ -186,12 +186,19 @@ class _Shell extends ConsumerStatefulWidget {
}
class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
static const _tabs = [
static const _baseTabs = [
Routes.briefing,
Routes.knowledge,
Routes.conversations,
Routes.projects,
];
List<String> _tabs(bool rssEnabled) => [
..._baseTabs,
if (rssEnabled) Routes.news,
Routes.calendar,
];
// Minimum gap between app-resume refreshes to avoid hammering the server.
static const _resumeCooldown = Duration(seconds: 30);
DateTime? _lastResumeRefresh;
@@ -202,6 +209,8 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
super.initState();
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) {
// Clean up any leftover APKs from previous update cycles.
ref.read(updateProvider.notifier).cleanup();
// Silent update check — only if we haven't already checked this session.
final repoUrl = ref.read(forgejoRepoUrlProvider);
if (repoUrl != null && repoUrl.isNotEmpty) {
@@ -246,15 +255,18 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
ref.invalidate(briefingProvider);
}
/// Refresh only the provider backing the given shell tab index.
void _refreshTab(int index) {
switch (index) {
case 0:
ref.invalidate(briefingProvider);
case 1:
ref.read(knowledgeProvider.notifier).refresh();
case 2:
ref.read(conversationsProvider.notifier).refresh();
/// Refresh only the provider backing the given shell tab route.
void _refreshTab(String route) {
if (route == Routes.briefing) {
ref.invalidate(briefingProvider);
} else if (route == Routes.knowledge) {
ref.read(knowledgeProvider.notifier).refresh();
} else if (route == Routes.conversations) {
ref.read(conversationsProvider.notifier).refresh();
} else if (route == Routes.news) {
ref.read(newsProvider.notifier).refresh();
} else if (route == Routes.calendar) {
ref.read(calendarProvider.notifier).refresh();
}
}
@@ -267,14 +279,9 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
}
}
int _tabIndex(String location) {
for (var i = 0; i < _tabs.length; i++) {
if (location.startsWith(_tabs[i])) return i;
}
if (location.startsWith(Routes.projects) ||
location.startsWith(Routes.news) ||
location.startsWith(Routes.calendar)) {
return 3;
int _tabIndex(String location, List<String> tabs) {
for (var i = 0; i < tabs.length; i++) {
if (location.startsWith(tabs[i])) return i;
}
return 0;
}
@@ -294,14 +301,15 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
context.push(Routes.projects);
},
),
ListTile(
leading: const Icon(Icons.newspaper_outlined),
title: const Text('News'),
onTap: () {
Navigator.pop(context);
context.push(Routes.news);
},
),
if (ref.read(rssEnabledProvider))
ListTile(
leading: const Icon(Icons.newspaper_outlined),
title: const Text('News'),
onTap: () {
Navigator.pop(context);
context.push(Routes.news);
},
),
ListTile(
leading: const Icon(Icons.calendar_month_outlined),
title: const Text('Calendar'),
@@ -316,66 +324,15 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
);
}
void _showUpdateDialog(UpdateState update) {
showDialog<void>(
context: context,
builder: (dialogContext) => Consumer(
builder: (context, ref, _) {
final state = ref.watch(updateProvider);
final isDownloading = state.status == UpdateStatus.downloading;
return AlertDialog(
title: const Text('Update available'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Version ${state.latestVersion ?? '?'} is ready to install.'),
if (state.currentVersion != null)
Text(
'Installed: v${state.currentVersion}',
style: Theme.of(context).textTheme.bodySmall,
),
if (isDownloading) ...[
const SizedBox(height: 16),
LinearProgressIndicator(
value: state.downloadProgress > 0
? state.downloadProgress
: null,
),
const SizedBox(height: 4),
Text(
'Downloading… '
'${(state.downloadProgress * 100).toStringAsFixed(0)}%',
style: Theme.of(context).textTheme.bodySmall,
),
],
if (state.status == UpdateStatus.error &&
state.errorMessage != null) ...[
const SizedBox(height: 12),
Text(
state.errorMessage!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
],
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Later'),
),
if (!isDownloading && state.downloadUrl != null)
FilledButton(
onPressed: () => ref
.read(updateProvider.notifier)
.downloadAndInstall(),
child: const Text('Download & Install'),
),
],
);
},
void _showUpdateSnackbar(UpdateState update) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('v${update.latestVersion} ready to install'),
duration: const Duration(seconds: 6),
action: SnackBarAction(
label: 'Install',
onPressed: () => ref.read(updateProvider.notifier).install(),
),
),
);
}
@@ -384,18 +341,21 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
Widget build(BuildContext context) {
// Show update dialog once when a new version is detected.
ref.listen(updateProvider, (prev, next) {
if (next.status == UpdateStatus.available &&
prev?.status != UpdateStatus.available) {
if (next.status == UpdateStatus.readyToInstall &&
prev?.status != UpdateStatus.readyToInstall) {
WidgetsBinding.instance
.addPostFrameCallback((_) => _showUpdateDialog(next));
.addPostFrameCallback((_) => _showUpdateSnackbar(next));
}
});
final rssEnabled = ref.watch(rssEnabledProvider);
final tabs = _tabs(rssEnabled);
final location = GoRouterState.of(context).matchedLocation;
final index = _tabIndex(location);
final index = _tabIndex(location, tabs);
// Refresh the incoming tab's data when switching between shell tabs.
if (_prevTabIndex != null && _prevTabIndex != index && index < 3) {
WidgetsBinding.instance.addPostFrameCallback((_) => _refreshTab(index));
if (_prevTabIndex != null && _prevTabIndex != index) {
final route = index < tabs.length ? tabs[index] : tabs[0];
WidgetsBinding.instance.addPostFrameCallback((_) => _refreshTab(route));
}
_prevTabIndex = index;
@@ -413,34 +373,39 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
children: [
NavigationRail(
selectedIndex: index,
onDestinationSelected: (i) {
if (i == 3) {
_showMoreSheet(context);
} else {
context.go(_tabs[i]);
}
},
onDestinationSelected: (i) => context.go(tabs[i]),
labelType: NavigationRailLabelType.all,
destinations: const [
NavigationRailDestination(
destinations: [
const NavigationRailDestination(
icon: Icon(Icons.wb_sunny_outlined),
selectedIcon: Icon(Icons.wb_sunny),
label: Text('Briefing'),
),
NavigationRailDestination(
const NavigationRailDestination(
icon: Icon(Icons.menu_book_outlined),
selectedIcon: Icon(Icons.menu_book),
label: Text('Knowledge'),
),
NavigationRailDestination(
const NavigationRailDestination(
icon: Icon(Icons.chat_bubble_outline),
selectedIcon: Icon(Icons.chat_bubble),
label: Text('Chat'),
),
NavigationRailDestination(
icon: Icon(Icons.more_horiz_outlined),
selectedIcon: Icon(Icons.more_horiz),
label: Text('More'),
const NavigationRailDestination(
icon: Icon(Icons.folder_outlined),
selectedIcon: Icon(Icons.folder),
label: Text('Projects'),
),
if (ref.watch(rssEnabledProvider))
const NavigationRailDestination(
icon: Icon(Icons.newspaper_outlined),
selectedIcon: Icon(Icons.newspaper),
label: Text('News'),
),
const NavigationRailDestination(
icon: Icon(Icons.calendar_month_outlined),
selectedIcon: Icon(Icons.calendar_month),
label: Text('Calendar'),
),
],
),
@@ -469,12 +434,12 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: index,
selectedIndex: index >= 3 ? 3 : index,
onDestinationSelected: (i) {
if (i == 3) {
_showMoreSheet(context);
} else {
context.go(_tabs[i]);
context.go(tabs[i]);
}
},
destinations: const [
@@ -523,7 +488,6 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
@override
void dispose() {
_controller.dispose();
ref.read(voiceProvider.notifier).exitVoiceMode();
super.dispose();
}
@@ -653,6 +617,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(
+17
View File
@@ -18,6 +18,15 @@ class ChatStatusUpdate extends ChatStreamEvent {
ChatStatusUpdate(this.status);
}
/// A single tool call fired during generation. Mirrors the `tool_call` SSE
/// event emitted by `generation_task.py` and the `tool_calls` array persisted
/// on the assistant Message row — same shape either way so the UI can render
/// live chips during streaming and re-render them from storage after reload.
class ChatToolCall extends ChatStreamEvent {
final Map<String, dynamic> toolCall;
ChatToolCall(this.toolCall);
}
class ChatApi {
final Dio _dio;
const ChatApi(this._dio);
@@ -136,6 +145,14 @@ class ChatApi {
} catch (_) {
// Ignore malformed status events
}
} else if (currentEvent == 'tool_call') {
try {
final obj = json.decode(data) as Map<String, dynamic>;
final tc = obj['tool_call'];
if (tc is Map<String, dynamic>) yield ChatToolCall(tc);
} catch (_) {
// Ignore malformed tool_call events
}
}
} else if (line.isEmpty) {
currentEvent = ''; // blank line = SSE event separator
+9
View File
@@ -10,4 +10,13 @@ class SettingsApi {
data: {'user_timezone': ianaTimezone},
);
}
Future<Map<String, dynamic>> getAll() async {
final response = await _dio.get<Map<String, dynamic>>('/api/settings');
return response.data ?? {};
}
Future<void> update(Map<String, String> updates) async {
await _dio.put<void>('/api/settings', data: updates);
}
}
+6 -5
View File
@@ -15,8 +15,9 @@ class VoiceStatus {
required this.tts,
});
/// True only when voice is enabled AND both STT and TTS are ready.
bool get fullyAvailable => enabled && stt && tts;
/// True when voice is enabled and at least STT is ready.
/// TTS is optional — voice mode works without it (STT-only).
bool get fullyAvailable => enabled && stt;
factory VoiceStatus.fromJson(Map<String, dynamic> json) => VoiceStatus(
enabled: json['enabled'] as bool? ?? false,
@@ -39,7 +40,7 @@ class VoiceApi {
}
}
/// POST WebM/Opus audio bytes and return the transcript string.
/// POST audio bytes (WAV) and return the transcript string.
/// [context] is optional recent conversation text passed as initial_prompt
/// to Whisper, reducing mishearings of domain-specific words.
/// Returns empty string on empty or error response.
@@ -48,8 +49,8 @@ class VoiceApi {
final fields = <String, dynamic>{
'audio': MultipartFile.fromBytes(
audioBytes,
filename: 'audio.m4a',
contentType: DioMediaType('audio', 'mp4'),
filename: 'audio.wav',
contentType: DioMediaType('audio', 'wav'),
),
if (context != null && context.isNotEmpty) 'context': context,
};
+1 -1
View File
@@ -52,7 +52,7 @@ class KnowledgeItem {
id: json['id'] as int,
noteType: json['note_type'] as String? ?? 'note',
title: json['title'] as String? ?? '',
body: json['body'] as String? ?? '',
body: (json['snippet'] ?? json['body']) as String? ?? '',
tags: (json['tags'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
+36 -12
View File
@@ -8,6 +8,11 @@ class Message {
final String status; // "complete" | "generating"
final DateTime? createdAt;
final Map<String, dynamic>? metadata;
// Tool invocations attached to this message. Each entry matches the shape
// persisted by the backend (`function`, `arguments`, `result`, `status`) so
// the UI can render the same chips whether they arrive live over SSE or
// from a reload.
final List<Map<String, dynamic>>? toolCalls;
const Message({
this.id,
@@ -17,21 +22,39 @@ class Message {
this.status = 'complete',
this.createdAt,
this.metadata,
this.toolCalls,
});
factory Message.fromJson(Map<String, dynamic> json) => Message(
id: json['id'] as int?,
conversationId: json['conversation_id'] as int,
role: json['role'] == 'user' ? MessageRole.user : MessageRole.assistant,
content: json['content'] as String,
status: json['status'] as String? ?? 'complete',
createdAt: json['created_at'] != null
? DateTime.parse(json['created_at'] as String)
: null,
metadata: json['metadata'] as Map<String, dynamic>?,
);
factory Message.fromJson(Map<String, dynamic> json) {
final rawCalls = json['tool_calls'];
List<Map<String, dynamic>>? parsedCalls;
if (rawCalls is List) {
parsedCalls = [
for (final tc in rawCalls)
if (tc is Map<String, dynamic>) tc,
];
if (parsedCalls.isEmpty) parsedCalls = null;
}
return Message(
id: json['id'] as int?,
conversationId: json['conversation_id'] as int,
role: json['role'] == 'user' ? MessageRole.user : MessageRole.assistant,
content: json['content'] as String,
status: json['status'] as String? ?? 'complete',
createdAt: json['created_at'] != null
? DateTime.parse(json['created_at'] as String)
: null,
metadata: json['metadata'] as Map<String, dynamic>?,
toolCalls: parsedCalls,
);
}
Message copyWith({String? content, String? status}) => Message(
Message copyWith({
String? content,
String? status,
List<Map<String, dynamic>>? toolCalls,
}) =>
Message(
id: id,
conversationId: conversationId,
role: role,
@@ -39,5 +62,6 @@ class Message {
status: status ?? this.status,
createdAt: createdAt,
metadata: metadata,
toolCalls: toolCalls ?? this.toolCalls,
);
}
+2 -1
View File
@@ -1,5 +1,6 @@
import '../api/chat_api.dart';
export '../api/chat_api.dart' show ChatStreamEvent, ChatTextChunk, ChatStatusUpdate;
export '../api/chat_api.dart'
show ChatStreamEvent, ChatTextChunk, ChatStatusUpdate, ChatToolCall;
import '../models/conversation.dart';
import '../models/message.dart';
+127 -100
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,26 +113,106 @@ 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)) {
if (event is! ChatTextChunk) continue;
streamedContent = true;
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;
if (msgs.isEmpty) continue;
final updated =
msgs.last.copyWith(content: msgs.last.content + event.text);
state = AsyncData(current.copyWith(
messages: [...msgs.sublist(0, msgs.length - 1), updated]));
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]));
}
}
} 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));
@@ -128,93 +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)) {
if (event is! ChatTextChunk) continue;
streamedContent = true;
final current = state.value;
if (current == null) break;
final msgs = current.messages;
if (msgs.isEmpty) continue;
final updated =
msgs.last.copyWith(content: msgs.last.content + event.text);
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;
}
}
}
+161 -1
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/models/conversation.dart';
@@ -93,10 +95,141 @@ class MessagesNotifier extends AsyncNotifier<List<Message>> {
}
/// Re-fetch messages without clearing the current list (no flicker).
///
/// Also unfreezes the UI if streaming state got stuck true — this happens
/// when an SSE connection dies silently (mobile network handoff, app
/// backgrounded mid-stream, reverse proxy dropping idle sockets) and the
/// send loop never observes a close. If the server-side message is already
/// done, we clear `isStreamingProvider` so the input unlocks.
Future<void> refresh() async {
final (_, messages) =
await ref.read(chatRepositoryProvider).getMessages(_convId);
state = AsyncData(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(isStreamingProvider(_convId).notifier).state = false;
ref.read(streamingStatusProvider(_convId).notifier).state = '';
}
}
/// 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 {
@@ -129,9 +262,19 @@ class MessagesNotifier extends AsyncNotifier<List<Message>> {
}
// ── Step 2: Stream the response (best effort — silent on failure). ──
//
// We use a StreamIterator with a per-event timeout as a stall watchdog.
// Mobile networks occasionally drop SSE sockets silently: the TCP
// connection is half-closed, Dio never sees the close, and `await for`
// hangs forever with `isStreaming=true`, freezing the input. If no
// event arrives within the watchdog window we bail out and let the
// polling pass below reconcile state from the server.
const stallTimeout = Duration(seconds: 45);
bool streamedContent = false;
final iter = StreamIterator(repo.streamGeneration(convId));
try {
await for (final event in repo.streamGeneration(convId)) {
while (await iter.moveNext().timeout(stallTimeout)) {
final event = iter.current;
if (event is ChatTextChunk) {
streamedContent = true;
ref.read(streamingStatusProvider(convId).notifier).state = '';
@@ -143,10 +286,27 @@ class MessagesNotifier extends AsyncNotifier<List<Message>> {
} else if (event is ChatStatusUpdate) {
ref.read(streamingStatusProvider(convId).notifier).state =
event.status;
} else if (event is ChatToolCall) {
// Append the tool call to the in-flight assistant message so the
// chip appears live. The reload pass at the end of this function
// will overwrite with the persisted version, which carries the
// same shape — no de-dup needed.
final msgs = state.requireValue;
if (msgs.isEmpty) continue;
final last = msgs.last;
if (last.role != MessageRole.assistant) continue;
final nextCalls = [...?last.toolCalls, event.toolCall];
final updated = last.copyWith(toolCalls: nextCalls);
state = AsyncData([...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 (_) {
// SSE failed — fall through to the polling reload below.
} finally {
await iter.cancel();
}
// ── Step 3: Poll the API until we have a completed assistant response.
+3
View File
@@ -246,6 +246,9 @@ class KnowledgeNotifier extends Notifier<KnowledgeState> {
isLoadingIds: false,
hasMore: combined.length < total,
);
// Hydrate the newly fetched IDs immediately — the user is
// already at the scroll bottom so _onScroll won't re-fire.
await hydrateNext();
} catch (_) {
state = state.copyWith(isLoadingIds: false);
}
+31
View File
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'api_client_provider.dart';
const _kServerUrl = 'server_url';
const _kThemeMode = 'theme_mode';
const _kForgejoRepoUrl = 'forgejo_repo_url';
@@ -82,3 +84,32 @@ class ServerUrlNotifier extends Notifier<String?> {
state = null;
}
}
final serverSettingsProvider =
AsyncNotifierProvider<ServerSettingsNotifier, Map<String, dynamic>>(
ServerSettingsNotifier.new);
class ServerSettingsNotifier extends AsyncNotifier<Map<String, dynamic>> {
@override
Future<Map<String, dynamic>> build() async {
try {
return await ref.read(settingsApiProvider).getAll();
} catch (_) {
return {};
}
}
bool get rssEnabled {
final data = state.value ?? {};
return data['rss_enabled']?.toString().toLowerCase() == 'true';
}
Future<void> refresh() async {
state = AsyncData(await ref.read(settingsApiProvider).getAll());
}
}
final rssEnabledProvider = Provider<bool>((ref) {
final settings = ref.watch(serverSettingsProvider).value ?? {};
return settings['rss_enabled']?.toString().toLowerCase() == 'true';
});
+86 -32
View File
@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:open_file/open_file.dart';
@@ -5,7 +7,14 @@ import 'package:package_info_plus/package_info_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
enum UpdateStatus { idle, checking, available, downloading, upToDate, error }
enum UpdateStatus {
idle,
checking,
downloading,
readyToInstall,
upToDate,
error,
}
class UpdateState {
final UpdateStatus status;
@@ -14,6 +23,7 @@ class UpdateState {
final String? downloadUrl;
final double downloadProgress;
final String? errorMessage;
final String? apkPath;
const UpdateState({
this.status = UpdateStatus.idle,
@@ -22,6 +32,7 @@ class UpdateState {
this.downloadUrl,
this.downloadProgress = 0.0,
this.errorMessage,
this.apkPath,
});
UpdateState copyWith({
@@ -31,6 +42,7 @@ class UpdateState {
String? downloadUrl,
double? downloadProgress,
String? errorMessage,
String? apkPath,
}) =>
UpdateState(
status: status ?? this.status,
@@ -39,6 +51,7 @@ class UpdateState {
downloadUrl: downloadUrl ?? this.downloadUrl,
downloadProgress: downloadProgress ?? this.downloadProgress,
errorMessage: errorMessage ?? this.errorMessage,
apkPath: apkPath ?? this.apkPath,
);
}
@@ -46,20 +59,15 @@ class UpdateNotifier extends Notifier<UpdateState> {
@override
UpdateState build() => const UpdateState();
/// [repoUrl] is the Forgejo repo page URL, e.g.
/// "https://git.example.com/user/fabled_app"
Future<void> check(String repoUrl) async {
state = state.copyWith(status: UpdateStatus.checking);
try {
final packageInfo = await PackageInfo.fromPlatform();
// Combine versionName + buildNumber to match the YY.MM.DD.N tag format.
final currentVersion =
'${packageInfo.version}.${packageInfo.buildNumber}';
// Parse repo URL → Forgejo API endpoint
final uri = Uri.parse(repoUrl);
final parts =
uri.pathSegments.where((s) => s.isNotEmpty).toList();
final parts = uri.pathSegments.where((s) => s.isNotEmpty).toList();
if (parts.length < 2) throw 'Invalid repository URL (need /owner/repo)';
final apiUrl =
'${uri.scheme}://${uri.authority}/api/v1/repos/${parts[0]}/${parts[1]}/releases/latest';
@@ -70,20 +78,20 @@ class UpdateNotifier extends Notifier<UpdateState> {
tagName.startsWith('v') ? tagName.substring(1) : tagName;
if (_isNewer(latestVersion, currentVersion)) {
final assets =
(response.data['assets'] as List<dynamic>? ?? [])
.cast<Map<String, dynamic>>();
final assets = (response.data['assets'] as List<dynamic>? ?? [])
.cast<Map<String, dynamic>>();
final apk = assets.firstWhere(
(a) => (a['name'] as String? ?? '').endsWith('.apk'),
orElse: () => {},
);
if (apk.isNotEmpty) {
final downloadUrl = apk['browser_download_url'] as String?;
state = state.copyWith(
status: UpdateStatus.available,
currentVersion: currentVersion,
latestVersion: latestVersion,
downloadUrl: apk['browser_download_url'] as String?,
downloadUrl: downloadUrl,
);
await _downloadInBackground();
return;
}
}
@@ -101,11 +109,52 @@ class UpdateNotifier extends Notifier<UpdateState> {
}
}
Future<void> downloadAndInstall() async {
Future<void> _downloadInBackground() async {
if (state.downloadUrl == null) return;
state = state.copyWith(status: UpdateStatus.downloading, downloadProgress: 0);
try {
final dir = await _apkDir();
await _cleanupApks(dir);
final path = '${dir.path}/fabled_${state.latestVersion}.apk';
await Dio().download(
state.downloadUrl!,
path,
onReceiveProgress: (received, total) {
if (total > 0) {
state = state.copyWith(downloadProgress: received / total);
}
},
);
final file = File(path);
if (!await file.exists() || await file.length() == 0) {
state = state.copyWith(
status: UpdateStatus.error,
errorMessage: 'Download failed — file is empty',
);
return;
}
state = state.copyWith(
status: UpdateStatus.readyToInstall,
apkPath: path,
downloadProgress: 1.0,
);
} catch (e) {
state = state.copyWith(
status: UpdateStatus.error,
errorMessage: e.toString(),
);
}
}
Future<void> install() async {
final path = state.apkPath;
if (path == null) return;
// Android 8+ requires explicit per-app "Install unknown apps" approval
// beyond the manifest declaration. Check and redirect to Settings if needed.
final installPermission = await Permission.requestInstallPackages.status;
if (!installPermission.isGranted) {
final result = await Permission.requestInstallPackages.request();
@@ -119,30 +168,13 @@ class UpdateNotifier extends Notifier<UpdateState> {
}
}
state = state.copyWith(status: UpdateStatus.downloading, downloadProgress: 0);
try {
final dir = await getExternalStorageDirectory() ??
await getTemporaryDirectory();
final path = '${dir.path}/fabled_update.apk';
await Dio().download(
state.downloadUrl!,
path,
onReceiveProgress: (received, total) {
if (total > 0) {
state = state.copyWith(downloadProgress: received / total);
}
},
);
final result = await OpenFile.open(
path,
type: 'application/vnd.android.package-archive',
);
if (result.type == ResultType.done) {
// Installer launched — reset to idle so the dialog closes naturally.
state = const UpdateState();
} else {
state = state.copyWith(
@@ -158,8 +190,30 @@ class UpdateNotifier extends Notifier<UpdateState> {
}
}
/// Remove any previously cached APKs.
Future<void> cleanup() async {
final dir = await _apkDir();
await _cleanupApks(dir);
}
void dismiss() => state = const UpdateState();
Future<Directory> _apkDir() async {
return await getExternalStorageDirectory() ??
await getTemporaryDirectory();
}
Future<void> _cleanupApks(Directory dir) async {
try {
final entries = dir.listSync();
for (final entry in entries) {
if (entry is File && entry.path.endsWith('.apk')) {
await entry.delete();
}
}
} catch (_) {}
}
bool _isNewer(String latest, String current) {
try {
final l = latest.split('.').map(int.parse).toList();
+207 -106
View File
@@ -1,13 +1,14 @@
import 'dart:async';
import 'dart:collection';
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:just_audio/just_audio.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:record/record.dart';
import 'package:vad/vad.dart';
import 'api_client_provider.dart';
@@ -51,6 +52,53 @@ String stripMarkdownForTts(String text) {
.trim();
}
/// Encode float PCM samples (-1..1) as a 16-bit mono WAV at 16 kHz.
Uint8List encodeWav(List<double> samples, {int sampleRate = 16000}) {
final numSamples = samples.length;
final dataSize = numSamples * 2;
final fileSize = 44 + dataSize;
final buf = ByteData(fileSize);
// RIFF header
buf.setUint8(0, 0x52); // R
buf.setUint8(1, 0x49); // I
buf.setUint8(2, 0x46); // F
buf.setUint8(3, 0x46); // F
buf.setUint32(4, fileSize - 8, Endian.little);
buf.setUint8(8, 0x57); // W
buf.setUint8(9, 0x41); // A
buf.setUint8(10, 0x56); // V
buf.setUint8(11, 0x45); // E
// fmt chunk
buf.setUint8(12, 0x66); // f
buf.setUint8(13, 0x6D); // m
buf.setUint8(14, 0x74); // t
buf.setUint8(15, 0x20); // (space)
buf.setUint32(16, 16, Endian.little); // chunk size
buf.setUint16(20, 1, Endian.little); // PCM format
buf.setUint16(22, 1, Endian.little); // mono
buf.setUint32(24, sampleRate, Endian.little);
buf.setUint32(28, sampleRate * 2, Endian.little); // byte rate
buf.setUint16(32, 2, Endian.little); // block align
buf.setUint16(34, 16, Endian.little); // bits per sample
// data chunk
buf.setUint8(36, 0x64); // d
buf.setUint8(37, 0x61); // a
buf.setUint8(38, 0x74); // t
buf.setUint8(39, 0x61); // a
buf.setUint32(40, dataSize, Endian.little);
for (var i = 0; i < numSamples; i++) {
final clamped = samples[i].clamp(-1.0, 1.0);
final int16 = (clamped * 32767).round().clamp(-32768, 32767);
buf.setInt16(44 + i * 2, int16, Endian.little);
}
return buf.buffer.asUint8List();
}
// ── State ─────────────────────────────────────────────────────────────────────
enum VoiceMode { idle, recording, transcribing, playing }
@@ -59,44 +107,52 @@ class VoiceState {
final VoiceMode mode;
final bool voiceModeActive;
final bool available;
/// Normalized mic amplitude 0.01.0 while recording.
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,
);
}
// ── Provider ──────────────────────────────────────────────────────────────────
final voiceProvider =
NotifierProvider<VoiceNotifier, VoiceState>(VoiceNotifier.new);
NotifierProvider.autoDispose<VoiceNotifier, VoiceState>(VoiceNotifier.new);
// ── Notifier ──────────────────────────────────────────────────────────────────
class VoiceNotifier extends Notifier<VoiceState> {
// Audio I/O
AudioRecorder? _recorder;
// Audio playback
AudioPlayer? _player;
StreamSubscription<Amplitude>? _amplitudeSubscription;
// Recording / silence detection
int _recordingStartMs = 0;
int _silenceMs = 0;
static const _silenceThresholdDb = -40.0;
static const _silenceDurationMs = 1500;
static const _minRecordingMs = 300;
// VAD — sole owner of the microphone
VadHandler? _vadHandler;
StreamSubscription<void>? _vadSpeechStartSub;
StreamSubscription<List<double>>? _vadSpeechEndSub;
StreamSubscription<({double isSpeech, double notSpeech, List<double> frame})>?
_vadFrameSub;
StreamSubscription<String>? _vadErrorSub;
bool _speechDetected = false;
int _speechStartMs = 0;
static const _vadGraceMs = 1500;
bool _disposed = false;
// Voice mode callbacks
Future<void> Function(String transcript)? _onTranscript;
@@ -108,10 +164,12 @@ class VoiceNotifier extends Notifier<VoiceState> {
int _lastSeenLength = 0;
bool _streamComplete = false;
// Last complete assistant response — passed to Whisper as initial_prompt
// to reduce STT mishearings of domain-specific words.
// Whisper context hint
String _lastAssistantContent = '';
// Empty transcript counter
int _emptyTranscriptCount = 0;
// TTS playback queue
final _ttsQueue = Queue<Uint8List>();
bool _ttsPlaying = false;
@@ -120,83 +178,73 @@ class VoiceNotifier extends Notifier<VoiceState> {
@override
VoiceState build() {
_recorder = AudioRecorder();
_disposed = false;
_player = AudioPlayer();
ref.onDispose(() {
_amplitudeSubscription?.cancel();
_recorder?.dispose();
_disposed = true;
_cancelSubscriptions();
_vadHandler?.dispose();
_vadHandler = null;
_player?.dispose();
_player = null;
});
return const VoiceState();
}
// ── Public API ──────────────────────────────────────────────────────────────
/// Enter voice mode. Checks server availability and mic permission first.
/// [onTranscript] is called with the transcript when a recording completes.
/// [enableTts] — if true, TTS plays when [feedContent] is called.
/// [onError] — called with a human-readable message on failure.
Future<void> enterVoiceMode({
required Future<void> Function(String transcript) onTranscript,
bool enableTts = false,
required void Function(String message) onError,
}) async {
if (state.voiceModeActive) {
if (state.mode == VoiceMode.recording && !_speechDetected) {
onError('No speech detected');
}
exitVoiceMode();
return;
}
// Check server availability
try {
final status = await ref.read(voiceRepositoryProvider).checkStatus();
if (!status.fullyAvailable) {
onError('Voice not available on this server');
if (!status.enabled || !status.stt) {
onError('Speech-to-text not available on this server');
return;
}
if (!status.tts) enableTts = false;
} catch (_) {
onError('Voice not available on this server');
onError('Could not reach voice service');
return;
}
// Check microphone permission
final permStatus = await Permission.microphone.request();
if (permStatus == PermissionStatus.denied ||
permStatus == PermissionStatus.permanentlyDenied) {
var permStatus = await Permission.microphone.request();
if (permStatus == PermissionStatus.permanentlyDenied) {
onError('Microphone blocked — opening settings');
final opened = await openAppSettings();
if (!opened) return;
permStatus = await Permission.microphone.status;
}
if (!permStatus.isGranted) {
onError('Microphone permission required');
if (permStatus == PermissionStatus.permanentlyDenied) {
await openAppSettings();
}
return;
}
_onTranscript = onTranscript;
_onError = onError;
_enableTts = enableTts;
_emptyTranscriptCount = 0;
_tempDir = await getTemporaryDirectory();
state = state.copyWith(voiceModeActive: true, available: true);
await _startListening();
}
/// Exit voice mode, stop all recording and TTS.
void exitVoiceMode() {
_amplitudeSubscription?.cancel();
_amplitudeSubscription = null;
_recorder?.stop();
_player?.stop();
_ttsQueue.clear();
_ttsPlaying = false;
_sentenceBuffer = '';
_lastSeenLength = 0;
_streamComplete = false;
_onTranscript = null;
_onError = null;
state = const VoiceState();
_cleanup();
if (!_disposed) state = const VoiceState();
}
/// Feed streaming assistant content for TTS synthesis.
/// Call from screens with the full [fullContent] string on each update.
/// Set [isComplete] to true when the stream has finished.
void feedContent(String fullContent, {required bool isComplete}) {
if (!state.voiceModeActive || !_enableTts) return;
@@ -215,105 +263,157 @@ class VoiceNotifier extends Notifier<VoiceState> {
}
}
// ── Internal helpers ────────────────────────────────────────────────────────
void _cleanup() {
_cancelSubscriptions();
final handler = _vadHandler;
_vadHandler = null;
handler?.dispose();
_player?.stop();
_ttsQueue.clear();
_ttsPlaying = false;
_sentenceBuffer = '';
_lastSeenLength = 0;
_streamComplete = false;
_speechDetected = false;
_onTranscript = null;
_onError = null;
}
void _cancelSubscriptions() {
_vadSpeechStartSub?.cancel();
_vadSpeechStartSub = null;
_vadSpeechEndSub?.cancel();
_vadSpeechEndSub = null;
_vadFrameSub?.cancel();
_vadFrameSub = null;
_vadErrorSub?.cancel();
_vadErrorSub = null;
}
// ── Internal recording ──────────────────────────────────────────────────────
Future<void> _startListening() async {
if (!state.voiceModeActive) return;
if (_disposed || !state.voiceModeActive) return;
_silenceMs = 0;
_recordingStartMs = DateTime.now().millisecondsSinceEpoch;
state = state.copyWith(mode: VoiceMode.recording);
final dir = _tempDir ?? await getTemporaryDirectory();
// AAC/M4A is reliably supported on Android (unlike WebM/Opus which can
// produce OGG bytes in a .webm file, confusing server-side decoders).
final path =
'${dir.path}/voice_rec_${DateTime.now().millisecondsSinceEpoch}.m4a';
_speechDetected = false;
_speechStartMs = 0;
try {
await _recorder!.start(
const RecordConfig(encoder: AudioEncoder.aacLc, sampleRate: 16000),
path: path,
);
await _stopVad();
_vadHandler = VadHandler.create();
_amplitudeSubscription?.cancel();
_amplitudeSubscription = _recorder!
.onAmplitudeChanged(const Duration(milliseconds: 200))
.listen(_onAmplitude);
} catch (e) {
_onError?.call('Microphone error: could not start recording');
exitVoiceMode();
}
}
_vadSpeechStartSub = _vadHandler!.onSpeechStart.listen((_) {
if (_disposed || !state.voiceModeActive) return;
if (!_speechDetected) {
_speechDetected = true;
_speechStartMs = DateTime.now().millisecondsSinceEpoch;
}
});
void _onAmplitude(Amplitude event) {
if (!state.voiceModeActive) return;
_vadSpeechEndSub = _vadHandler!.onSpeechEnd.listen((audioSamples) {
if (_disposed || !state.voiceModeActive) return;
final now = DateTime.now().millisecondsSinceEpoch;
final sinceStart = _speechStartMs > 0 ? now - _speechStartMs : 0;
if (_speechDetected && sinceStart >= _vadGraceMs) {
_stopVad();
_handleSpeechEnd(audioSamples);
}
});
final elapsed =
DateTime.now().millisecondsSinceEpoch - _recordingStartMs;
if (elapsed < _minRecordingMs) return;
_vadFrameSub = _vadHandler!.onFrameProcessed.listen((event) {
if (_disposed || !state.voiceModeActive) return;
final frame = event.frame;
if (frame.isEmpty) return;
double sumSq = 0;
for (final s in frame) {
sumSq += s * s;
}
final rms = sqrt(sumSq / frame.length);
final norm = (rms * 4.0).clamp(0.0, 1.0);
if ((norm - state.amplitude).abs() > 0.02) {
state = state.copyWith(amplitude: norm);
}
});
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;
_vadErrorSub = _vadHandler!.onError.listen((msg) {
if (_disposed) return;
_onError?.call('VAD error: $msg');
});
if (isSilent) {
_silenceMs += 200;
if (_silenceMs >= _silenceDurationMs) {
_amplitudeSubscription?.cancel();
_amplitudeSubscription = null;
_handleSilence();
await _vadHandler!.startListening(model: 'v5');
// Only show recording UI after the mic is actually open.
if (!_disposed && state.voiceModeActive) {
state = state.copyWith(mode: VoiceMode.recording, amplitude: 0.0);
}
} else {
_silenceMs = 0;
} catch (e) {
_onError?.call('Microphone error: $e');
_cleanup();
if (!_disposed) state = const VoiceState();
}
}
Future<void> _handleSilence() async {
if (!state.voiceModeActive) return;
Future<void> _stopVad() async {
_cancelSubscriptions();
if (_vadHandler != null) {
final handler = _vadHandler!;
_vadHandler = null;
await handler.dispose();
}
}
Future<void> _handleSpeechEnd(List<double> audioSamples) async {
if (_disposed || !state.voiceModeActive) return;
state = state.copyWith(mode: VoiceMode.transcribing);
final path = await _recorder!.stop();
if (path == null || !state.voiceModeActive) return;
try {
final bytes = await File(path).readAsBytes();
await File(path).delete().catchError((_) => File(path));
final wavBytes = encodeWav(audioSamples);
if (!state.voiceModeActive) return;
if (_disposed || !state.voiceModeActive) return;
final transcript = await ref.read(voiceRepositoryProvider).transcribe(
bytes,
context: _lastAssistantContent.isNotEmpty ? _lastAssistantContent : null,
wavBytes,
context:
_lastAssistantContent.isNotEmpty ? _lastAssistantContent : null,
);
if (!state.voiceModeActive) return;
if (_disposed || !state.voiceModeActive) return;
if (transcript.isEmpty) {
// Empty transcript — restart silently
_emptyTranscriptCount++;
if (_emptyTranscriptCount >= 3) {
_onError?.call('No speech detected — tap the mic to try again');
_cleanup();
if (!_disposed) state = const VoiceState();
return;
}
await _startListening();
return;
}
_emptyTranscriptCount = 0;
// Reset TTS state for this new turn
_sentenceBuffer = '';
_lastSeenLength = 0;
_streamComplete = false;
if (_enableTts) {
if (_enableTts && !_disposed) {
state = state.copyWith(mode: VoiceMode.playing);
}
await _onTranscript?.call(transcript);
// If TTS is not enabled, loop immediately
if (!_enableTts && state.voiceModeActive) {
await _startListening();
// In STT-only mode (no TTS), return to idle after transcript is sent.
// The user taps the mic again to record another message.
if (!_enableTts && !_disposed && state.voiceModeActive) {
state = state.copyWith(mode: VoiceMode.idle);
}
} catch (e) {
_onError?.call('Voice error: transcription failed');
exitVoiceMode();
_cleanup();
if (!_disposed) state = const VoiceState();
}
}
@@ -341,7 +441,7 @@ class VoiceNotifier extends Notifier<VoiceState> {
try {
final wavBytes =
await ref.read(voiceRepositoryProvider).synthesise(text);
if (!state.voiceModeActive) return;
if (_disposed || !state.voiceModeActive) return;
_ttsQueue.add(wavBytes);
if (!_ttsPlaying) _drainTtsQueue();
} catch (_) {
@@ -380,6 +480,7 @@ class VoiceNotifier extends Notifier<VoiceState> {
}
void _checkRestartListening() {
if (_disposed) return;
if (_streamComplete &&
_ttsQueue.isEmpty &&
!_ttsPlaying &&
+51 -11
View File
@@ -11,6 +11,7 @@ import '../../widgets/chat_message_bubble.dart';
import '../../widgets/weather_card.dart';
import '../../widgets/news_card.dart';
import 'briefing_history_screen.dart';
import '../../providers/settings_provider.dart';
import '../../providers/voice_provider.dart';
import '../../widgets/voice_mic_button.dart';
@@ -41,7 +42,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,13 +59,24 @@ 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();
WidgetsBinding.instance.removeObserver(this);
_controller.dispose();
_scrollController.dispose();
ref.read(voiceProvider.notifier).exitVoiceMode();
super.dispose();
}
@@ -247,12 +266,18 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
),
),
data: (conv) {
return Column(
final isWide = MediaQuery.of(context).size.width >= 600;
Widget body = 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(
@@ -289,11 +314,13 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
reactions: _reactions,
onReaction: _handleReaction,
onDiscuss: _handleDiscuss,
rssEnabled: ref.watch(rssEnabledProvider),
);
},
),
),
],
],
),
),
),
@@ -351,6 +378,7 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
VoiceMicButton(
mode: voiceState.mode,
voiceModeActive: voiceState.voiceModeActive,
amplitude: voiceState.amplitude,
onTap: _toggleVoiceMode,
),
const SizedBox(width: 6),
@@ -366,6 +394,15 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
),
],
);
if (isWide) {
body = Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 700),
child: body,
),
);
}
return body;
},
),
);
@@ -393,6 +430,7 @@ class _BriefingMessageItem extends StatelessWidget {
final Map<int, String?> reactions;
final void Function(int itemId, String reaction) onReaction;
final void Function(int convId, int itemId) onDiscuss;
final bool rssEnabled;
const _BriefingMessageItem({
required this.message,
@@ -400,6 +438,7 @@ class _BriefingMessageItem extends StatelessWidget {
required this.reactions,
required this.onReaction,
required this.onDiscuss,
this.rssEnabled = false,
});
@override
@@ -411,11 +450,12 @@ class _BriefingMessageItem extends StatelessWidget {
final bool hasWeatherKey = isAssistant && meta != null && meta.containsKey('weather');
final weatherData = hasWeatherKey ? meta['weather'] as Map<String, dynamic>? : null;
// RSS news cards — cap at 3
final rssItemsRaw = isAssistant && meta != null
? (meta['rss_items'] as List<dynamic>?)?.cast<Map<String, dynamic>>() ?? []
: <Map<String, dynamic>>[];
final rssItems = rssItemsRaw.map(RssItemMeta.fromJson).take(3).toList();
// RSS news cards — cap at 3 (only when RSS is enabled)
final rssItems = <RssItemMeta>[];
if (rssEnabled && isAssistant && meta != null) {
final raw = (meta['rss_items'] as List<dynamic>?)?.cast<Map<String, dynamic>>() ?? [];
rssItems.addAll(raw.map(RssItemMeta.fromJson).take(3));
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
+69 -18
View File
@@ -20,11 +20,40 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
with WidgetsBindingObserver {
final _controller = TextEditingController();
final _scrollController = ScrollController();
bool _refreshing = false;
Future<void> _refreshMessages() async {
if (_refreshing) return;
setState(() => _refreshing = true);
try {
await ref
.read(messagesProvider(widget.conversationId).notifier)
.refresh();
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not refresh messages.')),
);
}
} finally {
if (mounted) setState(() => _refreshing = false);
}
}
@override
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
@@ -39,8 +68,6 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
WidgetsBinding.instance.removeObserver(this);
_controller.dispose();
_scrollController.dispose();
// Exit voice mode if the user navigates away mid-session.
ref.read(voiceProvider.notifier).exitVoiceMode();
super.dispose();
}
@@ -136,6 +163,19 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
return Scaffold(
appBar: AppBar(
title: Text(convTitle?.isNotEmpty == true ? convTitle! : 'Chat'),
actions: [
IconButton(
tooltip: 'Refresh',
onPressed: _refreshing ? null : _refreshMessages,
icon: _refreshing
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
),
],
),
body: Column(
children: [
@@ -147,22 +187,32 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
child: Text('Could not load messages.'),
),
data: (messages) {
if (messages.isEmpty) {
return const Center(
child: Text('Send a message to start.'));
}
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 12),
itemCount: messages.length,
itemBuilder: (context, i) => ChatMessageBubble(
message: messages[i],
streamingStatus: (i == messages.length - 1 &&
messages[i].status == 'generating')
? streamingStatus
: '',
),
return RefreshIndicator(
onRefresh: _refreshMessages,
child: messages.isEmpty
? ListView(
// Needs to be scrollable for RefreshIndicator to
// fire on empty state — plain Center won't work.
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(height: 240),
Center(child: Text('Send a message to start.')),
],
)
: ListView.builder(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 12),
itemCount: messages.length,
itemBuilder: (context, i) => ChatMessageBubble(
message: messages[i],
streamingStatus: (i == messages.length - 1 &&
messages[i].status == 'generating')
? streamingStatus
: '',
),
),
);
},
),
@@ -215,6 +265,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
VoiceMicButton(
mode: voiceState.mode,
voiceModeActive: voiceState.voiceModeActive,
amplitude: voiceState.amplitude,
onTap: _toggleVoiceMode,
),
const SizedBox(width: 6),
+97 -45
View File
@@ -4,30 +4,79 @@ import 'package:go_router/go_router.dart';
import '../../core/constants.dart';
import '../../providers/chat_provider.dart';
import 'chat_screen.dart';
class ConversationsTabScreen extends ConsumerWidget {
class ConversationsTabScreen extends ConsumerStatefulWidget {
const ConversationsTabScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<ConversationsTabScreen> createState() =>
_ConversationsTabScreenState();
}
class _ConversationsTabScreenState
extends ConsumerState<ConversationsTabScreen> {
int? _selectedConvId;
Future<void> _createConversation() async {
final conv =
await ref.read(conversationsProvider.notifier).create('');
if (!mounted) return;
final isWide = MediaQuery.of(context).size.width >= 600;
if (isWide) {
setState(() => _selectedConvId = conv.id);
} else {
context.push(Routes.chat.replaceFirst(':id', '${conv.id}'));
}
}
void _openConversation(int id) {
final isWide = MediaQuery.of(context).size.width >= 600;
if (isWide) {
setState(() => _selectedConvId = id);
} else {
context.push(Routes.chat.replaceFirst(':id', '$id'));
}
}
Future<void> _confirmDelete(int id, String title) async {
final ok = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Delete conversation?'),
content: Text('"$title" will be permanently deleted.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel')),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Delete')),
],
),
);
if (ok == true) {
await ref.read(conversationsProvider.notifier).delete(id);
if (_selectedConvId == id) {
setState(() => _selectedConvId = null);
}
}
}
@override
Widget build(BuildContext context) {
final isWide = MediaQuery.of(context).size.width >= 600;
final theme = Theme.of(context);
final convsAsync = ref.watch(conversationsProvider);
return Scaffold(
final listPanel = Scaffold(
appBar: AppBar(
title: Text('Chat', style: theme.textTheme.titleLarge),
actions: [
IconButton(
icon: const Icon(Icons.add),
tooltip: 'New conversation',
onPressed: () async {
final conv = await ref
.read(conversationsProvider.notifier)
.create('');
if (context.mounted) {
context.push(Routes.chat.replaceFirst(':id', '${conv.id}'));
}
},
onPressed: _createConversation,
),
],
),
@@ -49,26 +98,20 @@ class ConversationsTabScreen extends ConsumerWidget {
FilledButton.icon(
icon: const Icon(Icons.add),
label: const Text('Start a conversation'),
onPressed: () async {
final conv = await ref
.read(conversationsProvider.notifier)
.create('');
if (context.mounted) {
context.push(
Routes.chat.replaceFirst(':id', '${conv.id}'));
}
},
onPressed: _createConversation,
),
],
),
);
}
return RefreshIndicator(
onRefresh: () => ref.read(conversationsProvider.notifier).refresh(),
onRefresh: () =>
ref.read(conversationsProvider.notifier).refresh(),
child: ListView.builder(
itemCount: convs.length,
itemBuilder: (ctx, i) {
final c = convs[i];
final selected = isWide && c.id == _selectedConvId;
return ListTile(
leading: const Icon(Icons.chat_bubble_outline),
title: Text(
@@ -79,13 +122,12 @@ class ConversationsTabScreen extends ConsumerWidget {
_relativeTime(c.updatedAt),
style: theme.textTheme.labelSmall,
),
selected: selected,
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () =>
_confirmDelete(context, ref, c.id, c.title),
onPressed: () => _confirmDelete(c.id, c.title),
),
onTap: () =>
ctx.push(Routes.chat.replaceFirst(':id', '${c.id}')),
onTap: () => _openConversation(c.id),
);
},
),
@@ -93,28 +135,38 @@ class ConversationsTabScreen extends ConsumerWidget {
},
),
);
}
Future<void> _confirmDelete(
BuildContext context, WidgetRef ref, int id, String title) async {
final ok = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Delete conversation?'),
content: Text('"$title" will be permanently deleted.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel')),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Delete')),
],
),
if (!isWide) return listPanel;
return Row(
children: [
SizedBox(
width: 320,
child: listPanel,
),
const VerticalDivider(width: 1),
Expanded(
child: _selectedConvId != null
? ChatScreen(
key: ValueKey(_selectedConvId),
conversationId: _selectedConvId!,
)
: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.chat_bubble_outline,
size: 48,
color: theme.colorScheme.onSurfaceVariant),
const SizedBox(height: 16),
Text('Select a conversation',
style: theme.textTheme.titleMedium),
],
),
),
),
],
);
if (ok == true) {
await ref.read(conversationsProvider.notifier).delete(id);
}
}
}
+49 -10
View File
@@ -186,18 +186,57 @@ class _KnowledgeScreenState extends ConsumerState<KnowledgeScreen>
}
return RefreshIndicator(
onRefresh: () => ref.read(knowledgeProvider.notifier).refresh(),
child: ListView.separated(
controller: _scrollController,
itemCount: items.length + (state.isLoadingBatch ? 1 : 0),
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (_, i) {
if (i >= items.length) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
child: LayoutBuilder(
builder: (context, constraints) {
final cols = constraints.maxWidth >= 900
? 3
: constraints.maxWidth >= 600
? 2
: 1;
if (cols == 1) {
return ListView.separated(
controller: _scrollController,
itemCount: items.length + (state.isLoadingBatch ? 1 : 0),
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (_, i) {
if (i >= items.length) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
return KnowledgeItemCard(item: items[i]);
},
);
}
return KnowledgeItemCard(item: items[i]);
return CustomScrollView(
controller: _scrollController,
slivers: [
SliverPadding(
padding: const EdgeInsets.all(8),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: cols,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 1.8,
),
delegate: SliverChildBuilderDelegate(
(_, i) {
if (i >= items.length) {
return const Center(
child: CircularProgressIndicator());
}
return KnowledgeItemCard(item: items[i])
.buildGridCard(context);
},
childCount:
items.length + (state.isLoadingBatch ? 1 : 0),
),
),
),
],
);
},
),
);
+79 -30
View File
@@ -50,6 +50,34 @@ class _NewsScreenState extends ConsumerState<NewsScreen> {
}
}
Widget _buildNewsItem(NewsState news, int i, {int cols = 1}) {
if (i >= news.items.length) {
if (!news.hasMore) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Center(
child: news.loadingMore
? const CircularProgressIndicator()
: FilledButton.tonal(
onPressed: _loadMore,
child: const Text('Load more'),
),
),
);
}
final item = news.items[i];
return NewsCard(
item: RssItemMeta.fromNewsItem(item),
reaction: news.reactions[item.id],
snippetMaxLines: cols > 1 ? 5 : 2,
onReaction: (itemId, reaction) =>
ref.read(newsProvider.notifier).toggleReaction(itemId, reaction),
onDiscuss: _openingChat.contains(item.id)
? null
: () => _handleDiscuss(item.id),
);
}
@override
Widget build(BuildContext context) {
final newsAsync = ref.watch(newsProvider);
@@ -98,38 +126,59 @@ class _NewsScreenState extends ConsumerState<NewsScreen> {
Expanded(
child: RefreshIndicator(
onRefresh: () => ref.read(newsProvider.notifier).refresh(),
child: ListView.builder(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
itemCount: news.items.length + 1,
itemBuilder: (_, i) {
if (i == news.items.length) {
if (!news.hasMore) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Center(
child: news.loadingMore
? const CircularProgressIndicator()
: FilledButton.tonal(
onPressed: _loadMore,
child: const Text('Load more'),
child: LayoutBuilder(
builder: (context, constraints) {
final cols = constraints.maxWidth >= 900
? 3
: constraints.maxWidth >= 600
? 2
: 1;
if (cols == 1) {
return ListView.builder(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 8),
itemCount: news.items.length + 1,
itemBuilder: (_, i) =>
_buildNewsItem(news, i),
);
}
return CustomScrollView(
slivers: [
SliverPadding(
padding: const EdgeInsets.all(8),
sliver: SliverGrid(
gridDelegate:
SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: cols,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 1.6,
),
delegate: SliverChildBuilderDelegate(
(_, i) => _buildNewsItem(news, i, cols: cols),
childCount: news.items.length,
),
),
),
if (news.hasMore)
SliverToBoxAdapter(
child: Padding(
padding:
const EdgeInsets.symmetric(vertical: 12),
child: Center(
child: news.loadingMore
? const CircularProgressIndicator()
: FilledButton.tonal(
onPressed: _loadMore,
child: const Text('Load more'),
),
),
),
),
),
],
);
}
final item = news.items[i];
return NewsCard(
item: RssItemMeta.fromNewsItem(item),
reaction: news.reactions[item.id],
onReaction: (itemId, reaction) => ref
.read(newsProvider.notifier)
.toggleReaction(itemId, reaction),
onDiscuss: _openingChat.contains(item.id)
? null
: () => _handleDiscuss(item.id),
);
},
),
},
),
),
),
],
+4 -4
View File
@@ -92,7 +92,7 @@ class SettingsScreen extends ConsumerWidget {
child: const Text('Check'),
),
),
if (update.status == UpdateStatus.available ||
if (update.status == UpdateStatus.readyToInstall ||
update.status == UpdateStatus.downloading)
_UpdateTile(update: update),
if (update.status == UpdateStatus.error)
@@ -131,7 +131,7 @@ class SettingsScreen extends ConsumerWidget {
if (update.status == UpdateStatus.upToDate) {
return Text('v$current — up to date');
}
if (update.status == UpdateStatus.available ||
if (update.status == UpdateStatus.readyToInstall ||
update.status == UpdateStatus.downloading) {
return Text('v$current installed');
}
@@ -202,12 +202,12 @@ class _UpdateTile extends ConsumerWidget {
'${(update.downloadProgress * 100).toStringAsFixed(0)}%'),
],
)
: const Text('Tap to download and install'),
: const Text('Ready to install'),
trailing: isDownloading
? null
: FilledButton(
onPressed: () =>
ref.read(updateProvider.notifier).downloadAndInstall(),
ref.read(updateProvider.notifier).install(),
child: const Text('Install'),
),
);
+176 -34
View File
@@ -1,11 +1,17 @@
import 'dart:math' show min;
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/models/message.dart';
import '../providers/api_client_provider.dart';
import '../providers/settings_provider.dart';
import 'tool_call_chip.dart';
class ChatMessageBubble extends StatelessWidget {
class ChatMessageBubble extends ConsumerWidget {
final Message message;
final String streamingStatus;
const ChatMessageBubble({
@@ -15,10 +21,18 @@ class ChatMessageBubble extends StatelessWidget {
});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final isUser = message.role == MessageRole.user;
final scheme = Theme.of(context).colorScheme;
final serverUrl = ref.watch(serverUrlProvider) ?? '';
final dio = ref.watch(dioProvider);
final isGenerating = message.status == 'generating';
final toolCalls = message.toolCalls ?? const [];
// An assistant bubble with no text, no tool calls, and still generating
// falls back to the spinner+status "waiting for the first token" view.
final showSpinnerOnly =
isGenerating && message.content.isEmpty && toolCalls.isEmpty;
return Align(
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
@@ -29,7 +43,6 @@ class ChatMessageBubble extends StatelessWidget {
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
decoration: isUser
? BoxDecoration(
// Ghost style: transparent bg, thin border
color: Colors.transparent,
border: Border.all(
color: scheme.primary.withValues(alpha: 0.35),
@@ -43,7 +56,6 @@ class ChatMessageBubble extends StatelessWidget {
),
)
: BoxDecoration(
// Assistant: elevated surface + left accent border
color: scheme.surfaceContainerHighest,
border: Border(
left: BorderSide(color: scheme.primary, width: 2),
@@ -57,46 +69,176 @@ class ChatMessageBubble extends StatelessWidget {
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: isGenerating && message.content.isEmpty
? Row(
child: showSpinnerOnly
? _buildSpinner(scheme)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: scheme.onSurfaceVariant,
// Accumulated tool-call chips: visible both during
// streaming (fed live over SSE) and after reload (from
// the persisted message.tool_calls array).
if (toolCalls.isNotEmpty) ...[
Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (final tc in toolCalls) ToolCallChip(toolCall: tc),
],
),
),
if (streamingStatus.isNotEmpty) ...[
const SizedBox(width: 8),
Flexible(
child: Text(
streamingStatus,
style: TextStyle(
fontSize: 12,
color: scheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
const SizedBox(height: 6),
],
// Rolling status line — shows backend stage text
// ("Creating note", "Searching calendar") while the
// model is between tool rounds or just before the
// first token. Stays above any already-streamed text
// so the user can see what's happening mid-turn.
if (isGenerating && streamingStatus.isNotEmpty) ...[
Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 1.5,
color: scheme.onSurfaceVariant,
),
),
const SizedBox(width: 6),
Flexible(
child: Text(
streamingStatus,
style: TextStyle(
fontSize: 11,
color: scheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
),
],
),
const SizedBox(height: 6),
],
if (message.content.isNotEmpty)
MarkdownBody(
data: message.content,
imageBuilder: (uri, title, alt) {
return _AuthImage(
uri: uri,
alt: alt,
serverUrl: serverUrl,
dio: dio,
);
},
styleSheet: MarkdownStyleSheet(
p: TextStyle(
color: isUser
? scheme.onSurface.withValues(alpha: 0.75)
: scheme.onSurface,
fontSize: 14,
),
),
),
],
],
)
: MarkdownBody(
data: message.content.isEmpty ? '' : message.content,
styleSheet: MarkdownStyleSheet(
p: TextStyle(
color: isUser
? scheme.onSurface.withValues(alpha: 0.75)
: scheme.onSurface,
fontSize: 14,
),
),
),
),
),
);
}
Widget _buildSpinner(ColorScheme scheme) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: scheme.onSurfaceVariant,
),
),
if (streamingStatus.isNotEmpty) ...[
const SizedBox(width: 8),
Flexible(
child: Text(
streamingStatus,
style: TextStyle(
fontSize: 12,
color: scheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
),
],
],
);
}
}
class _AuthImage extends StatefulWidget {
final Uri uri;
final String? alt;
final String serverUrl;
final Dio dio;
const _AuthImage({
required this.uri,
this.alt,
required this.serverUrl,
required this.dio,
});
@override
State<_AuthImage> createState() => _AuthImageState();
}
class _AuthImageState extends State<_AuthImage> {
late Future<Uint8List> _future;
@override
void initState() {
super.initState();
_future = _fetchImage();
}
Future<Uint8List> _fetchImage() async {
var url = widget.uri.toString();
if (url.startsWith('/')) {
url = '${widget.serverUrl}$url';
}
final response = await widget.dio.get<List<int>>(
url,
options: Options(responseType: ResponseType.bytes),
);
return Uint8List.fromList(response.data!);
}
@override
Widget build(BuildContext context) {
return FutureBuilder<Uint8List>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const SizedBox(
height: 100,
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
);
}
if (snapshot.hasError || !snapshot.hasData) {
return Text(widget.alt ?? 'Image failed to load');
}
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.memory(
snapshot.data!,
fit: BoxFit.contain,
errorBuilder: (_, _, _) =>
Text(widget.alt ?? 'Image failed to load'),
),
);
},
);
}
}
+71 -8
View File
@@ -27,12 +27,24 @@ class KnowledgeItemCard extends StatelessWidget {
String? get _subtitle {
if (item.noteType == 'task') {
if (item.body.trim().isNotEmpty) {
final preview = item.body.trim().replaceAll('\n', ' ');
return preview.length > 200 ? '${preview.substring(0, 200)}' : preview;
}
if (item.dueDate != null) return 'Due ${item.dueDate}';
return item.status;
}
if (item.body.trim().isEmpty) return null;
final preview = item.body.trim().replaceAll('\n', ' ');
return preview.length > 120 ? '${preview.substring(0, 120)}' : preview;
return preview.length > 200 ? '${preview.substring(0, 200)}' : preview;
}
void _onTap(BuildContext context) {
if (item.noteType == 'task') {
context.push('/tasks/${item.id}/edit');
} else {
context.push('/notes/${item.id}');
}
}
@override
@@ -53,13 +65,64 @@ class KnowledgeItemCard extends StatelessWidget {
)
: null,
trailing: item.tags.isNotEmpty ? _TagChips(tags: item.tags) : null,
onTap: () {
if (item.noteType == 'task') {
context.push('/tasks/${item.id}/edit');
} else {
context.push('/notes/${item.id}');
}
},
onTap: () => _onTap(context),
);
}
Widget buildGridCard(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: scheme.outlineVariant),
),
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: () => _onTap(context),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(_icon, size: 18, color: _statusColor(context)),
const SizedBox(width: 8),
Expanded(
child: Text(
item.title.isEmpty ? '(untitled)' : item.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.w600),
),
),
],
),
if (_subtitle != null) ...[
const SizedBox(height: 8),
Expanded(
child: Text(
_subtitle!,
overflow: TextOverflow.ellipsis,
maxLines: 4,
style: textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
height: 1.4,
),
),
),
],
if (item.tags.isNotEmpty) ...[
const Spacer(),
_TagChips(tags: item.tags),
],
],
),
),
),
);
}
}
+11 -5
View File
@@ -54,6 +54,7 @@ class NewsCard extends StatelessWidget {
final String? reaction; // 'up' | 'down' | null
final void Function(int itemId, String reaction) onReaction;
final VoidCallback? onDiscuss;
final int snippetMaxLines;
const NewsCard({
super.key,
@@ -61,6 +62,7 @@ class NewsCard extends StatelessWidget {
required this.reaction,
required this.onReaction,
this.onDiscuss,
this.snippetMaxLines = 2,
});
Future<void> _openUrl() async {
@@ -113,16 +115,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,
),
),
),
@@ -131,7 +137,7 @@ class NewsCard extends StatelessWidget {
const SizedBox(height: 4),
Text(
item.snippet,
maxLines: 2,
maxLines: snippetMaxLines,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
+181
View File
@@ -0,0 +1,181 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../core/constants.dart';
/// Human-readable labels for each backend tool. Kept in sync with
/// `_TOOL_LABELS` in `fabledassistant/services/generation_task.py` so the
/// mobile chips use the same wording as the web ToolCallCard status pills.
const Map<String, String> _toolLabels = {
'create_note': 'Created note',
'update_note': 'Updated note',
'delete_note': 'Deleted note',
'create_task': 'Created task',
'update_task': 'Updated task',
'delete_task': 'Deleted task',
'read_note': 'Read note',
'list_notes': 'Listed notes',
'list_tasks': 'Searched tasks',
'search_notes': 'Searched notes',
'create_event': 'Created event',
'list_events': 'Searched calendar',
'search_events': 'Searched calendar',
'update_event': 'Updated event',
'delete_event': 'Removed event',
'list_calendars': 'Listed calendars',
'search_web': 'Searched the web',
'research_topic': 'Researched topic',
'search_images': 'Searched images',
'create_project': 'Created project',
'update_project': 'Updated project',
'list_projects': 'Listed projects',
'get_project': 'Read project',
'search_projects': 'Searched projects',
'create_milestone': 'Created milestone',
'update_milestone': 'Updated milestone',
'list_milestones': 'Listed milestones',
'set_rag_scope': 'Changed knowledge scope',
'calculate': 'Calculated',
'read_article': 'Read article',
'get_profile': 'Read profile',
'update_profile': 'Updated profile',
'update_person': 'Updated person',
'update_place': 'Updated place',
'add_task_log': 'Logged task progress',
};
IconData _iconFor(String fn) {
if (fn.contains('note')) return Icons.sticky_note_2_outlined;
if (fn.contains('task')) return Icons.check_circle_outline;
if (fn.contains('event') || fn.contains('calendar')) {
return Icons.event_outlined;
}
if (fn.contains('project')) return Icons.folder_outlined;
if (fn.contains('milestone')) return Icons.flag_outlined;
if (fn.contains('web') || fn.contains('research') || fn.contains('article')) {
return Icons.public;
}
if (fn.contains('image')) return Icons.image_outlined;
if (fn.contains('person') || fn.contains('profile')) {
return Icons.person_outline;
}
if (fn.contains('place')) return Icons.place_outlined;
if (fn.contains('rag') || fn.contains('scope')) return Icons.tune;
if (fn.contains('calculate')) return Icons.calculate_outlined;
return Icons.auto_awesome;
}
/// Pull the destination route for this tool call from its `result` payload,
/// if the tool produced something we can navigate to. Returns `null` for
/// read-only / no-target tools so the chip stays visible but non-tappable.
///
/// Backend tool results use the shape:
/// `{success, type: "note"|"task"|"event"|"project"|..., data: {id, ...}}`
/// which is defined alongside each tool handler (see
/// `services/tools/notes.py`, `calendar.py`, etc.).
String? _routeForToolCall(Map<String, dynamic> tc) {
final result = tc['result'];
if (result is! Map<String, dynamic>) return null;
if (result['success'] != true) return null;
final type = result['type'] as String?;
final data = result['data'];
if (type == null || data is! Map<String, dynamic>) return null;
final id = data['id'];
if (id is! int) return null;
switch (type) {
case 'note':
return Routes.noteDetail.replaceFirst(':id', '$id');
case 'task':
return Routes.taskEdit.replaceFirst(':id', '$id');
case 'event':
case 'event_updated':
// No single-event route on mobile — fall back to the calendar.
return Routes.calendar;
case 'project':
return Routes.projectTasks.replaceFirst(':id', '$id');
default:
return null;
}
}
/// Small status pill rendered inside an assistant message bubble for each
/// tool invocation. Mirrors the web app's ToolCallCard header at a glance —
/// icon + label + success/error tint — and, when the tool produced a
/// navigable entity (note, task, event, project), tapping the chip opens it.
class ToolCallChip extends StatelessWidget {
final Map<String, dynamic> toolCall;
const ToolCallChip({super.key, required this.toolCall});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final function = (toolCall['function'] as String?) ?? 'tool';
final status = (toolCall['status'] as String?) ?? 'success';
final isError = status == 'error';
final label = _toolLabels[function] ?? function;
final route = isError ? null : _routeForToolCall(toolCall);
final bg = isError
? scheme.errorContainer.withValues(alpha: 0.55)
: scheme.primary.withValues(alpha: 0.12);
final fg = isError ? scheme.onErrorContainer : scheme.primary;
// Pull the entity title from the result payload so the chip can show
// "Created note: Grocery List" instead of a generic label. Falls back
// to the generic label when the tool didn't return a titled entity.
String? entityTitle;
final result = toolCall['result'];
if (result is Map<String, dynamic>) {
final data = result['data'];
if (data is Map<String, dynamic>) {
final t = data['title'];
if (t is String && t.isNotEmpty) entityTitle = t;
}
}
final displayText =
entityTitle != null ? '$label: $entityTitle' : label;
final chip = Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: fg.withValues(alpha: 0.35), width: 0.5),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(_iconFor(function), size: 13, color: fg),
const SizedBox(width: 5),
Flexible(
child: Text(
displayText,
style: TextStyle(
fontSize: 11,
color: fg,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
if (route != null) ...[
const SizedBox(width: 4),
Icon(Icons.arrow_forward, size: 11, color: fg),
],
],
),
);
if (route == null) return chip;
return Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(10),
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: () => context.push(route),
child: chip,
),
);
}
}
+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,
);
}
}
+1
View File
@@ -10,6 +10,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
vad
)
set(PLUGIN_BUNDLED_LIBRARIES)
@@ -11,7 +11,7 @@ import flutter_timezone
import just_audio
import open_file_mac
import package_info_plus
import record_darwin
import record_macos
import shared_preferences_foundation
import url_launcher_macos
@@ -22,7 +22,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
RecordPlugin.register(with: registry.registrar(forPlugin: "RecordPlugin"))
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
}
+23 -7
View File
@@ -804,10 +804,10 @@ packages:
dependency: "direct main"
description:
name: record
sha256: "2e3d56d196abcd69f1046339b75e5f3855b2406fc087e5991f6703f188aa03a6"
sha256: d5b6b334f3ab02460db6544e08583c942dbf23e3504bf1e14fd4cbe3d9409277
url: "https://pub.dev"
source: hosted
version: "5.2.1"
version: "6.2.0"
record_android:
dependency: transitive
description:
@@ -816,22 +816,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.5.1"
record_darwin:
record_ios:
dependency: transitive
description:
name: record_darwin
sha256: e487eccb19d82a9a39cd0126945cfc47b9986e0df211734e2788c95e3f63c82c
name: record_ios
sha256: "8df7c136131bd05efc19256af29b2ba6ccc000ccc2c80d4b6b6d7a8d21a3b5a9"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
version: "1.2.0"
record_linux:
dependency: "direct overridden"
dependency: transitive
description:
name: record_linux
sha256: c31a35cc158cd666fc6395f7f56fc054f31685571684be6b97670a27649ce5c7
url: "https://pub.dev"
source: hosted
version: "1.3.0"
record_macos:
dependency: transitive
description:
name: record_macos
sha256: "084902e63fc9c0c224c29203d6c75f0bdf9b6a40536c9d916393c8f4c4256488"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
record_platform_interface:
dependency: transitive
description:
@@ -1165,6 +1173,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.5.3"
vad:
dependency: "direct main"
description:
name: vad
sha256: ef6c8b12c5af7a6a519ff5684f074b8a2ac00c434705f544af379ea77bccd258
url: "https://pub.dev"
source: hosted
version: "0.0.7+1"
vector_math:
dependency: transitive
description:
+2 -4
View File
@@ -28,13 +28,11 @@ dependencies:
google_fonts: ^8.0.2
flutter_timezone: ^5.0.2
url_launcher: ^6.3.1
record: ^5.0.0
record: ^6.2.0
vad: ^0.0.7
just_audio: ^0.9.39
table_calendar: ^3.1.2
dependency_overrides:
record_linux: ^1.3.0
dev_dependencies:
flutter_test:
sdk: flutter
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
#
# Publish a signed release APK to Forgejo.
#
# Finds the release matching $TAG (creating it if the CI job got here
# before the UI did), then attaches the APK as a release asset.
#
# Required env:
# RELEASE_TOKEN — Forgejo PAT with write:repository scope
# TAG — release tag (e.g. v26.04.11)
# APK — path to the built APK
#
# Optional env:
# FORGEJO_API — defaults to the FabledApp repo API root
#
# Exits non-zero if the release can't be created or the asset upload
# fails. Designed to be testable locally:
# RELEASE_TOKEN=... TAG=v0.0.1 APK=/tmp/test.apk bash -x scripts/publish_apk_release.sh
set -euo pipefail
: "${RELEASE_TOKEN:?RELEASE_TOKEN not set}"
: "${TAG:?TAG not set}"
: "${APK:?APK not set}"
if [ ! -f "$APK" ]; then
echo "APK not found at $APK" >&2
exit 1
fi
API="${FORGEJO_API:-https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledApp}"
# jq isn't in the cirruslabs Flutter image, so we parse JSON with grep.
# Fragile but bounded: we only care about the first "id": <n> field,
# which is always the release id in Forgejo's responses for these
# endpoints. If Forgejo ever adds a preceding id field, revisit.
extract_id() {
echo "$1" | grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+'
}
# Look for an existing release (created via the UI or a prior run).
# curl -f turns 4xx/5xx into non-zero so we can distinguish "not found"
# (no release yet) from "auth broken" (real failure).
existing=$(curl -fsS -H "Authorization: token $RELEASE_TOKEN" \
"$API/releases/tags/$TAG" 2>/dev/null || true)
release_id=$(extract_id "$existing")
if [ -z "$release_id" ]; then
echo "No existing release for $TAG — creating one..."
response=$(curl -fsS -X POST \
-H "Authorization: token $RELEASE_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"$TAG\", \"name\": \"$TAG\", \"body\": \"\"}" \
"$API/releases")
release_id=$(extract_id "$response")
if [ -z "$release_id" ]; then
echo "Failed to create release. API response:" >&2
echo "$response" >&2
exit 1
fi
echo "Created release $TAG (id=$release_id)."
else
echo "Found existing release $TAG (id=$release_id). Attaching APK..."
fi
curl -fsS -X POST \
-H "Authorization: token $RELEASE_TOKEN" \
-F "attachment=@$APK" \
"$API/releases/$release_id/assets" > /dev/null
echo "Done — $TAG is live at:"
echo "https://git.fabledsword.com/bvandeusen/FabledApp/releases/tag/$TAG"
+2 -2
View File
@@ -209,8 +209,8 @@ void main() {
final event = CalendarEvent.fromJson(json);
expect(event.id, equals(10));
expect(event.title, equals('Team meeting'));
expect(event.startDt, equals(DateTime.parse('2026-04-07T09:00:00+00:00')));
expect(event.endDt, equals(DateTime.parse('2026-04-07T10:00:00+00:00')));
expect(event.startDt, equals(DateTime.parse('2026-04-07T09:00:00+00:00').toLocal()));
expect(event.endDt, equals(DateTime.parse('2026-04-07T10:00:00+00:00').toLocal()));
expect(event.allDay, isFalse);
expect(event.description, equals('Weekly sync'));
expect(event.location, equals('Room 4'));
+1
View File
@@ -11,6 +11,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
vad
)
set(PLUGIN_BUNDLED_LIBRARIES)