Compare commits

...

60 Commits

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 07:55:47 -04:00
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
bvandeusen ab3a482705 Merge pull request 'Violet theme, flicker-free refresh, STT context' (#17) from dev into main 2026-04-08 18:52:27 +00:00
bvandeusen 47c190891e fix(knowledge): hold stale items during refresh to eliminate flicker 2026-04-08 14:45:57 -04:00
bvandeusen 3e888b6458 fix: eliminate pull-to-refresh flicker by holding stale data during re-fetch 2026-04-08 14:39:50 -04:00
bvandeusen 6c29b685e8 feat(theme): shift Android palette from indigo to deep violet to match web identity 2026-04-08 13:54:39 -04:00
bvandeusen 5957551546 Merge pull request 'STT context, refresh improvements, calendar timezone fix' (#16) from dev into main 2026-04-07 21:52:20 +00:00
bvandeusen d2582f9111 feat: reduce resume cooldown to 30s; add pull-to-refresh to News, Calendar; refresh chat on resume 2026-04-07 17:43:46 -04:00
bvandeusen 36350d35b1 feat(stt): pass last assistant response as Whisper context to reduce mishearings 2026-04-07 09:57:07 -04:00
bvandeusen 96e6b6466f Merge pull request 'fix(calendar): convert event times to local timezone on parse' (#15) from dev into main 2026-04-07 03:07:22 +00:00
bvandeusen d75d34ce8e fix(calendar): convert event times to local timezone on parse 2026-04-06 23:05:33 -04:00
bvandeusen 1c97f9dea5 Android app: Calendar, News, Knowledge, Voice, Nav restructure, bug fixes 2026-04-07 02:41:10 +00:00
bvandeusen c177bf0691 fix: restore offline queue for captures, drain via chat on retry
Offline queue (SharedPreferences) persists captures when the device
is offline and replays them as chat conversations on next launch,
preserving the same fire-and-forget guarantee as the online path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:06:13 -04:00
bvandeusen 4ebc57d2e5 feat: quick capture sends to chat instead of quick-capture endpoint
Replace POST /api/quick-capture with: create a conversation, send the
message, and fire-and-forget the SSE generation stream so the assistant
processes the request in the background without blocking the UI.
The new conversation appears immediately in the chat tab.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:00:33 -04:00
bvandeusen 946b70ecc4 fix: use dialog context in event delete confirmation
Same navigator mismatch as the chat delete bug — outer context inside
showDialog builder resolves to the wrong navigator ancestor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 18:35:41 -04:00
bvandeusen 6ea268bf58 fix: use dialog context in delete confirmation to prevent shell nav pop
Navigator.pop(outerContext) inside a showDialog builder resolves to the
ShellRoute's nested navigator instead of the root navigator where the
dialog lives, popping the conversations route and showing a black screen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 18:33:04 -04:00
bvandeusen 7e332530fb feat: Calendar, News, Knowledge fixes, Voice STT fix, auto-refresh, chat improvements 2026-04-06 21:52:51 +00:00
bvandeusen 79dce1a01c feat: wire discuss button in briefing RSS cards, cap cards at 3
Adds discussArticle() to BriefingApi and wires it through to the
RSS news cards in BriefingScreen so tapping Discuss opens a chat
conversation seeded with the article. Also caps RSS cards per
message at 3 to avoid overly long briefing threads.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 17:49:16 -04:00
bvandeusen cb3a09756f fix: create conversations with empty title so server auto-names them
The app was creating conversations with title 'New conversation'.
The server only generates a title when conv_title is falsy (empty).
With a non-empty title, should_gen_title is False for the first
message (msg_count % 10 != 0), so auto-naming never fired.

Now creates with empty title (matching web app behaviour). The list
still displays 'New conversation' as a UI placeholder until the
server-generated title arrives.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 17:46:23 -04:00
bvandeusen d441dcf954 fix(voice): fix silent STT failure — use AAC/M4A, store onError, guard NaN amplitude
Three bugs causing silent STT failure on Android:

1. AudioEncoder.opus produces an OGG container on Android but the file
   was named .webm — faster-whisper rejected it due to format mismatch.
   Changed to AudioEncoder.aacLc + .m4a (reliable on all Android versions).
   Updated voice_api.dart to send audio/mp4 MIME type accordingly.

2. onError callback was never stored, so errors in _startListening() and
   _handleSilence() were silently swallowed. Now stored as _onError and
   called before exitVoiceMode() on any failure.

3. Amplitude stream can emit NaN or ±Infinity on some Android devices
   during recorder initialisation. NaN < -40.0 is false, so silence was
   never detected. Now treated explicitly as silence.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 17:39:21 -04:00
bvandeusen 03dc9108a3 docs: update README to reflect current app features and architecture
Replaces outdated Library/stub descriptions with accurate coverage of
Knowledge, Calendar, News, Voice I/O, chat tool-status notifications,
auto-refresh, and the current file tree.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 17:34:20 -04:00
bvandeusen 5014eca9ac feat: auto-refresh data on app resume and tab switch
- App resume: invalidates all main providers (conversations, calendar,
  news, knowledge, briefing) when the app returns to foreground.
  Throttled to once per 5 minutes to avoid hammering the server.
- Tab switch: refreshes the incoming tab's provider when navigating
  between Briefing / Knowledge / Conversations shell tabs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 12:45:33 -04:00
bvandeusen d530920284 fix: load tasks via TasksApi in Knowledge view, remove tab counts
Tasks are not part of the knowledge API (backend rejects type=task).
When the Tasks tab is selected, fetch from /api/tasks and convert to
KnowledgeItem. Also removes per-type counts from tab labels to prevent
tabs from awkwardly resizing when counts load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 12:36:00 -04:00
bvandeusen e2a358a158 feat: show tool status notifications in chat during generation
Parse SSE `status` events alongside `chunk` events and display the
status text next to the spinner while the assistant is generating.
Previously the Android app discarded all non-chunk SSE events.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 11:46:42 -04:00
bvandeusen 4919f7a185 feat: wire Calendar route to CalendarScreen 2026-04-06 10:32:53 -04:00
bvandeusen 5b639dbd4c feat: add CalendarScreen with month strip and agenda list 2026-04-06 10:32:13 -04:00
bvandeusen 334882520c feat: add EventFormSheet with CRUD, recurrence picker, and color chips
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 10:31:08 -04:00
bvandeusen c4dca6d4ed feat: add CalendarNotifier and calendarProvider 2026-04-06 10:28:53 -04:00
bvandeusen 776b394874 feat: add EventsApi and eventsApiProvider 2026-04-06 10:27:11 -04:00
bvandeusen b56c0fc02d feat: add table_calendar package and CalendarEvent model with tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 10:25:52 -04:00
bvandeusen 8a0837a843 docs: add Android Calendar screen implementation plan 2026-04-06 10:17:29 -04:00
bvandeusen 8e5a95b0f2 docs: add Android Calendar screen design spec 2026-04-06 10:01:37 -04:00
bvandeusen 3a07221968 feat: wire News route to NewsScreen 2026-04-06 08:30:08 -04:00
bvandeusen e7d174cef7 feat: add NewsScreen with feed filter, reactions, and discuss 2026-04-06 08:29:30 -04:00
bvandeusen f4e39c00eb fix: use ref.watch in build() and avoid stale snapshot in loadMore catch 2026-04-06 08:26:59 -04:00
bvandeusen e08a8906e3 feat: add NewsNotifier and feedsProvider
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 08:22:24 -04:00
bvandeusen 01ea6b48db feat: add RssItemMeta.fromNewsItem adapter factory 2026-04-06 08:18:34 -04:00
bvandeusen b56f3d3a0f feat: add NewsApi, openArticleInChat, and newsApiProvider 2026-04-06 08:16:00 -04:00
bvandeusen 77fc82af45 feat: add NewsItem and BriefingFeed models with tests 2026-04-06 08:13:06 -04:00
bvandeusen a2fc0d6c7d docs: add Android news screen implementation plan 2026-04-06 08:11:12 -04:00
bvandeusen 2a2f9e6e85 docs: add Android news screen design spec 2026-04-06 08:04:18 -04:00
bvandeusen 95d0f529ea fix: invalidate project tasks on return from task edit screen 2026-04-06 07:15:35 -04:00
bvandeusen 36bc36cd9d feat: replace Projects tab with More bottom sheet (news/calendar stubs) 2026-04-06 07:14:38 -04:00
bvandeusen a23af0658a docs: add Android nav restructure implementation plan 2026-04-06 06:40:06 -04:00
bvandeusen 39d9f7e053 docs: add Android nav restructure + projects staleness fix spec 2026-04-06 06:35:31 -04:00
55 changed files with 6173 additions and 358 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
+44 -22
View File
@@ -6,11 +6,16 @@ Native Android client for FabledAssistant, a self-hosted AI second-brain and pro
- **Daily Briefing** — the primary screen; opens on launch. Shows your AI-compiled morning digest (tasks, calendar, weather, RSS) with a full conversation you can reply to inline. Refresh manually or browse past briefings from the overflow menu.
- **Quick Capture** — always-visible input bar above all tabs. Type a note or task and submit; multiple captures queue sequentially so the input is never blocked. Falls back to offline persistence when the server is unreachable.
- **Library** — unified browsable list of notes, tasks, and projects with filter pills (All · Notes · Tasks · Projects). Tasks have a secondary status sub-filter and a tappable status icon to cycle todo → in progress → done without opening the editor. Inline search filters results live.
- **Chat** — streaming AI conversations with real-time SSE display. Tap + to start a new conversation or open an existing one.
- **Knowledge** — unified browsable feed of notes, people, places, lists, and tasks across six filter tabs. Tag filters, inline search (debounced), and two-tier pagination (ID list → batch hydration). Tasks load from `/api/tasks` directly and are fully integrated with the knowledge feed.
- **Chat** — streaming AI conversations with real-time SSE display, including live tool-use status notifications (e.g. "Calling create_note…"). Tap + to start a new conversation or open an existing one.
- **Calendar** — month strip + daily agenda view backed by the server's internal event store. Full event CRUD with a modal form: title, all-day toggle, start/end date+time pickers, repeat (None/Daily/Weekly/Monthly/Yearly), description, location, and colour chips. Custom RRULE strings are preserved read-only.
- **News** — RSS article feed with per-feed filtering and reactions. Tap "Discuss" to open any article in a new chat conversation.
- **Projects** — browse and edit projects; tap a project to see its milestone-grouped task list.
- **Voice I/O** — tap the microphone in the capture bar or chat to dictate. Server-side STT transcribes audio; TTS reads assistant replies aloud in voice mode.
- **Note & task editing** — full Markdown editor for notes, task editor with due date, priority, project, and milestone assignment.
- **OAuth / SSO** — authenticates via your server's OIDC provider; local username/password login also supported if enabled server-side.
- **Session persistence** — stays logged in across restarts via a persistent cookie jar.
- **Auto-refresh** — data refreshes automatically when the app returns to the foreground (throttled to once per 5 minutes) and when switching between shell tabs.
- **Auto-update** — checks your Forgejo releases on launch and prompts to download and install new APKs in-app.
## Requirements
@@ -39,33 +44,50 @@ On first launch, enter your FabledAssistant server URL (e.g. `https://fabled.exa
```
lib/
├── main.dart # Entry point; resolves async deps before runApp
├── app.dart # GoRouter + auth redirect guards + 3-tab shell
├── main.dart # Entry point; resolves async deps before runApp
├── app.dart # GoRouter + auth guards + 3-tab shell + auto-refresh
├── core/
│ ├── constants.dart # Route name constants
│ ├── exceptions.dart # AppException hierarchy
│ └── theme.dart # Custom slate-indigo ColorScheme + Fraunces typography
│ ├── constants.dart # Route name constants
│ ├── exceptions.dart # AppException hierarchy
│ └── theme.dart # Custom slate-indigo ColorScheme + Fraunces typography
├── data/
│ ├── api/ # Dio HTTP layer (one class per resource)
│ ├── models/ # Plain Dart models with fromJson/toJson
└── repositories/ # Thin wrappers over API classes
├── providers/ # Riverpod providers (state + dependency wiring)
│ ├── briefing_provider.dart # Today's briefing — optimistic UI, SSE, polling
│ ├── api/ # Dio HTTP layer (one class per resource)
│ ├── chat_api.dart # SSE streaming; typed ChatStreamEvent (text/status)
│ ├── events_api.dart # Calendar event CRUD
│ │ ├── knowledge_api.dart # Two-tier paginated knowledge feed
│ ├── news_api.dart # RSS feed + reactions
│ │ └── voice_api.dart # STT + TTS endpoints
│ ├── models/ # Plain Dart models with fromJson/toJson
│ │ ├── calendar_event.dart # CalendarEvent + dateOnly() helper
│ │ ├── knowledge_item.dart # KnowledgeItem (notes/tasks unified)
│ │ └── message.dart # Chat message with streaming status
│ └── repositories/ # Thin wrappers over API classes
├── providers/ # Riverpod providers (state + dependency wiring)
│ ├── briefing_provider.dart # Today's briefing — optimistic UI, SSE, polling
│ ├── calendar_provider.dart # CalendarNotifier: month navigation + event CRUD
│ ├── chat_provider.dart # Conversations + streaming messages + status
│ ├── knowledge_provider.dart # Two-tier pagination; delegates tasks to TasksApi
│ ├── news_provider.dart # NewsNotifier + FeedsNotifier
│ └── capture_work_queue_provider.dart # Sequential in-memory capture queue
├── screens/
│ ├── briefing/ # BriefingScreen + BriefingHistoryScreen
│ ├── library/ # LibraryScreen (unified notes/tasks/projects)
│ ├── chat/ # ConversationsTabScreen + ChatScreen
│ ├── notes/ # NoteDetailScreen + NoteEditScreen
│ ├── tasks/ # TaskEditScreen
│ ├── briefing/ # BriefingScreen + BriefingHistoryScreen
│ ├── calendar/ # CalendarScreen (TableCalendar) + EventFormSheet
│ ├── chat/ # ConversationsTabScreen + ChatScreen
│ ├── knowledge/ # KnowledgeScreen (6-tab feed + search + tag filters)
│ ├── library/ # ProjectTasksScreen (milestone-grouped task list)
│ ├── news/ # NewsScreen (feed filter + reactions + discuss)
│ ├── notes/ # NoteDetailScreen + NoteEditScreen
│ ├── projects/ # ProjectsScreen + ProjectEditScreen
│ ├── tasks/ # TaskEditScreen
│ └── settings/ auth/ setup/ splash/
└── widgets/
├── chat_message_bubble.dart # Shared bubble (used by Chat + Briefing)
├── briefing_digest_card.dart # Expandable first-message card
── library_item_card.dart # Note / task / project row widgets
├── chat_message_bubble.dart # Shared bubble (Chat + Briefing); shows tool status
├── knowledge_item_card.dart # Card for notes, people, places, lists, tasks
── news_card.dart # RSS article card with reactions
└── voice_mic_button.dart # Animated mic button (capture bar + chat)
```
**Key packages:** `flutter_riverpod`, `go_router`, `dio` + `cookie_jar`, `google_fonts`, `flutter_markdown_plus`
**Key packages:** `flutter_riverpod`, `go_router`, `dio` + `cookie_jar`, `google_fonts`, `flutter_markdown_plus`, `table_calendar`, `record`, `just_audio`
## Building a Release APK
@@ -91,7 +113,7 @@ The build job has `needs: [analyze]` — a failed analyze or test blocks the APK
To cut a release:
```bash
git tag v26.03.11 && git push origin v26.03.11
git tag v26.04.06 && git push origin v26.04.06
```
Requires a `RELEASE_TOKEN` secret (Forgejo PAT with `write:repository` scope) set in repo Settings → Secrets → Actions.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,521 @@
# Android Nav Restructure & Projects Staleness Fix Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the Projects bottom-nav tab with a "More" bottom sheet giving access to Projects, News, and Calendar; fix stale task data when returning from task edit.
**Architecture:** The shell's `_tabs` list shrinks from 4 to 3 entries; the 4th nav destination ("More") is intercepted in `onDestinationSelected` to show a `showModalBottomSheet` instead of navigating. Projects moves from a ShellRoute to a top-level push route. News and Calendar are added as stub push-routes. `_tabIndex` maps the three overflow paths to index 3 so "More" highlights correctly. The staleness fix moves the task-tap callback out of the stateless `_TaskRow` widget into the parent `ConsumerState` where `ref` is available, using `.then()` to invalidate after pop.
**Tech Stack:** Flutter, GoRouter, Riverpod, Material 3
---
## Files
| File | Action | What changes |
|------|--------|-------------|
| `lib/core/constants.dart` | Modify | Add `news` and `calendar` route constants |
| `lib/app.dart` | Modify | Remove Projects from ShellRoute, add 3 push routes, shrink `_tabs`, add `_showMoreSheet`, update `_tabIndex`, update both nav widgets |
| `lib/screens/library/project_tasks_screen.dart` | Modify | Add `onTap: VoidCallback` to `_TaskRow`; add `_openTask` method to state; pass callback at both call sites |
---
### Task 1: Add route constants
**Files:**
- Modify: `lib/core/constants.dart`
- [ ] **Step 1: Add `news` and `calendar` to `Routes`**
Open `lib/core/constants.dart`. The file currently ends with:
```dart
abstract class Routes {
static const splash = '/';
static const setup = '/setup';
static const login = '/login';
static const notes = '/notes';
static const noteDetail = '/notes/:id';
static const noteEdit = '/notes/:id/edit';
static const noteNew = '/notes/new';
static const tasks = '/tasks';
static const taskNew = '/tasks/new';
static const taskEdit = '/tasks/:id/edit';
static const knowledge = '/knowledge';
static const projects = '/projects';
static const projectEdit = '/projects/:id/edit';
static const conversations = '/chat';
static const chat = '/chat/:id';
static const quickCapture = '/quick-capture';
static const settings = '/settings';
static const briefing = '/briefing';
static const projectTasks = '/projects/:id/tasks';
}
```
Add two constants after `briefing`:
```dart
abstract class Routes {
static const splash = '/';
static const setup = '/setup';
static const login = '/login';
static const notes = '/notes';
static const noteDetail = '/notes/:id';
static const noteEdit = '/notes/:id/edit';
static const noteNew = '/notes/new';
static const tasks = '/tasks';
static const taskNew = '/tasks/new';
static const taskEdit = '/tasks/:id/edit';
static const knowledge = '/knowledge';
static const projects = '/projects';
static const projectEdit = '/projects/:id/edit';
static const conversations = '/chat';
static const chat = '/chat/:id';
static const quickCapture = '/quick-capture';
static const settings = '/settings';
static const briefing = '/briefing';
static const news = '/news';
static const calendar = '/calendar';
static const projectTasks = '/projects/:id/tasks';
}
```
- [ ] **Step 2: Verify no analysis errors**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
flutter analyze lib/core/constants.dart
```
Expected: `No issues found!`
---
### Task 2: Restructure shell and add routes in `app.dart`
**Files:**
- Modify: `lib/app.dart`
This task has several sub-steps. Make them all before running analyze.
- [ ] **Step 1: Move Projects out of ShellRoute, add three push routes**
Find the `ShellRoute` block (currently lines ~142162):
```dart
ShellRoute(
builder: (context, state, child) => _Shell(child: child),
routes: [
GoRoute(
path: Routes.briefing,
builder: (_, _) => const BriefingScreen(),
),
GoRoute(
path: Routes.knowledge,
builder: (_, _) => const KnowledgeScreen(),
),
GoRoute(
path: Routes.conversations,
builder: (_, _) => const ConversationsTabScreen(),
),
GoRoute(
path: Routes.projects,
builder: (_, _) => const ProjectsScreen(),
),
],
),
```
Replace with (Projects removed from shell; three new top-level routes added after the closing `],` of the ShellRoute):
```dart
ShellRoute(
builder: (context, state, child) => _Shell(child: child),
routes: [
GoRoute(
path: Routes.briefing,
builder: (_, _) => const BriefingScreen(),
),
GoRoute(
path: Routes.knowledge,
builder: (_, _) => const KnowledgeScreen(),
),
GoRoute(
path: Routes.conversations,
builder: (_, _) => const ConversationsTabScreen(),
),
],
),
GoRoute(
path: Routes.projects,
builder: (_, _) => const ProjectsScreen(),
),
GoRoute(
path: Routes.news,
builder: (_, _) => Scaffold(
appBar: AppBar(title: const Text('News')),
body: const Center(child: Text('News — coming soon')),
),
),
GoRoute(
path: Routes.calendar,
builder: (_, _) => Scaffold(
appBar: AppBar(title: const Text('Calendar')),
body: const Center(child: Text('Calendar — coming soon')),
),
),
```
- [ ] **Step 2: Shrink `_tabs` from 4 to 3 entries**
Find in `_ShellState`:
```dart
static const _tabs = [
Routes.briefing,
Routes.knowledge,
Routes.conversations,
Routes.projects,
];
```
Replace with:
```dart
static const _tabs = [
Routes.briefing,
Routes.knowledge,
Routes.conversations,
];
```
- [ ] **Step 3: Update `_tabIndex` to map overflow routes to index 3**
Find:
```dart
int _tabIndex(String location) {
for (var i = 0; i < _tabs.length; i++) {
if (location.startsWith(_tabs[i])) return i;
}
return 0;
}
```
Replace with:
```dart
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;
return 0;
}
```
- [ ] **Step 4: Add `_showMoreSheet` method to `_ShellState`**
Add this method anywhere in `_ShellState`, e.g. just before `build`:
```dart
void _showMoreSheet(BuildContext context) {
showModalBottomSheet<void>(
context: context,
builder: (_) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.folder_outlined),
title: const Text('Projects'),
onTap: () {
Navigator.pop(context);
context.push(Routes.projects);
},
),
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'),
onTap: () {
Navigator.pop(context);
context.push(Routes.calendar);
},
),
],
),
),
);
}
```
- [ ] **Step 5: Update `NavigationRail` — intercept index 3 and swap destination**
Find in the wide-layout branch:
```dart
NavigationRail(
selectedIndex: index,
onDestinationSelected: (i) => context.go(_tabs[i]),
labelType: NavigationRailLabelType.all,
destinations: const [
NavigationRailDestination(
icon: Icon(Icons.wb_sunny_outlined),
selectedIcon: Icon(Icons.wb_sunny),
label: Text('Briefing'),
),
NavigationRailDestination(
icon: Icon(Icons.menu_book_outlined),
selectedIcon: Icon(Icons.menu_book),
label: Text('Knowledge'),
),
NavigationRailDestination(
icon: Icon(Icons.chat_bubble_outline),
selectedIcon: Icon(Icons.chat_bubble),
label: Text('Chat'),
),
NavigationRailDestination(
icon: Icon(Icons.folder_outlined),
selectedIcon: Icon(Icons.folder),
label: Text('Projects'),
),
],
),
```
Replace with:
```dart
NavigationRail(
selectedIndex: index,
onDestinationSelected: (i) {
if (i == 3) {
_showMoreSheet(context);
} else {
context.go(_tabs[i]);
}
},
labelType: NavigationRailLabelType.all,
destinations: const [
NavigationRailDestination(
icon: Icon(Icons.wb_sunny_outlined),
selectedIcon: Icon(Icons.wb_sunny),
label: Text('Briefing'),
),
NavigationRailDestination(
icon: Icon(Icons.menu_book_outlined),
selectedIcon: Icon(Icons.menu_book),
label: Text('Knowledge'),
),
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'),
),
],
),
```
- [ ] **Step 6: Update `NavigationBar` — intercept index 3 and swap destination**
Find in the narrow-layout branch:
```dart
bottomNavigationBar: NavigationBar(
selectedIndex: index,
onDestinationSelected: (i) => context.go(_tabs[i]),
destinations: const [
NavigationDestination(
icon: Icon(Icons.wb_sunny_outlined),
selectedIcon: Icon(Icons.wb_sunny),
label: 'Briefing',
),
NavigationDestination(
icon: Icon(Icons.menu_book_outlined),
selectedIcon: Icon(Icons.menu_book),
label: 'Knowledge',
),
NavigationDestination(
icon: Icon(Icons.chat_bubble_outline),
selectedIcon: Icon(Icons.chat_bubble),
label: 'Chat',
),
NavigationDestination(
icon: Icon(Icons.folder_outlined),
selectedIcon: Icon(Icons.folder),
label: 'Projects',
),
],
),
```
Replace with:
```dart
bottomNavigationBar: NavigationBar(
selectedIndex: index,
onDestinationSelected: (i) {
if (i == 3) {
_showMoreSheet(context);
} else {
context.go(_tabs[i]);
}
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.wb_sunny_outlined),
selectedIcon: Icon(Icons.wb_sunny),
label: 'Briefing',
),
NavigationDestination(
icon: Icon(Icons.menu_book_outlined),
selectedIcon: Icon(Icons.menu_book),
label: 'Knowledge',
),
NavigationDestination(
icon: Icon(Icons.chat_bubble_outline),
selectedIcon: Icon(Icons.chat_bubble),
label: 'Chat',
),
NavigationDestination(
icon: Icon(Icons.more_horiz_outlined),
selectedIcon: Icon(Icons.more_horiz),
label: 'More',
),
],
),
```
- [ ] **Step 7: Verify no analysis errors**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
flutter analyze lib/app.dart lib/core/constants.dart
```
Expected: `No issues found!`
- [ ] **Step 8: Commit**
```bash
git add lib/core/constants.dart lib/app.dart
git commit -m "feat: replace Projects tab with More bottom sheet (news/calendar stubs)"
```
---
### Task 3: Fix stale tasks after returning from task edit
**Files:**
- Modify: `lib/screens/library/project_tasks_screen.dart`
- [ ] **Step 1: Add `_openTask` method to `_ProjectTasksScreenState`**
`_ProjectTasksScreenState` is a `ConsumerState` — it has `ref` and `context`. Find the class body (look for `_cycleStatus` method as a landmark) and add `_openTask` as a sibling method:
```dart
void _openTask(int taskId) {
context
.push(Routes.taskEdit.replaceFirst(':id', '$taskId'))
.then((_) => ref.invalidate(projectTasksProvider(widget.projectId)));
}
```
- [ ] **Step 2: Add `onTap` parameter to `_TaskRow`**
Find the `_TaskRow` class definition:
```dart
class _TaskRow extends StatelessWidget {
final Task task;
final TaskStatus effectiveStatus;
final VoidCallback onStatusTap;
const _TaskRow({
required this.task,
required this.effectiveStatus,
required this.onStatusTap,
});
```
Replace with:
```dart
class _TaskRow extends StatelessWidget {
final Task task;
final TaskStatus effectiveStatus;
final VoidCallback onStatusTap;
final VoidCallback onTap;
const _TaskRow({
required this.task,
required this.effectiveStatus,
required this.onStatusTap,
required this.onTap,
});
```
- [ ] **Step 3: Use the `onTap` callback in `InkWell`**
Find inside `_TaskRow.build`:
```dart
child: InkWell(
onTap: () => context
.push(Routes.taskEdit.replaceFirst(':id', '${task.id}')),
```
Replace with:
```dart
child: InkWell(
onTap: onTap,
```
- [ ] **Step 4: Pass `onTap` at both `_TaskRow` call sites**
There are two places in `_buildBody` where `_TaskRow` is instantiated. Update both:
**First call site (milestone tasks, around line 170):**
```dart
return _TaskRow(
task: task,
effectiveStatus: _effectiveStatus(task),
onStatusTap: () => _cycleStatus(task),
onTap: () => _openTask(task.id),
);
```
**Second call site (unassigned tasks, around line 197):**
```dart
return _TaskRow(
task: task,
effectiveStatus: _effectiveStatus(task),
onStatusTap: () => _cycleStatus(task),
onTap: () => _openTask(task.id),
);
```
- [ ] **Step 5: Verify no analysis errors**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
flutter analyze lib/screens/library/project_tasks_screen.dart
```
Expected: `No issues found!`
- [ ] **Step 6: Commit**
```bash
git add lib/screens/library/project_tasks_screen.dart
git commit -m "fix: invalidate project tasks on return from task edit screen"
```
---
### Task 4: Final check
- [ ] **Step 1: Full analyze**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
flutter analyze
```
Expected: `No issues found!`
@@ -0,0 +1,823 @@
# Android News Screen Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the Android News screen — a paginated, feed-filtered list of RSS news items with reactions and a Discuss action that opens a new general chat conversation.
**Architecture:** `NewsNotifier` (`AsyncNotifier<NewsState>`) holds the accumulated item list, pagination, reaction map, and selected feed. The screen is a `ConsumerStatefulWidget` at `/news`. Discuss calls `POST /api/chat/from-article/{id}` (same endpoint as web) and navigates to the returned conversation. Reactions reuse `briefingApiProvider`. The existing `NewsCard` widget is used unchanged via a `RssItemMeta.fromNewsItem` adapter factory.
**Tech Stack:** Flutter, Riverpod AsyncNotifier, Dio, GoRouter, existing `NewsCard` widget
---
## Files
| File | Action | Responsibility |
|------|--------|---------------|
| `lib/data/models/news_item.dart` | Create | `NewsItem` model + `fromJson` |
| `lib/data/models/briefing_feed.dart` | Create | `BriefingFeed` model + `fromJson` |
| `lib/data/api/news_api.dart` | Create | `getNewsItems(...)` + `getFeeds()` Dio calls |
| `lib/data/api/chat_api.dart` | Modify | Add `openArticleInChat(int itemId)` |
| `lib/providers/api_client_provider.dart` | Modify | Add `newsApiProvider` |
| `lib/widgets/news_card.dart` | Modify | Add `RssItemMeta.fromNewsItem` factory |
| `lib/providers/news_provider.dart` | Create | `NewsState`, `NewsNotifier`, `newsProvider`, `feedsProvider` |
| `lib/screens/news/news_screen.dart` | Create | News screen UI |
| `lib/app.dart` | Modify | Replace News stub route with `NewsScreen()` |
| `test/widget_test.dart` | Modify | Add `NewsItem.fromJson` and `BriefingFeed.fromJson` tests |
---
### Task 1: Data models + tests
**Files:**
- Create: `lib/data/models/news_item.dart`
- Create: `lib/data/models/briefing_feed.dart`
- Modify: `test/widget_test.dart`
- [ ] **Step 1: Create `NewsItem` model**
Create `lib/data/models/news_item.dart`:
```dart
class NewsItem {
final int id;
final String title;
final String url;
final String snippet;
final String source;
final DateTime? publishedAt;
final List<String> topics;
final String? reaction;
const NewsItem({
required this.id,
required this.title,
required this.url,
required this.snippet,
required this.source,
this.publishedAt,
required this.topics,
this.reaction,
});
factory NewsItem.fromJson(Map<String, dynamic> json) => NewsItem(
id: json['id'] as int,
title: json['title'] as String? ?? '',
url: json['url'] as String? ?? '',
snippet: json['snippet'] as String? ?? '',
source: json['source'] as String? ?? '',
publishedAt: json['published_at'] != null
? DateTime.tryParse(json['published_at'] as String)
: null,
topics: (json['topics'] as List<dynamic>?)
?.cast<String>()
.toList() ??
[],
reaction: json['reaction'] as String?,
);
}
```
- [ ] **Step 2: Create `BriefingFeed` model**
Create `lib/data/models/briefing_feed.dart`:
```dart
class BriefingFeed {
final int id;
final String title;
final String url;
final String? category;
const BriefingFeed({
required this.id,
required this.title,
required this.url,
this.category,
});
factory BriefingFeed.fromJson(Map<String, dynamic> json) => BriefingFeed(
id: json['id'] as int,
title: json['title'] as String? ?? '',
url: json['url'] as String? ?? '',
category: json['category'] as String?,
);
}
```
- [ ] **Step 3: Write model tests**
Add these groups to `test/widget_test.dart` (before the closing `}`):
```dart
group('NewsItem.fromJson', () {
test('parses all fields', () {
final json = {
'id': 42,
'title': 'Big news',
'url': 'https://example.com/article',
'snippet': 'A short summary.',
'source': 'Example News',
'published_at': '2026-01-15T10:00:00',
'topics': ['tech', 'ai'],
'reaction': 'up',
};
final item = NewsItem.fromJson(json);
expect(item.id, equals(42));
expect(item.title, equals('Big news'));
expect(item.url, equals('https://example.com/article'));
expect(item.snippet, equals('A short summary.'));
expect(item.source, equals('Example News'));
expect(item.publishedAt, equals(DateTime.parse('2026-01-15T10:00:00')));
expect(item.topics, equals(['tech', 'ai']));
expect(item.reaction, equals('up'));
});
test('handles null published_at and reaction', () {
final json = {
'id': 1,
'title': '',
'url': '',
'snippet': '',
'source': '',
'published_at': null,
'topics': <dynamic>[],
'reaction': null,
};
final item = NewsItem.fromJson(json);
expect(item.publishedAt, isNull);
expect(item.reaction, isNull);
expect(item.topics, isEmpty);
});
});
group('BriefingFeed.fromJson', () {
test('parses all fields', () {
final json = {
'id': 7,
'title': 'Hacker News',
'url': 'https://news.ycombinator.com/rss',
'category': 'tech',
};
final feed = BriefingFeed.fromJson(json);
expect(feed.id, equals(7));
expect(feed.title, equals('Hacker News'));
expect(feed.url, equals('https://news.ycombinator.com/rss'));
expect(feed.category, equals('tech'));
});
test('handles null category', () {
final json = {
'id': 8,
'title': 'Feed',
'url': 'https://example.com/rss',
'category': null,
};
final feed = BriefingFeed.fromJson(json);
expect(feed.category, isNull);
});
});
```
Also add these imports at the top of `test/widget_test.dart`:
```dart
import 'package:fabled_app/data/models/news_item.dart';
import 'package:fabled_app/data/models/briefing_feed.dart';
```
- [ ] **Step 4: Run tests**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
flutter test
```
Expected: All tests passed (17 total — 15 existing + 2 new groups = 4 new tests).
- [ ] **Step 5: Commit**
```bash
git add lib/data/models/news_item.dart lib/data/models/briefing_feed.dart test/widget_test.dart
git commit -m "feat: add NewsItem and BriefingFeed models with tests"
```
---
### Task 2: API layer
**Files:**
- Create: `lib/data/api/news_api.dart`
- Modify: `lib/data/api/chat_api.dart`
- Modify: `lib/providers/api_client_provider.dart`
- [ ] **Step 1: Create `NewsApi`**
Create `lib/data/api/news_api.dart`:
```dart
import 'package:dio/dio.dart';
import '../models/briefing_feed.dart';
import '../models/news_item.dart';
import 'api_client.dart';
class NewsApi {
final Dio _dio;
const NewsApi(this._dio);
/// GET /api/briefing/news
/// Returns up to [limit] items starting at [offset], optionally filtered by [feedId].
Future<List<NewsItem>> getNewsItems({
int days = 90,
int limit = 40,
int offset = 0,
int? feedId,
}) async {
try {
final params = <String, dynamic>{
'days': days,
'limit': limit,
'offset': offset,
if (feedId != null) 'feed_id': feedId,
};
final response = await _dio.get(
'/api/briefing/news',
queryParameters: params,
);
final data = response.data as Map<String, dynamic>;
final list = data['items'] as List<dynamic>;
return list
.map((e) => NewsItem.fromJson(e as Map<String, dynamic>))
.toList();
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// GET /api/briefing/feeds
Future<List<BriefingFeed>> getFeeds() async {
try {
final response = await _dio.get('/api/briefing/feeds');
final list = response.data as List<dynamic>;
return list
.map((e) => BriefingFeed.fromJson(e as Map<String, dynamic>))
.toList();
} on DioException catch (e) {
throw dioToApp(e);
}
}
}
```
- [ ] **Step 2: Add `openArticleInChat` to `ChatApi`**
In `lib/data/api/chat_api.dart`, add this method at the end of the `ChatApi` class (before the closing `}`):
```dart
/// POST /api/chat/from-article/{itemId}
/// Creates or retrieves a chat conversation seeded with the article.
/// Returns the conversation_id.
Future<int> openArticleInChat(int itemId) async {
try {
final response =
await _dio.post('/api/chat/from-article/$itemId', data: <String, dynamic>{});
final data = response.data as Map<String, dynamic>;
return data['conversation_id'] as int;
} on DioException catch (e) {
throw dioToApp(e);
}
}
```
- [ ] **Step 3: Add `newsApiProvider` to `api_client_provider.dart`**
In `lib/providers/api_client_provider.dart`, add the import and provider.
Add import after the existing API imports (e.g., after `voice_api.dart`):
```dart
import '../data/api/news_api.dart';
```
Add provider after `voiceRepositoryProvider`:
```dart
final newsApiProvider = Provider<NewsApi>((ref) {
return NewsApi(ref.watch(dioProvider));
});
```
- [ ] **Step 4: Verify**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
flutter analyze lib/data/api/news_api.dart lib/data/api/chat_api.dart lib/providers/api_client_provider.dart
```
Expected: `No issues found!`
- [ ] **Step 5: Commit**
```bash
git add lib/data/api/news_api.dart lib/data/api/chat_api.dart lib/providers/api_client_provider.dart
git commit -m "feat: add NewsApi, openArticleInChat, and newsApiProvider"
```
---
### Task 3: RssItemMeta adapter
**Files:**
- Modify: `lib/widgets/news_card.dart`
`NewsCard` renders `RssItemMeta` objects. Rather than modifying the widget, we add a factory on `RssItemMeta` that converts a `NewsItem`.
- [ ] **Step 1: Add `fromNewsItem` factory**
In `lib/widgets/news_card.dart`, add this import at the top:
```dart
import '../data/models/news_item.dart';
```
Then add this factory inside the `RssItemMeta` class, after the existing `fromJson` factory:
```dart
factory RssItemMeta.fromNewsItem(NewsItem item) => RssItemMeta(
id: item.id,
title: item.title,
url: item.url,
source: item.source,
snippet: item.snippet,
publishedAt: item.publishedAt,
);
```
- [ ] **Step 2: Verify**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
flutter analyze lib/widgets/news_card.dart
```
Expected: `No issues found!`
- [ ] **Step 3: Commit**
```bash
git add lib/widgets/news_card.dart
git commit -m "feat: add RssItemMeta.fromNewsItem adapter factory"
```
---
### Task 4: Providers
**Files:**
- Create: `lib/providers/news_provider.dart`
- [ ] **Step 1: Create `news_provider.dart`**
Create `lib/providers/news_provider.dart`:
```dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/models/briefing_feed.dart';
import '../data/models/news_item.dart';
import 'api_client_provider.dart';
// ─── NewsState ────────────────────────────────────────────────────────────────
class NewsState {
final List<NewsItem> items;
final int offset;
final bool hasMore;
final bool loadingMore;
final int? selectedFeedId;
final Map<int, String?> reactions;
const NewsState({
required this.items,
required this.offset,
required this.hasMore,
required this.loadingMore,
required this.selectedFeedId,
required this.reactions,
});
NewsState copyWith({
List<NewsItem>? items,
int? offset,
bool? hasMore,
bool? loadingMore,
Object? selectedFeedId = _sentinel,
Map<int, String?>? reactions,
}) {
return NewsState(
items: items ?? this.items,
offset: offset ?? this.offset,
hasMore: hasMore ?? this.hasMore,
loadingMore: loadingMore ?? this.loadingMore,
selectedFeedId: selectedFeedId == _sentinel
? this.selectedFeedId
: selectedFeedId as int?,
reactions: reactions ?? this.reactions,
);
}
}
const _sentinel = Object();
// ─── NewsNotifier ─────────────────────────────────────────────────────────────
final newsProvider =
AsyncNotifierProvider<NewsNotifier, NewsState>(NewsNotifier.new);
class NewsNotifier extends AsyncNotifier<NewsState> {
static const _limit = 40;
@override
Future<NewsState> build() async {
final items = await ref.read(newsApiProvider).getNewsItems(
days: 90,
limit: _limit,
offset: 0,
);
return NewsState(
items: items,
offset: items.length,
hasMore: items.length == _limit,
loadingMore: false,
selectedFeedId: null,
reactions: {for (final item in items) item.id: item.reaction},
);
}
Future<void> loadMore() async {
final current = state.value;
if (current == null || current.loadingMore || !current.hasMore) return;
state = AsyncData(current.copyWith(loadingMore: true));
try {
final items = await ref.read(newsApiProvider).getNewsItems(
days: 90,
limit: _limit,
offset: current.offset,
feedId: current.selectedFeedId,
);
final updatedReactions = Map<int, String?>.from(current.reactions);
for (final item in items) {
updatedReactions.putIfAbsent(item.id, () => item.reaction);
}
state = AsyncData(current.copyWith(
items: [...current.items, ...items],
offset: current.offset + items.length,
hasMore: items.length == _limit,
loadingMore: false,
reactions: updatedReactions,
));
} catch (e) {
state = AsyncData(current.copyWith(loadingMore: false));
rethrow;
}
}
Future<void> setFeed(int? feedId) async {
state = const AsyncLoading();
try {
final items = await ref.read(newsApiProvider).getNewsItems(
days: 90,
limit: _limit,
offset: 0,
feedId: feedId,
);
state = AsyncData(NewsState(
items: items,
offset: items.length,
hasMore: items.length == _limit,
loadingMore: false,
selectedFeedId: feedId,
reactions: {for (final item in items) item.id: item.reaction},
));
} catch (e, st) {
state = AsyncError(e, st);
}
}
void toggleReaction(int itemId, String reaction) {
final current = state.value;
if (current == null) return;
final prev = current.reactions[itemId];
final next = prev == reaction ? null : reaction;
state = AsyncData(current.copyWith(
reactions: {...current.reactions, itemId: next},
));
final briefingApi = ref.read(briefingApiProvider);
final future = next == null
? briefingApi.deleteRssReaction(itemId)
: briefingApi.postRssReaction(itemId, next);
future.catchError((_) {
final s = state.value;
if (s != null) {
state = AsyncData(s.copyWith(
reactions: {...s.reactions, itemId: prev},
));
}
});
}
}
// ─── FeedsNotifier ────────────────────────────────────────────────────────────
final feedsProvider =
AsyncNotifierProvider<FeedsNotifier, List<BriefingFeed>>(FeedsNotifier.new);
class FeedsNotifier extends AsyncNotifier<List<BriefingFeed>> {
@override
Future<List<BriefingFeed>> build() async {
return ref.read(newsApiProvider).getFeeds();
}
}
```
- [ ] **Step 2: Verify**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
flutter analyze lib/providers/news_provider.dart
```
Expected: `No issues found!`
- [ ] **Step 3: Commit**
```bash
git add lib/providers/news_provider.dart
git commit -m "feat: add NewsNotifier and feedsProvider"
```
---
### Task 5: News screen
**Files:**
- Create: `lib/screens/news/news_screen.dart`
- [ ] **Step 1: Create the screen**
Create `lib/screens/news/news_screen.dart`:
```dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/constants.dart';
import '../../data/models/briefing_feed.dart';
import '../../providers/api_client_provider.dart';
import '../../providers/news_provider.dart';
import '../../widgets/news_card.dart';
class NewsScreen extends ConsumerStatefulWidget {
const NewsScreen({super.key});
@override
ConsumerState<NewsScreen> createState() => _NewsScreenState();
}
class _NewsScreenState extends ConsumerState<NewsScreen> {
final Set<int> _openingChat = {};
Future<void> _handleDiscuss(int itemId) async {
if (_openingChat.contains(itemId)) return;
setState(() => _openingChat.add(itemId));
try {
final conversationId =
await ref.read(chatApiProvider).openArticleInChat(itemId);
if (mounted) {
context.push(Routes.chat.replaceFirst(':id', '$conversationId'));
}
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to open article in chat.')),
);
}
} finally {
if (mounted) setState(() => _openingChat.remove(itemId));
}
}
Future<void> _loadMore() async {
try {
await ref.read(newsProvider.notifier).loadMore();
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to load more articles.')),
);
}
}
}
@override
Widget build(BuildContext context) {
final newsAsync = ref.watch(newsProvider);
final feedsAsync = ref.watch(feedsProvider);
final scheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('News', style: Theme.of(context).textTheme.titleLarge),
Text(
'Last 90 days',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
),
),
body: newsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text("Could not load news."),
const SizedBox(height: 12),
FilledButton.tonal(
onPressed: () => ref.invalidate(newsProvider),
child: const Text('Retry'),
),
],
),
),
data: (news) => Column(
children: [
_FeedFilter(
feeds: feedsAsync.value ?? [],
selectedFeedId: news.selectedFeedId,
onChanged: (feedId) =>
ref.read(newsProvider.notifier).setFeed(feedId),
),
const Divider(height: 1),
Expanded(
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'),
),
),
);
}
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),
);
},
),
),
],
),
),
);
}
}
class _FeedFilter extends StatelessWidget {
final List<BriefingFeed> feeds;
final int? selectedFeedId;
final void Function(int? feedId) onChanged;
const _FeedFilter({
required this.feeds,
required this.selectedFeedId,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 6),
child: Row(
children: [
Text(
'Feed:',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: 8),
DropdownButton<int?>(
value: selectedFeedId,
underline: const SizedBox.shrink(),
items: [
const DropdownMenuItem<int?>(
value: null,
child: Text('All feeds'),
),
...feeds.map(
(f) => DropdownMenuItem<int?>(
value: f.id,
child: Text(f.title),
),
),
],
onChanged: (v) => onChanged(v),
),
],
),
);
}
}
```
- [ ] **Step 2: Verify**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
flutter analyze lib/screens/news/news_screen.dart
```
Expected: `No issues found!`
- [ ] **Step 3: Commit**
```bash
git add lib/screens/news/news_screen.dart
git commit -m "feat: add NewsScreen with feed filter, reactions, and discuss"
```
---
### Task 6: Wire route and final verification
**Files:**
- Modify: `lib/app.dart`
- [ ] **Step 1: Replace the News stub route with `NewsScreen`**
In `lib/app.dart`, add the import near the other screen imports:
```dart
import 'screens/news/news_screen.dart';
```
Find and replace the stub route:
```dart
GoRoute(
path: Routes.news,
builder: (_, _) => Scaffold(
appBar: AppBar(title: const Text('News')),
body: const Center(child: Text('News — coming soon')),
),
),
```
Replace with:
```dart
GoRoute(
path: Routes.news,
builder: (_, _) => const NewsScreen(),
),
```
- [ ] **Step 2: Full analyze and tests**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
flutter analyze
flutter test
```
Expected: `No issues found!` and all tests passed.
- [ ] **Step 3: Commit**
```bash
git add lib/app.dart
git commit -m "feat: wire News route to NewsScreen"
```
@@ -0,0 +1,199 @@
# Android Calendar Screen Design
**Goal:** Build the Android Calendar screen — a month-strip + daily agenda view with full event CRUD (create, edit, delete) including a simple recurrence picker.
**Architecture:** `CalendarNotifier` (`AsyncNotifier<CalendarState>`) holds a `Map<DateTime, List<CalendarEvent>>` keyed by date-only values, selected day, focused month, and loaded date range. The screen uses `table_calendar` for the month strip and a `ListView` for the daily agenda. Create/edit/delete is handled by `EventFormSheet`, a scrollable modal bottom sheet. The existing `/api/events` backend is used unchanged.
**Tech Stack:** Flutter, Riverpod AsyncNotifier, Dio, GoRouter, `table_calendar` package
---
## Files
### New
| File | Responsibility |
|------|---------------|
| `lib/data/models/calendar_event.dart` | `CalendarEvent` model + `fromJson` |
| `lib/data/api/events_api.dart` | `getEvents`, `createEvent`, `updateEvent`, `deleteEvent` Dio calls |
| `lib/providers/calendar_provider.dart` | `CalendarState`, `CalendarNotifier`, `calendarProvider` |
| `lib/screens/calendar/calendar_screen.dart` | Calendar screen UI (month strip + agenda) |
| `lib/screens/calendar/event_form_sheet.dart` | Create/edit modal bottom sheet |
### Modified
| File | Change |
|------|--------|
| `lib/providers/api_client_provider.dart` | Add `eventsApiProvider` |
| `lib/app.dart` | Replace Calendar stub route with `CalendarScreen()` |
| `test/widget_test.dart` | Add `CalendarEvent.fromJson` tests |
| `pubspec.yaml` | Add `table_calendar` dependency |
---
## Data Model
### `CalendarEvent`
```dart
class CalendarEvent {
final int id;
final String title;
final DateTime startDt;
final DateTime? endDt;
final bool allDay;
final String description;
final String location;
final String color;
final String? recurrence; // raw RRULE string, e.g. "FREQ=WEEKLY"
final int? projectId;
final int? reminderMinutes;
}
```
`fromJson` maps: `id`, `title`, `start_dt` / `end_dt` (ISO string → `DateTime.parse`), `all_day`, `description`, `location`, `color`, `recurrence` (nullable), `project_id` (nullable), `reminder_minutes` (nullable).
Date normalization helper — used throughout to build map keys:
```dart
DateTime dateOnly(DateTime dt) => DateTime(dt.year, dt.month, dt.day);
```
---
## API Layer
### `events_api.dart`
```dart
class EventsApi {
final Dio _dio;
const EventsApi(this._dio);
// GET /api/events?from=<iso>&to=<iso>
Future<List<CalendarEvent>> getEvents(DateTime from, DateTime to) async { ... }
// POST /api/events
Future<CalendarEvent> createEvent(Map<String, dynamic> payload) async { ... }
// PATCH /api/events/{id}
Future<CalendarEvent> updateEvent(int id, Map<String, dynamic> fields) async { ... }
// DELETE /api/events/{id}
Future<void> deleteEvent(int id) async { ... }
}
```
All methods catch `DioException` and rethrow via `dioToApp(e)` (same pattern as `NewsApi`).
### `api_client_provider.dart` addition
```dart
final eventsApiProvider = Provider<EventsApi>((ref) =>
EventsApi(ref.watch(dioProvider)));
```
---
## State Management
### `CalendarState`
```dart
class CalendarState {
final Map<DateTime, List<CalendarEvent>> eventsByDay; // keys: midnight local time
final DateTime selectedDay;
final DateTime focusedMonth;
final DateTimeRange loadedRange;
}
```
### `CalendarNotifier extends AsyncNotifier<CalendarState>`
- **`build()`**: fetches events for `[firstDayOfMonth - 1 month, lastDayOfMonth + 1 month]` for the current month; populates `eventsByDay`; sets `selectedDay` to today; sets `loadedRange` to the fetched range.
- **`selectDay(DateTime day)`**: synchronous state update — updates `selectedDay` and `focusedMonth` to `DateTime(day.year, day.month)`. No API call.
- **`loadMonth(DateTime month)`**: updates `focusedMonth`. If `month` is already within `loadedRange`, no-op (state update only). Otherwise fetches events for that month, merges new items into `eventsByDay`, extends `loadedRange`.
- **`addEvent(CalendarEvent event)`**: inserts the event into `eventsByDay` under `dateOnly(event.startDt)`. Synchronous local mutation after successful API call.
- **`updateEvent(CalendarEvent updated)`**: removes old entry by `id` from its old date bucket (scanned), inserts under `dateOnly(updated.startDt)`. Synchronous local mutation.
- **`removeEvent(int id, DateTime date)`**: removes from `eventsByDay[dateOnly(date)]` by id. Synchronous local mutation.
All three mutation methods accept the server-returned `CalendarEvent` — the screen calls the API first, then passes the result to the notifier.
---
## Screen Behaviour
### `CalendarScreen` (`ConsumerStatefulWidget`)
**AppBar**: title "Calendar".
**Month strip** (`TableCalendar`):
- Format: `CalendarFormat.month`
- Selected day highlighted with primary color
- Days with events show a dot indicator
- `onDaySelected`: calls `notifier.selectDay(day)`
- `onPageChanged`: calls `notifier.loadMonth(month)`
**Agenda list** (`ListView.builder`):
- Items: `state.eventsByDay[dateOnly(state.selectedDay)] ?? []`
- Each `EventTile` shows: color dot, title, time string ("All day" if `allDay`, otherwise formatted start time)
- Tap → opens `EventFormSheet` in edit mode
- Empty: centered "No events" message
**FAB** (`FloatingActionButton`): opens `EventFormSheet` in create mode with `startDt` pre-set to `selectedDay` at current time (rounded to nearest hour)
**Initial loading**: `CircularProgressIndicator` centered. Error: message + "Retry" button calls `ref.invalidate(calendarProvider)`.
---
## Event Form Sheet
### `EventFormSheet` (shown via `showModalBottomSheet(isScrollControlled: true, useSafeArea: true)`)
Accepts `CalendarEvent? event` (null = create mode) and `DateTime? initialDate` (used in create mode).
**Fields:**
| Field | Widget | Notes |
|-------|--------|-------|
| Title | `TextField` | Required |
| All-day | `SwitchListTile` | Hides time pickers when on |
| Start date | `ListTile``showDatePicker` | |
| Start time | `ListTile``showTimePicker` | Hidden when all-day |
| End date | `ListTile``showDatePicker` | Optional, clearable |
| End time | `ListTile``showTimePicker` | Hidden when all-day |
| Repeat | `DropdownButton` | None / Daily / Weekly / Monthly / Yearly |
| Description | `TextField` multiline | Optional |
| Location | `TextField` | Optional |
| Color | Row of `InkWell` color chips | 6 preset colors + clear (empty string) |
**Repeat → RRULE mapping:**
| UI value | RRULE stored |
|----------|-------------|
| None | `null` |
| Daily | `FREQ=DAILY` |
| Weekly | `FREQ=WEEKLY` |
| Monthly | `FREQ=MONTHLY` |
| Yearly | `FREQ=YEARLY` |
Existing events with an unrecognized RRULE string (does not match the 5 patterns above) display "Custom (read-only)" and the dropdown is disabled. The raw RRULE is preserved unchanged on save.
**Save flow:**
1. Validate title non-empty
2. Build payload map from form state
3. Call `EventsApi.createEvent` or `EventsApi.updateEvent`
4. On success: call `notifier.addEvent(result)` or `notifier.updateEvent(result)`, pop sheet
5. On error: show SnackBar "Failed to save event."
**Delete flow (edit mode only):**
1. Show `AlertDialog` "Delete this event?"
2. On confirm: call `EventsApi.deleteEvent(event.id)`
3. On success: call `notifier.removeEvent(event.id, event.startDt)`, pop sheet
4. On error: show SnackBar "Failed to delete event."
---
## What This Does NOT Include
- Recurrence instance editing ("edit this event only" vs "all events") — backend does not support this distinction
- Reminder/notification editing — `reminder_minutes` field not exposed (backend stores it but no push notification is sent from events)
- Project association — `project_id` field not exposed in the form
- CalDAV sync trigger — available via `POST /api/events/sync` but not surfaced in this screen
@@ -0,0 +1,136 @@
# Android Nav Restructure & Projects Staleness Fix
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the Projects bottom-nav tab with a "More" bottom sheet that houses Projects, News, and Calendar; fix stale task data on the project tasks screen.
**Architecture:** The shell's 4th nav item becomes a non-routing action — tapping it shows a `showModalBottomSheet` with the three overflow destinations. `_tabIndex()` maps `/projects`, `/news`, `/calendar` prefixes to index 3 so the "More" tab highlights correctly. News and Calendar are added as stub push-routes now; their real screens are built in subsequent passes. The staleness fix is a single `await` + `ref.invalidate` after returning from task edit.
**Tech Stack:** Flutter, GoRouter, Riverpod, Material 3 `NavigationBar` / `NavigationRail`
---
## Scope
Two independent changes in one pass:
1. **Nav restructure**`lib/app.dart`
2. **Staleness fix**`lib/screens/library/project_tasks_screen.dart`
---
## Design Details
### 1. Nav Restructure (`lib/app.dart`)
**Route changes:**
- Remove `/projects` from the `ShellRoute` routes list.
- Add three new top-level `GoRoute` entries (alongside the existing non-shell routes):
- `/projects``ProjectsScreen()` (moved from shell)
- `/news` → stub `Scaffold(body: Center(child: Text('News — coming soon')))`
- `/calendar` → stub `Scaffold(body: Center(child: Text('Calendar — coming soon')))`
- Add route constants to `lib/core/constants.dart`: `news = '/news'`, `calendar = '/calendar'`
**Shell tab list:**
```dart
static const _tabs = [
Routes.briefing,
Routes.knowledge,
Routes.conversations,
];
```
(3 entries — "More" is index 3 but handled specially, not a route)
**`_tabIndex()` update:**
```dart
int _tabIndex(String location) {
for (var i = 0; i < _tabs.length; i++) {
if (location.startsWith(_tabs[i])) return i;
}
// Projects, News, Calendar all highlight "More"
if (location.startsWith(Routes.projects) ||
location.startsWith(Routes.news) ||
location.startsWith(Routes.calendar)) return 3;
return 0;
}
```
**`onDestinationSelected` update (both `NavigationBar` and `NavigationRail`):**
```dart
onDestinationSelected: (i) {
if (i == 3) {
_showMoreSheet(context);
} else {
context.go(_tabs[i]);
}
},
```
**`_showMoreSheet` method on `_ShellState`:**
```dart
void _showMoreSheet(BuildContext context) {
showModalBottomSheet<void>(
context: context,
builder: (_) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.folder_outlined),
title: const Text('Projects'),
onTap: () {
Navigator.pop(context);
context.push(Routes.projects);
},
),
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'),
onTap: () {
Navigator.pop(context);
context.push(Routes.calendar);
},
),
],
),
),
);
}
```
**4th nav destination label:** "More" with `Icons.more_horiz_outlined` / `Icons.more_horiz`.
**NavigationRail note:** The wide-layout rail also gets the same 4 destinations and the same intercept on `onDestinationSelected`.
---
### 2. Projects Staleness Fix (`lib/screens/library/project_tasks_screen.dart`)
**Current (line ~321):**
```dart
context.push(Routes.taskEdit.replaceFirst(':id', '${task.id}'));
```
**Fixed:**
```dart
await context.push(Routes.taskEdit.replaceFirst(':id', '${task.id}'));
ref.invalidate(projectTasksProvider(widget.projectId));
```
This re-fetches tasks as soon as the user pops back from the task edit screen, eliminating stale data.
---
## What This Does NOT Include
- Real News screen implementation (separate spec/pass)
- Real Calendar screen implementation (separate spec/pass)
- Any changes to the web frontend
@@ -0,0 +1,177 @@
# Android News Screen Design
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the Android News screen — a paginated, filterable list of RSS news items with reactions and a Discuss action that opens a new general chat conversation.
**Architecture:** A `NewsNotifier` (`AsyncNotifier`) holds the full accumulated item list, pagination state, and selected feed ID. The screen is a `ConsumerStatefulWidget` accessed via the More bottom sheet at `/news`. Discuss uses `POST /api/chat/from-article/{id}` (same endpoint as the web) so any backend behaviour change is automatically reflected. Reactions reuse the existing `briefingApiProvider` methods. The existing `NewsCard` widget is used without modification.
**Tech Stack:** Flutter, Riverpod AsyncNotifier, Dio, GoRouter, existing `NewsCard` widget
---
## Files
### New
| File | Responsibility |
|------|---------------|
| `lib/data/models/news_item.dart` | `NewsItem` model + `fromJson` |
| `lib/data/models/briefing_feed.dart` | `BriefingFeed` model + `fromJson` |
| `lib/data/api/news_api.dart` | `getNewsItems(...)` and `getFeeds()` API calls |
| `lib/providers/news_provider.dart` | `NewsNotifier`, `NewsState`, `newsProvider`, `feedsProvider` |
| `lib/screens/news/news_screen.dart` | News screen UI |
### Modified
| File | Change |
|------|--------|
| `lib/data/api/chat_api.dart` | Add `openArticleInChat(int itemId) → Future<int>` |
| `lib/providers/api_client_provider.dart` | Add `newsApiProvider` |
---
## Data Models
### `NewsItem`
```dart
class NewsItem {
final int id;
final String title;
final String url;
final String snippet;
final String source;
final DateTime? publishedAt;
final List<String> topics;
final String? reaction; // 'up' | 'down' | null
}
```
`fromJson` maps: `id`, `title`, `url`, `snippet`, `source`, `published_at` (nullable ISO string → `DateTime.tryParse`), `topics` (cast `List<dynamic>``List<String>`), `reaction` (nullable string).
### `BriefingFeed`
```dart
class BriefingFeed {
final int id;
final String title;
final String url;
final String? category;
}
```
---
## API Layer
### `news_api.dart`
```dart
class NewsApi {
final Dio _dio;
const NewsApi(this._dio);
// GET /api/briefing/news
Future<NewsItemsResponse> getNewsItems({
int days = 90,
int limit = 40,
int offset = 0,
int? feedId,
}) async { ... }
// GET /api/briefing/feeds
Future<List<BriefingFeed>> getFeeds() async { ... }
}
class NewsItemsResponse {
final List<NewsItem> items;
final int offset;
final int limit;
}
```
### `chat_api.dart` addition
```dart
// POST /api/chat/from-article/{itemId}
// Returns conversation_id
Future<int> openArticleInChat(int itemId) async { ... }
```
### `api_client_provider.dart` addition
```dart
final newsApiProvider = Provider<NewsApi>((ref) =>
NewsApi(ref.watch(dioProvider)));
```
---
## State Management
### `NewsState`
```dart
class NewsState {
final List<NewsItem> items;
final int offset;
final bool hasMore;
final bool loadingMore;
final int? selectedFeedId;
final Map<int, String?> reactions; // item id → 'up'|'down'|null
}
```
### `NewsNotifier extends AsyncNotifier<NewsState>`
- `build()`: fetches first page (offset=0, no feed filter); initialises `reactions` from `item.reaction` on each item
- `loadMore()`: appends next page; no-op if `loadingMore` or `!hasMore`; sets `loadingMore = true` optimistically; on error shows snackbar (error returned to caller, not thrown into AsyncError)
- `setFeed(int? feedId)`: resets `items`, `offset`, `hasMore`, `reactions`; sets `selectedFeedId`; triggers `build()`-equivalent reload via `state = AsyncLoading()` + fetch
- `toggleReaction(int itemId, String reaction)`: optimistic toggle in `reactions` map; calls `briefingApi.postRssReaction` or `deleteRssReaction`; reverts on error
### `feedsProvider extends AsyncNotifier<List<BriefingFeed>>`
- `build()`: fetches once; cached for the session (no invalidation needed — feeds rarely change)
---
## Screen Behaviour
### `NewsScreen` (`ConsumerStatefulWidget`)
**AppBar**: title "News", subtitle "Last 90 days"
**Feed filter row** (below app bar, above list): `DropdownButton` with "All feeds" option + one entry per feed. On change calls `ref.read(newsProvider.notifier).setFeed(id)`.
**List**: `ListView.builder` of `NewsCard` widgets. Each `NewsCard` receives:
- `item`: `RssItemMeta.fromNewsItem(item)` — a thin adapter since `NewsCard` already uses `RssItemMeta`
- `reaction`: `newsState.reactions[item.id]`
- `onReaction`: calls `notifier.toggleReaction`
- `onDiscuss`: calls `_handleDiscuss(item.id)`
**Load more**: `ListTile` / `FilledButton.tonal` at the bottom — shows "Load more" when `hasMore && !loadingMore`, spinner when `loadingMore`, hidden when `!hasMore`.
**Initial loading**: `CircularProgressIndicator` centered. Error state shows message + "Retry" button that calls `ref.invalidate(newsProvider)`.
**Discuss flow** (`_handleDiscuss`):
1. Track `_openingChat = {itemId}` in local `setState` (disables that card's button while in flight)
2. Call `chatApi.openArticleInChat(itemId)`
3. On success: `context.push(Routes.chat.replaceFirst(':id', '$conversationId'))`
4. On error: show snackbar "Failed to open article in chat."
5. Always: remove from `_openingChat`
---
## `RssItemMeta` Adapter
`NewsCard` currently consumes `RssItemMeta` (from `news_card.dart`). Add a factory on `RssItemMeta`:
```dart
factory RssItemMeta.fromNewsItem(NewsItem item) => RssItemMeta(
id: item.id,
title: item.title,
url: item.url,
source: item.source,
snippet: item.snippet,
publishedAt: item.publishedAt,
);
```
This keeps `NewsCard` unchanged and avoids coupling the widget to a second model type.
---
## What This Does NOT Include
- Feed management (add/remove/refresh feeds) — that is a Settings concern
- Offline caching
- Pull-to-refresh (load-more button is sufficient for the initial pass)
+146 -31
View File
@@ -5,16 +5,19 @@ import 'package:go_router/go_router.dart';
import 'package:flutter_timezone/flutter_timezone.dart';
import 'core/constants.dart';
import 'core/exceptions.dart';
import 'core/theme.dart';
import 'providers/api_client_provider.dart';
import 'providers/auth_provider.dart';
import 'core/exceptions.dart';
import 'providers/capture_queue_provider.dart';
import 'providers/capture_work_queue_provider.dart';
import 'providers/notes_provider.dart';
import 'providers/briefing_provider.dart';
import 'providers/calendar_provider.dart';
import 'providers/chat_provider.dart';
import 'providers/knowledge_provider.dart';
import 'providers/news_provider.dart';
import 'providers/settings_provider.dart';
import 'providers/update_provider.dart';
import 'providers/tasks_provider.dart';
import 'screens/auth/login_screen.dart';
import 'screens/briefing/briefing_screen.dart';
import 'screens/knowledge/knowledge_screen.dart';
@@ -26,9 +29,11 @@ import 'screens/projects/project_edit_screen.dart';
import 'screens/projects/projects_screen.dart';
import 'screens/notes/note_edit_screen.dart';
import 'screens/settings/settings_screen.dart';
import 'screens/news/news_screen.dart';
import 'screens/setup/setup_screen.dart';
import 'screens/splash/splash_screen.dart';
import 'screens/tasks/task_edit_screen.dart';
import 'screens/calendar/calendar_screen.dart';
import 'providers/voice_provider.dart';
import 'widgets/voice_mic_button.dart';
@@ -154,12 +159,20 @@ final routerProvider = Provider<GoRouter>((ref) {
path: Routes.conversations,
builder: (_, _) => const ConversationsTabScreen(),
),
GoRoute(
path: Routes.projects,
builder: (_, _) => const ProjectsScreen(),
),
],
),
GoRoute(
path: Routes.projects,
builder: (_, _) => const ProjectsScreen(),
),
GoRoute(
path: Routes.news,
builder: (_, _) => const NewsScreen(),
),
GoRoute(
path: Routes.calendar,
builder: (_, _) => const CalendarScreen(),
),
],
);
});
@@ -172,17 +185,22 @@ class _Shell extends ConsumerStatefulWidget {
ConsumerState<_Shell> createState() => _ShellState();
}
class _ShellState extends ConsumerState<_Shell> {
class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
static const _tabs = [
Routes.briefing,
Routes.knowledge,
Routes.conversations,
Routes.projects,
];
// Minimum gap between app-resume refreshes to avoid hammering the server.
static const _resumeCooldown = Duration(seconds: 30);
DateTime? _lastResumeRefresh;
int? _prevTabIndex;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) {
// Silent update check — only if we haven't already checked this session.
final repoUrl = ref.read(forgejoRepoUrlProvider);
@@ -197,6 +215,49 @@ class _ShellState extends ConsumerState<_Shell> {
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state != AppLifecycleState.resumed) return;
final now = DateTime.now();
if (_lastResumeRefresh != null &&
now.difference(_lastResumeRefresh!) < _resumeCooldown) {
return;
}
_lastResumeRefresh = now;
_refreshAll();
}
/// Refresh every major data provider. Safe to call speculatively —
/// providers that aren't currently watched are already disposed.
void _refreshAll() {
ref.read(conversationsProvider.notifier).refresh();
ref.read(calendarProvider.notifier).refresh();
ref.read(newsProvider.notifier).refresh();
ref.read(knowledgeProvider.notifier).refresh();
// briefingProvider is an AsyncNotifier family; invalidating is safe
// even if no conversation is open — it doesn't cause flicker since
// the briefing screen isn't a list view.
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();
}
}
Future<void> _syncTimezone() async {
try {
final tzInfo = await FlutterTimezone.getLocalTimezone();
@@ -210,9 +271,51 @@ class _ShellState extends ConsumerState<_Shell> {
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;
}
return 0;
}
void _showMoreSheet(BuildContext context) {
showModalBottomSheet<void>(
context: context,
builder: (_) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.folder_outlined),
title: const Text('Projects'),
onTap: () {
Navigator.pop(context);
context.push(Routes.projects);
},
),
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'),
onTap: () {
Navigator.pop(context);
context.push(Routes.calendar);
},
),
],
),
),
);
}
void _showUpdateDialog(UpdateState update) {
showDialog<void>(
context: context,
@@ -289,6 +392,13 @@ class _ShellState extends ConsumerState<_Shell> {
});
final location = GoRouterState.of(context).matchedLocation;
final index = _tabIndex(location);
// Refresh the incoming tab's data when switching between shell tabs.
if (_prevTabIndex != null && _prevTabIndex != index && index < 3) {
WidgetsBinding.instance.addPostFrameCallback((_) => _refreshTab(index));
}
_prevTabIndex = index;
final child = widget.child;
final isWide = MediaQuery.of(context).size.width >= 600;
@@ -303,7 +413,13 @@ class _ShellState extends ConsumerState<_Shell> {
children: [
NavigationRail(
selectedIndex: index,
onDestinationSelected: (i) => context.go(_tabs[i]),
onDestinationSelected: (i) {
if (i == 3) {
_showMoreSheet(context);
} else {
context.go(_tabs[i]);
}
},
labelType: NavigationRailLabelType.all,
destinations: const [
NavigationRailDestination(
@@ -322,9 +438,9 @@ class _ShellState extends ConsumerState<_Shell> {
label: Text('Chat'),
),
NavigationRailDestination(
icon: Icon(Icons.folder_outlined),
selectedIcon: Icon(Icons.folder),
label: Text('Projects'),
icon: Icon(Icons.more_horiz_outlined),
selectedIcon: Icon(Icons.more_horiz),
label: Text('More'),
),
],
),
@@ -354,7 +470,13 @@ class _ShellState extends ConsumerState<_Shell> {
),
bottomNavigationBar: NavigationBar(
selectedIndex: index,
onDestinationSelected: (i) => context.go(_tabs[i]),
onDestinationSelected: (i) {
if (i == 3) {
_showMoreSheet(context);
} else {
context.go(_tabs[i]);
}
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.wb_sunny_outlined),
@@ -372,9 +494,9 @@ class _ShellState extends ConsumerState<_Shell> {
label: 'Chat',
),
NavigationDestination(
icon: Icon(Icons.folder_outlined),
selectedIcon: Icon(Icons.folder),
label: 'Projects',
icon: Icon(Icons.more_horiz_outlined),
selectedIcon: Icon(Icons.more_horiz),
label: 'More',
),
],
),
@@ -417,27 +539,19 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
if (!mounted) return;
final queue = ref.read(captureQueueProvider);
if (queue.isEmpty) return;
final api = ref.read(quickCaptureApiProvider);
for (final text in List<String>.from(queue)) {
if (!mounted) break;
try {
final result = await api.capture(text);
// Dequeue before the mounted check — SharedPreferences doesn't need
// the widget alive, and skipping this would leave a ghost item.
final conv =
await ref.read(conversationsProvider.notifier).create('');
final chatRepo = ref.read(chatRepositoryProvider);
await chatRepo.sendMessage(conv.id, text);
chatRepo.streamGeneration(conv.id).drain<void>().ignore();
await ref.read(captureQueueProvider.notifier).dequeue(text);
if (!mounted) break;
switch (result.type) {
case 'note':
ref.invalidate(notesProvider);
case 'task':
case 'todo':
ref.invalidate(tasksProvider);
}
} on NetworkException {
break;
} catch (_) {
// Server error or unexpected failure — drop from queue to prevent
// ghost items that can never be cleared.
// Server error — drop from queue to prevent ghost items.
await ref.read(captureQueueProvider.notifier).dequeue(text);
}
}
@@ -539,6 +653,7 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
VoiceMicButton(
mode: ref.watch(voiceProvider).mode,
voiceModeActive: ref.watch(voiceProvider).voiceModeActive,
amplitude: ref.watch(voiceProvider).amplitude,
onTap: _toggleCaptureMic,
),
IconButton(
+2
View File
@@ -17,5 +17,7 @@ abstract class Routes {
static const quickCapture = '/quick-capture';
static const settings = '/settings';
static const briefing = '/briefing';
static const news = '/news';
static const calendar = '/calendar';
static const projectTasks = '/projects/:id/tasks';
}
+17 -17
View File
@@ -3,21 +3,21 @@ import 'package:google_fonts/google_fonts.dart';
// ── Colour constants ──────────────────────────────────────────────────────────
const _darkBackground = Color(0xFF111113);
const _darkSurface = Color(0xFF18181C);
const _darkSurfaceVar = Color(0xFF1E1E24);
const _darkPrimary = Color(0xFF6366F1);
const _darkOnSurface = Color(0xFFE8E8F0);
const _darkBackground = Color(0xFF0F0F14);
const _darkSurface = Color(0xFF16161F);
const _darkSurfaceVar = Color(0xFF1A1A24);
const _darkPrimary = Color(0xFF7C3AED);
const _darkOnSurface = Color(0xFFE4E4F0);
const _darkOnSurfaceVar = Color(0xFF8888A8);
const _darkOutline = Color(0xFF2E2E3A);
const _darkOutline = Color(0xFF2A2A3A);
const _lightBackground = Color(0xFFF4F4F8);
const _lightBackground = Color(0xFFF5F5FB);
const _lightSurface = Color(0xFFFFFFFF);
const _lightSurfaceVar = Color(0xFFF0F0F5);
const _lightPrimary = Color(0xFF4F46E5);
const _lightOnSurface = Color(0xFF18181C);
const _lightOnSurfaceVar = Color(0xFF6B6B88);
const _lightOutline = Color(0xFFD4D4E4);
const _lightSurfaceVar = Color(0xFFF0F0F8);
const _lightPrimary = Color(0xFF7C3AED);
const _lightOnSurface = Color(0xFF1A1A1A);
const _lightOnSurfaceVar = Color(0xFF666666);
const _lightOutline = Color(0xFFDDDDE8);
// ── Typography ─────────────────────────────────────────────────────────────────
@@ -41,7 +41,7 @@ ThemeData fabledDarkTheme() {
brightness: Brightness.dark,
primary: _darkPrimary,
onPrimary: Colors.white,
primaryContainer: const Color(0xFF3730A3),
primaryContainer: const Color(0xFF5B21B6),
onPrimaryContainer: _darkOnSurface,
secondary: _darkPrimary,
onSecondary: Colors.white,
@@ -118,7 +118,7 @@ ThemeData fabledLightTheme() {
brightness: Brightness.light,
primary: _lightPrimary,
onPrimary: Colors.white,
primaryContainer: const Color(0xFFE0E0FF),
primaryContainer: const Color(0xFFEDE5FF),
onPrimaryContainer: _lightOnSurface,
secondary: _lightPrimary,
onSecondary: Colors.white,
@@ -218,15 +218,15 @@ class GradientButton extends StatelessWidget {
: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF6366F1), Color(0xFF4F46E5)],
colors: [Color(0xFF7C3AED), Color(0xFF5B21B6)],
),
color: disabled ? const Color(0xFF6366F1) : null,
color: disabled ? const Color(0xFF7C3AED) : null,
borderRadius: BorderRadius.circular(12),
boxShadow: disabled
? null
: [
BoxShadow(
color: const Color(0xFF6366F1).withValues(alpha: 0.35),
color: const Color(0xFF7C3AED).withValues(alpha: 0.45),
blurRadius: 8,
offset: const Offset(0, 3),
),
+16
View File
@@ -70,6 +70,22 @@ class BriefingApi {
}
}
/// POST /api/briefing/articles/{itemId}/discuss body: {"conv_id": convId}
/// Injects the article as context and triggers LLM generation.
/// Returns the assistant_message_id of the generating placeholder.
Future<int> discussArticle(int convId, int itemId) async {
try {
final response = await _dio.post(
'/api/briefing/articles/$itemId/discuss',
data: {'conv_id': convId},
);
final data = response.data as Map<String, dynamic>;
return data['assistant_message_id'] as int;
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// DELETE /api/briefing/rss-reactions/{rssItemId}
Future<void> deleteRssReaction(int rssItemId) async {
try {
+55 -5
View File
@@ -6,6 +6,27 @@ import '../models/conversation.dart';
import '../models/message.dart';
import 'api_client.dart';
sealed class ChatStreamEvent {}
class ChatTextChunk extends ChatStreamEvent {
final String text;
ChatTextChunk(this.text);
}
class ChatStatusUpdate extends ChatStreamEvent {
final String status; // empty string = clear status
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);
@@ -71,8 +92,8 @@ class ChatApi {
}
}
// Step 2: GET the SSE stream and yield text chunks.
Stream<String> streamGeneration(int conversationId) async* {
// Step 2: GET the SSE stream and yield typed events (text chunks + status updates).
Stream<ChatStreamEvent> streamGeneration(int conversationId) async* {
try {
final response = await _dio.get(
'/api/chat/conversations/$conversationId/generation/stream',
@@ -108,14 +129,29 @@ class ChatApi {
if (data == '[DONE]') return;
if (currentEvent == 'done' || currentEvent == 'error') return;
// Parse as JSON if possible, otherwise yield raw text.
if (currentEvent == 'chunk' || currentEvent.isEmpty) {
try {
final obj = json.decode(data) as Map<String, dynamic>;
final text = obj['text'] as String? ?? '';
if (text.isNotEmpty) yield text;
if (text.isNotEmpty) yield ChatTextChunk(text);
} catch (_) {
if (data.isNotEmpty) yield data;
if (data.isNotEmpty) yield ChatTextChunk(data);
}
} else if (currentEvent == 'status') {
try {
final obj = json.decode(data) as Map<String, dynamic>;
final status = obj['status'] as String? ?? '';
yield ChatStatusUpdate(status);
} 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) {
@@ -127,4 +163,18 @@ class ChatApi {
throw dioToApp(e);
}
}
/// POST /api/chat/from-article/{itemId}
/// Creates or retrieves a chat conversation seeded with the article.
/// Returns the conversation_id.
Future<int> openArticleInChat(int itemId) async {
try {
final response =
await _dio.post('/api/chat/from-article/$itemId', data: <String, dynamic>{});
final data = response.data as Map<String, dynamic>;
return data['conversation_id'] as int;
} on DioException catch (e) {
throw dioToApp(e);
}
}
}
+58
View File
@@ -0,0 +1,58 @@
import 'package:dio/dio.dart';
import '../models/calendar_event.dart';
import 'api_client.dart';
class EventsApi {
final Dio _dio;
const EventsApi(this._dio);
/// GET /api/events?from={iso}&to={iso}
Future<List<CalendarEvent>> getEvents(DateTime from, DateTime to) async {
try {
final response = await _dio.get(
'/api/events',
queryParameters: {
'from': from.toUtc().toIso8601String(),
'to': to.toUtc().toIso8601String(),
},
);
final list = response.data as List<dynamic>;
return list
.map((e) => CalendarEvent.fromJson(e as Map<String, dynamic>))
.toList();
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// POST /api/events
Future<CalendarEvent> createEvent(Map<String, dynamic> payload) async {
try {
final response = await _dio.post('/api/events', data: payload);
return CalendarEvent.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// PATCH /api/events/{id}
Future<CalendarEvent> updateEvent(
int id, Map<String, dynamic> fields) async {
try {
final response = await _dio.patch('/api/events/$id', data: fields);
return CalendarEvent.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// DELETE /api/events/{id}
Future<void> deleteEvent(int id) async {
try {
await _dio.delete('/api/events/$id');
} on DioException catch (e) {
throw dioToApp(e);
}
}
}
+52
View File
@@ -0,0 +1,52 @@
import 'package:dio/dio.dart';
import '../models/briefing_feed.dart';
import '../models/news_item.dart';
import 'api_client.dart';
class NewsApi {
final Dio _dio;
const NewsApi(this._dio);
/// GET /api/briefing/news
/// Returns up to [limit] items starting at [offset], optionally filtered by [feedId].
Future<List<NewsItem>> getNewsItems({
int days = 90,
int limit = 40,
int offset = 0,
int? feedId,
}) async {
try {
final params = <String, dynamic>{
'days': days,
'limit': limit,
'offset': offset,
if (feedId != null) 'feed_id': feedId,
};
final response = await _dio.get(
'/api/briefing/news',
queryParameters: params,
);
final data = response.data as Map<String, dynamic>;
final list = data['items'] as List<dynamic>;
return list
.map((e) => NewsItem.fromJson(e as Map<String, dynamic>))
.toList();
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// GET /api/briefing/feeds
Future<List<BriefingFeed>> getFeeds() async {
try {
final response = await _dio.get('/api/briefing/feeds');
final list = response.data as List<dynamic>;
return list
.map((e) => BriefingFeed.fromJson(e as Map<String, dynamic>))
.toList();
} on DioException catch (e) {
throw dioToApp(e);
}
}
}
+12 -7
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,
@@ -40,16 +41,20 @@ class VoiceApi {
}
/// POST WebM/Opus audio bytes 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.
Future<String> transcribe(Uint8List audioBytes) async {
Future<String> transcribe(Uint8List audioBytes, {String? context}) async {
try {
final formData = FormData.fromMap({
final fields = <String, dynamic>{
'audio': MultipartFile.fromBytes(
audioBytes,
filename: 'audio.webm',
contentType: DioMediaType('audio', 'webm'),
filename: 'audio.m4a',
contentType: DioMediaType('audio', 'mp4'),
),
});
if (context != null && context.isNotEmpty) 'context': context,
};
final formData = FormData.fromMap(fields);
final response = await _dio.post(
'/api/voice/transcribe',
data: formData,
+20
View File
@@ -0,0 +1,20 @@
class BriefingFeed {
final int id;
final String title;
final String url;
final String? category;
const BriefingFeed({
required this.id,
required this.title,
required this.url,
this.category,
});
factory BriefingFeed.fromJson(Map<String, dynamic> json) => BriefingFeed(
id: json['id'] as int,
title: json['title'] as String? ?? '',
url: json['url'] as String? ?? '',
category: json['category'] as String?,
);
}
+47
View File
@@ -0,0 +1,47 @@
class CalendarEvent {
final int id;
final String title;
final DateTime startDt;
final DateTime? endDt;
final bool allDay;
final String description;
final String location;
final String color;
final String? recurrence;
final int? projectId;
final int? reminderMinutes;
const CalendarEvent({
required this.id,
required this.title,
required this.startDt,
this.endDt,
required this.allDay,
required this.description,
required this.location,
required this.color,
this.recurrence,
this.projectId,
this.reminderMinutes,
});
factory CalendarEvent.fromJson(Map<String, dynamic> json) => CalendarEvent(
id: json['id'] as int,
title: json['title'] as String? ?? '',
startDt: DateTime.parse(json['start_dt'] as String).toLocal(),
endDt: json['end_dt'] != null
? DateTime.parse(json['end_dt'] as String).toLocal()
: null,
allDay: json['all_day'] as bool? ?? false,
description: json['description'] as String? ?? '',
location: json['location'] as String? ?? '',
color: json['color'] as String? ?? '',
recurrence: json['recurrence'] as String?,
projectId: json['project_id'] as int?,
reminderMinutes: json['reminder_minutes'] as int?,
);
}
/// Strips time from a DateTime, returning midnight local.
/// Used as map keys in CalendarState.eventsByDay.
DateTime dateOnly(DateTime dt) => DateTime(dt.year, dt.month, dt.day);
+18
View File
@@ -1,3 +1,5 @@
import 'task.dart';
class KnowledgeItem {
final int id;
final String noteType; // 'note' | 'person' | 'place' | 'list' | 'task'
@@ -30,6 +32,22 @@ class KnowledgeItem {
required this.updatedAt,
});
factory KnowledgeItem.fromTask(Task task) => KnowledgeItem(
id: task.id,
noteType: 'task',
title: task.title,
body: task.description ?? '',
tags: const [],
projectId: task.projectId,
milestoneId: task.milestoneId,
parentId: task.parentId,
status: task.status.value,
priority: task.priority.value,
dueDate: task.dueDate?.toIso8601String(),
createdAt: task.createdAt,
updatedAt: task.updatedAt,
);
factory KnowledgeItem.fromJson(Map<String, dynamic> json) => KnowledgeItem(
id: json['id'] as int,
noteType: json['note_type'] as String? ?? 'note',
+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,
);
}
+37
View File
@@ -0,0 +1,37 @@
class NewsItem {
final int id;
final String title;
final String url;
final String snippet;
final String source;
final DateTime? publishedAt;
final List<String> topics;
final String? reaction;
const NewsItem({
required this.id,
required this.title,
required this.url,
required this.snippet,
required this.source,
this.publishedAt,
required this.topics,
this.reaction,
});
factory NewsItem.fromJson(Map<String, dynamic> json) => NewsItem(
id: json['id'] as int,
title: json['title'] as String? ?? '',
url: json['url'] as String? ?? '',
snippet: json['snippet'] as String? ?? '',
source: json['source'] as String? ?? '',
publishedAt: json['published_at'] != null
? DateTime.tryParse(json['published_at'] as String)
: null,
topics: (json['topics'] as List<dynamic>?)
?.cast<String>()
.toList() ??
[],
reaction: json['reaction'] as String?,
);
}
+3 -1
View File
@@ -1,4 +1,6 @@
import '../api/chat_api.dart';
export '../api/chat_api.dart'
show ChatStreamEvent, ChatTextChunk, ChatStatusUpdate, ChatToolCall;
import '../models/conversation.dart';
import '../models/message.dart';
@@ -14,6 +16,6 @@ class ChatRepository {
_api.getMessages(conversationId);
Future<void> sendMessage(int conversationId, String content) =>
_api.sendMessage(conversationId, content);
Stream<String> streamGeneration(int conversationId) =>
Stream<ChatStreamEvent> streamGeneration(int conversationId) =>
_api.streamGeneration(conversationId);
}
+2 -1
View File
@@ -7,6 +7,7 @@ class VoiceRepository {
const VoiceRepository(this._api);
Future<VoiceStatus> checkStatus() => _api.checkStatus();
Future<String> transcribe(Uint8List audioBytes) => _api.transcribe(audioBytes);
Future<String> transcribe(Uint8List audioBytes, {String? context}) =>
_api.transcribe(audioBytes, context: context);
Future<Uint8List> synthesise(String text) => _api.synthesise(text);
}
+10
View File
@@ -9,6 +9,8 @@ import '../data/api/chat_api.dart';
import '../data/api/knowledge_api.dart';
import '../data/api/voice_api.dart';
import '../data/api/milestones_api.dart';
import '../data/api/events_api.dart';
import '../data/api/news_api.dart';
import '../data/api/notes_api.dart';
import '../data/api/projects_api.dart';
import '../data/api/quick_capture_api.dart';
@@ -110,3 +112,11 @@ final voiceApiProvider = Provider<VoiceApi>((ref) {
final voiceRepositoryProvider = Provider<VoiceRepository>((ref) {
return VoiceRepository(ref.watch(voiceApiProvider));
});
final newsApiProvider = Provider<NewsApi>((ref) {
return NewsApi(ref.watch(dioProvider));
});
final eventsApiProvider = Provider<EventsApi>((ref) {
return EventsApi(ref.watch(dioProvider));
});
+122 -12
View File
@@ -1,5 +1,8 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/api/chat_api.dart';
import '../data/models/briefing_conversation.dart';
import '../data/models/message.dart';
import 'api_client_provider.dart';
@@ -48,12 +51,79 @@ 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
/// /messages so the backend injects article content before generating.
Future<void> discussArticle(int convId, int itemId) async {
final conv = state.value;
if (conv == null) return;
final chatApi = ref.read(chatApiProvider);
final briefingApi = ref.read(briefingApiProvider);
final previous = conv.messages;
final placeholder = Message(
conversationId: convId,
role: MessageRole.assistant,
content: '',
status: 'generating',
);
state = AsyncData(conv.copyWith(messages: [...previous, placeholder]));
ref.read(isBriefingStreamingProvider.notifier).state = true;
try {
await briefingApi.discussArticle(convId, itemId);
} 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);
}
/// Send a reply to today's briefing conversation.
///
/// Mirrors MessagesNotifier.sendMessage():
/// 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 (best-effort)
/// 3. SSE stream with per-event timeout (stall watchdog)
/// 4. Poll until complete
Future<void> sendReply(String content) async {
final conv = state.value;
@@ -84,28 +154,69 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
rethrow;
}
// SSE stream (best-effort)
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 {
await for (final chunk in chatApi.streamGeneration(convId)) {
streamedContent = true;
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 + chunk);
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));
final fresh = await ref.read(briefingApiProvider).getMessages(convId);
final fresh = await briefingApi.getMessages(convId);
final done = fresh.any(
(m) => m.role == MessageRole.assistant && m.status != 'generating',
);
@@ -119,7 +230,6 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
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;
+162
View File
@@ -0,0 +1,162 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/models/calendar_event.dart';
import 'api_client_provider.dart';
// ─── CalendarState ────────────────────────────────────────────────────────────
class CalendarState {
final Map<DateTime, List<CalendarEvent>> eventsByDay;
final DateTime selectedDay;
final DateTime focusedMonth;
final DateTimeRange loadedRange;
const CalendarState({
required this.eventsByDay,
required this.selectedDay,
required this.focusedMonth,
required this.loadedRange,
});
CalendarState copyWith({
Map<DateTime, List<CalendarEvent>>? eventsByDay,
DateTime? selectedDay,
DateTime? focusedMonth,
DateTimeRange? loadedRange,
}) {
return CalendarState(
eventsByDay: eventsByDay ?? this.eventsByDay,
selectedDay: selectedDay ?? this.selectedDay,
focusedMonth: focusedMonth ?? this.focusedMonth,
loadedRange: loadedRange ?? this.loadedRange,
);
}
}
// ─── CalendarNotifier ─────────────────────────────────────────────────────────
final calendarProvider =
AsyncNotifierProvider<CalendarNotifier, CalendarState>(CalendarNotifier.new);
class CalendarNotifier extends AsyncNotifier<CalendarState> {
@override
Future<CalendarState> build() async {
final now = DateTime.now();
final today = dateOnly(now);
// Fetch current month ± 1 month as the initial window.
final from = DateTime(now.year, now.month - 1, 1);
final to = DateTime(now.year, now.month + 2, 0, 23, 59, 59);
final events = await ref.watch(eventsApiProvider).getEvents(from, to);
return CalendarState(
eventsByDay: _groupByDay(events),
selectedDay: today,
focusedMonth: DateTime(now.year, now.month),
loadedRange: DateTimeRange(start: from, end: to),
);
}
/// Re-fetch events for the current range without clearing state (no flicker).
Future<void> refresh() async {
final current = state.value;
if (current == null) return;
final events = await ref.read(eventsApiProvider).getEvents(
current.loadedRange.start,
current.loadedRange.end,
);
state = AsyncData(current.copyWith(eventsByDay: _groupByDay(events)));
}
/// Synchronously updates selectedDay and focusedMonth. No API call.
void selectDay(DateTime day) {
final current = state.value;
if (current == null) return;
state = AsyncData(current.copyWith(
selectedDay: dateOnly(day),
focusedMonth: DateTime(day.year, day.month),
));
}
/// Updates focusedMonth. Fetches events for [month] if not already loaded.
Future<void> loadMonth(DateTime month) async {
final current = state.value;
if (current == null) return;
final focused = DateTime(month.year, month.month);
// Always update focusedMonth so TableCalendar shows the right page.
state = AsyncData(current.copyWith(focusedMonth: focused));
// Skip fetch if the first day of [month] is within the already-loaded range.
final monthStart = DateTime(month.year, month.month, 1);
if (!monthStart.isBefore(current.loadedRange.start) &&
!monthStart.isAfter(current.loadedRange.end)) {
return;
}
try {
final from = DateTime(month.year, month.month, 1);
final to = DateTime(month.year, month.month + 1, 0, 23, 59, 59);
final events = await ref.read(eventsApiProvider).getEvents(from, to);
final s = state.value!;
final merged = Map<DateTime, List<CalendarEvent>>.from(s.eventsByDay);
for (final e in events) {
final key = dateOnly(e.startDt);
(merged[key] ??= []).add(e);
}
final newStart =
from.isBefore(s.loadedRange.start) ? from : s.loadedRange.start;
final newEnd =
to.isAfter(s.loadedRange.end) ? to : s.loadedRange.end;
state = AsyncData(s.copyWith(
eventsByDay: merged,
loadedRange: DateTimeRange(start: newStart, end: newEnd),
));
} catch (_) {
// Failures are silent — already-loaded months remain visible.
}
}
/// Inserts [event] into eventsByDay after a successful createEvent API call.
void addEvent(CalendarEvent event) {
final current = state.value;
if (current == null) return;
final key = dateOnly(event.startDt);
final updated =
Map<DateTime, List<CalendarEvent>>.from(current.eventsByDay);
(updated[key] ??= []).add(event);
state = AsyncData(current.copyWith(eventsByDay: updated));
}
/// Replaces the old entry for [updated.id] with [updated] after a successful
/// updateEvent API call. Scans all buckets to handle date changes.
void updateEvent(CalendarEvent updated) {
final current = state.value;
if (current == null) return;
final byDay =
Map<DateTime, List<CalendarEvent>>.from(current.eventsByDay);
for (final key in byDay.keys) {
byDay[key] = byDay[key]!.where((e) => e.id != updated.id).toList();
}
final newKey = dateOnly(updated.startDt);
(byDay[newKey] ??= []).add(updated);
state = AsyncData(current.copyWith(eventsByDay: byDay));
}
/// Removes event [id] from [date]'s bucket after a successful deleteEvent
/// API call.
void removeEvent(int id, DateTime date) {
final current = state.value;
if (current == null) return;
final key = dateOnly(date);
final byDay =
Map<DateTime, List<CalendarEvent>>.from(current.eventsByDay);
byDay[key] = (byDay[key] ?? []).where((e) => e.id != id).toList();
state = AsyncData(current.copyWith(eventsByDay: byDay));
}
}
Map<DateTime, List<CalendarEvent>> _groupByDay(List<CalendarEvent> events) {
final byDay = <DateTime, List<CalendarEvent>>{};
for (final e in events) {
final key = dateOnly(e.startDt);
(byDay[key] ??= []).add(e);
}
return byDay;
}
+16 -32
View File
@@ -3,8 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/exceptions.dart';
import 'api_client_provider.dart';
import 'capture_queue_provider.dart';
import 'notes_provider.dart';
import 'tasks_provider.dart';
import 'chat_provider.dart';
/// Outcome of a single capture attempt — consumed by the UI for snackbars.
class CaptureResult {
@@ -25,7 +24,6 @@ class _CaptureResultNotifier extends Notifier<CaptureResult?> {
}
/// In-memory sequential work queue for quick captures.
/// Separate from [captureQueueProvider] (which is the offline persistence queue).
final captureWorkQueueProvider =
NotifierProvider<CaptureWorkQueueNotifier, List<String>>(
CaptureWorkQueueNotifier.new,
@@ -52,31 +50,24 @@ class CaptureWorkQueueNotifier extends Notifier<List<String>> {
// Signal "no result yet" so the same result value can re-trigger watch.
ref.read(captureResultProvider.notifier).state = null;
try {
final api = ref.read(quickCaptureApiProvider);
final result = await api.capture(text);
// Create a new conversation, add it to the conversations list, then
// send the message and kick off generation in the background.
final conv =
await ref.read(conversationsProvider.notifier).create('');
final chatRepo = ref.read(chatRepositoryProvider);
await chatRepo.sendMessage(conv.id, text);
// Fire-and-forget: drain the SSE stream so the server generates a
// response (creating notes/tasks/etc.) without blocking the UI.
chatRepo.streamGeneration(conv.id).drain<void>().ignore();
// Dequeue on success.
state = state.length > 1 ? state.sublist(1) : [];
// Invalidate content providers so lists refresh.
switch (result.type) {
case 'note':
ref.invalidate(notesProvider);
case 'task':
case 'todo':
ref.invalidate(tasksProvider);
}
// Publish result for snackbar.
final msg = result.message.isNotEmpty
? result.message
: '${_typeLabel(result.type)} created: ${result.title}';
ref.read(captureResultProvider.notifier).state = CaptureResult(msg);
ref.read(captureResultProvider.notifier).state =
const CaptureResult('Sent to Fabled.');
} on NetworkException catch (_) {
// Persist to offline queue and stop draining — still offline.
await ref.read(captureQueueProvider.notifier).enqueue(text);
state = state.length > 1 ? state.sublist(1) : [];
ref.read(captureResultProvider.notifier).state = CaptureResult(
ref.read(captureResultProvider.notifier).state = const CaptureResult(
"You're offline — capture saved and will retry automatically.",
);
break;
@@ -86,20 +77,13 @@ class CaptureWorkQueueNotifier extends Notifier<List<String>> {
CaptureResult(e.message, isError: true);
} catch (_) {
state = state.length > 1 ? state.sublist(1) : [];
ref.read(captureResultProvider.notifier).state =
CaptureResult('Capture failed. Please try again.', isError: true);
ref.read(captureResultProvider.notifier).state = const CaptureResult(
'Failed to send. Please try again.',
isError: true);
}
}
} finally {
_running = false;
}
}
String _typeLabel(String type) => switch (type) {
'note' => 'Note',
'task' => 'Task',
'event' => 'Event',
'todo' => 'To-do',
_ => type,
};
}
+202 -6
View File
@@ -1,7 +1,10 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/models/conversation.dart';
import '../data/models/message.dart';
import '../data/repositories/chat_repository.dart';
import 'api_client_provider.dart';
// Separate NotifierProvider.family so UI re-builds immediately when streaming starts/stops.
@@ -18,6 +21,20 @@ class _IsStreamingNotifier extends Notifier<bool> {
bool build() => false;
}
// Tracks the current tool status text during generation (empty = no status).
final streamingStatusProvider =
NotifierProvider.family<_StreamingStatusNotifier, String, int>(
(convId) => _StreamingStatusNotifier(convId),
);
class _StreamingStatusNotifier extends Notifier<String> {
// ignore: avoid_unused_constructor_parameters
_StreamingStatusNotifier(int convId);
@override
String build() => '';
}
final conversationsProvider =
AsyncNotifierProvider<ConversationsNotifier, List<Conversation>>(
ConversationsNotifier.new);
@@ -43,6 +60,12 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
]);
}
/// Re-fetch conversations without clearing the current list (no flicker).
Future<void> refresh() async {
final fresh = await ref.read(chatRepositoryProvider).getConversations();
state = AsyncData(fresh);
}
// Called after a message is sent to patch the server-generated title
// in-place without triggering a full reload or loading state.
void patchConversation(Conversation updated) {
@@ -71,6 +94,144 @@ class MessagesNotifier extends AsyncNotifier<List<Message>> {
return messages;
}
/// 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 {
final convId = _convId;
final repo = ref.read(chatRepositoryProvider);
@@ -101,17 +262,51 @@ 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 chunk in repo.streamGeneration(convId)) {
streamedContent = true;
final msgs = state.requireValue;
if (msgs.isEmpty) continue;
final updated = msgs.last.copyWith(content: msgs.last.content + chunk);
state = AsyncData([...msgs.sublist(0, msgs.length - 1), updated]);
while (await iter.moveNext().timeout(stallTimeout)) {
final event = iter.current;
if (event is ChatTextChunk) {
streamedContent = true;
ref.read(streamingStatusProvider(convId).notifier).state = '';
final msgs = state.requireValue;
if (msgs.isEmpty) continue;
final updated =
msgs.last.copyWith(content: msgs.last.content + event.text);
state = AsyncData([...msgs.sublist(0, msgs.length - 1), updated]);
} 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.
@@ -157,6 +352,7 @@ class MessagesNotifier extends AsyncNotifier<List<Message>> {
}
} finally {
ref.read(isStreamingProvider(convId).notifier).state = false;
ref.read(streamingStatusProvider(convId).notifier).state = '';
}
}
}
+59 -6
View File
@@ -4,6 +4,8 @@ import '../data/models/knowledge_item.dart';
import '../data/repositories/knowledge_repository.dart';
import 'api_client_provider.dart';
export '../data/models/knowledge_item.dart';
/// Immutable state for the Knowledge screen's two-tier paginated feed.
class KnowledgeState {
final List<int> ids;
@@ -113,12 +115,43 @@ class KnowledgeNotifier extends Notifier<KnowledgeState> {
}
Future<void> refresh() async {
state = KnowledgeState(
noteType: state.noteType,
activeTags: state.activeTags,
searchQuery: state.searchQuery,
);
await _fetchFromScratch();
// Keep current items visible during re-fetch (no flicker).
try {
if (state.noteType == 'task') {
final tasks = await ref.read(tasksApiProvider).getAll();
final items = {
for (final t in tasks) t.id: KnowledgeItem.fromTask(t),
};
state = state.copyWith(
ids: tasks.map((t) => t.id).toList(),
items: items,
totalIds: tasks.length,
hasMore: false,
);
await _loadCounts();
return;
}
final (ids, total) = await _repo.fetchIds(
noteType: state.noteType,
tags: state.activeTags,
q: state.searchQuery,
limit: 50,
offset: 0,
);
// Hydrate the new IDs
final batch = await _repo.fetchBatch(ids);
final freshItems = {for (final item in batch) item.id: item};
state = state.copyWith(
ids: ids,
items: freshItems,
totalIds: total,
hasMore: ids.length < total,
);
await Future.wait([_loadCounts(), _loadTags()]);
} catch (_) {
// Silent — stale data is better than an error on refresh
}
}
// ── Scroll-triggered loaders ─────────────────────────────────────────────
@@ -150,6 +183,23 @@ class KnowledgeNotifier extends Notifier<KnowledgeState> {
Future<void> _fetchFromScratch() async {
state = state.copyWith(isLoadingIds: true, error: null);
try {
if (state.noteType == 'task') {
// Tasks live under /api/tasks — fetch all and convert directly.
final tasks = await ref.read(tasksApiProvider).getAll();
final items = {
for (final t in tasks) t.id: KnowledgeItem.fromTask(t),
};
state = state.copyWith(
ids: tasks.map((t) => t.id).toList(),
items: items,
totalIds: tasks.length,
isLoadingIds: false,
hasMore: false,
);
await _loadCounts();
return;
}
final (ids, total) = await _repo.fetchIds(
noteType: state.noteType,
tags: state.activeTags,
@@ -196,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);
}
+177
View File
@@ -0,0 +1,177 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/models/briefing_feed.dart';
import '../data/models/news_item.dart';
import 'api_client_provider.dart';
// ─── NewsState ────────────────────────────────────────────────────────────────
class NewsState {
final List<NewsItem> items;
final int offset;
final bool hasMore;
final bool loadingMore;
final int? selectedFeedId;
final Map<int, String?> reactions;
const NewsState({
required this.items,
required this.offset,
required this.hasMore,
required this.loadingMore,
required this.selectedFeedId,
required this.reactions,
});
NewsState copyWith({
List<NewsItem>? items,
int? offset,
bool? hasMore,
bool? loadingMore,
Object? selectedFeedId = _sentinel,
Map<int, String?>? reactions,
}) {
return NewsState(
items: items ?? this.items,
offset: offset ?? this.offset,
hasMore: hasMore ?? this.hasMore,
loadingMore: loadingMore ?? this.loadingMore,
selectedFeedId: selectedFeedId == _sentinel
? this.selectedFeedId
: selectedFeedId as int?,
reactions: reactions ?? this.reactions,
);
}
}
const _sentinel = Object();
// ─── NewsNotifier ─────────────────────────────────────────────────────────────
final newsProvider =
AsyncNotifierProvider<NewsNotifier, NewsState>(NewsNotifier.new);
class NewsNotifier extends AsyncNotifier<NewsState> {
static const _limit = 40;
@override
Future<NewsState> build() async {
final items = await ref.watch(newsApiProvider).getNewsItems(
days: 90,
limit: _limit,
offset: 0,
);
return NewsState(
items: items,
offset: items.length,
hasMore: items.length == _limit,
loadingMore: false,
selectedFeedId: null,
reactions: {for (final item in items) item.id: item.reaction},
);
}
/// Re-fetch the first page without clearing state (no flicker).
Future<void> refresh() async {
final current = state.value;
final items = await ref.read(newsApiProvider).getNewsItems(
days: 90,
limit: _limit,
offset: 0,
feedId: current?.selectedFeedId,
);
state = AsyncData(NewsState(
items: items,
offset: items.length,
hasMore: items.length == _limit,
loadingMore: false,
selectedFeedId: current?.selectedFeedId,
reactions: {for (final item in items) item.id: item.reaction},
));
}
Future<void> loadMore() async {
final current = state.value;
if (current == null || current.loadingMore || !current.hasMore) return;
state = AsyncData(current.copyWith(loadingMore: true));
try {
final items = await ref.read(newsApiProvider).getNewsItems(
days: 90,
limit: _limit,
offset: current.offset,
feedId: current.selectedFeedId,
);
final updatedReactions = Map<int, String?>.from(current.reactions);
for (final item in items) {
updatedReactions.putIfAbsent(item.id, () => item.reaction);
}
state = AsyncData(current.copyWith(
items: [...current.items, ...items],
offset: current.offset + items.length,
hasMore: items.length == _limit,
loadingMore: false,
reactions: updatedReactions,
));
} catch (e) {
final recovered = state.value ?? current;
state = AsyncData(recovered.copyWith(loadingMore: false));
rethrow;
}
}
Future<void> setFeed(int? feedId) async {
state = const AsyncLoading();
try {
final items = await ref.read(newsApiProvider).getNewsItems(
days: 90,
limit: _limit,
offset: 0,
feedId: feedId,
);
state = AsyncData(NewsState(
items: items,
offset: items.length,
hasMore: items.length == _limit,
loadingMore: false,
selectedFeedId: feedId,
reactions: {for (final item in items) item.id: item.reaction},
));
} catch (e, st) {
state = AsyncError(e, st);
}
}
void toggleReaction(int itemId, String reaction) {
final current = state.value;
if (current == null) return;
final prev = current.reactions[itemId];
final next = prev == reaction ? null : reaction;
state = AsyncData(current.copyWith(
reactions: {...current.reactions, itemId: next},
));
final briefingApi = ref.read(briefingApiProvider);
final future = next == null
? briefingApi.deleteRssReaction(itemId)
: briefingApi.postRssReaction(itemId, next);
future.catchError((_) {
final s = state.value;
if (s != null) {
state = AsyncData(s.copyWith(
reactions: {...s.reactions, itemId: prev},
));
}
});
}
}
// ─── FeedsNotifier ────────────────────────────────────────────────────────────
final feedsProvider =
AsyncNotifierProvider<FeedsNotifier, List<BriefingFeed>>(FeedsNotifier.new);
class FeedsNotifier extends AsyncNotifier<List<BriefingFeed>> {
@override
Future<List<BriefingFeed>> build() async {
return ref.watch(newsApiProvider).getFeeds();
}
}
+6
View File
@@ -15,6 +15,12 @@ class ProjectsNotifier extends AsyncNotifier<List<Project>> {
.getAll(sort: 'updated_at', order: 'desc');
}
Future<void> refresh() async {
final fresh = await ref.read(projectsRepositoryProvider)
.getAll(sort: 'updated_at', order: 'desc');
state = AsyncData(fresh);
}
Future<Project> create({
required String title,
String? description,
+5
View File
@@ -17,6 +17,11 @@ class TasksNotifier extends AsyncNotifier<List<Task>> {
return ref.watch(tasksRepositoryProvider).getAll();
}
Future<void> refresh() async {
final fresh = await ref.read(tasksRepositoryProvider).getAll();
state = AsyncData(fresh);
}
Future<Task> create({
required String title,
String? description,
+89 -26
View File
@@ -59,22 +59,29 @@ class VoiceState {
final VoiceMode mode;
final bool voiceModeActive;
final bool available;
/// Normalized mic amplitude 0.01.0 while recording. Drives the live
/// pulse on VoiceMicButton so the user has obvious feedback that audio
/// is actually being picked up.
final double amplitude;
const VoiceState({
this.mode = VoiceMode.idle,
this.voiceModeActive = false,
this.available = true,
this.amplitude = 0.0,
});
VoiceState copyWith({
VoiceMode? mode,
bool? voiceModeActive,
bool? available,
double? amplitude,
}) =>
VoiceState(
mode: mode ?? this.mode,
voiceModeActive: voiceModeActive ?? this.voiceModeActive,
available: available ?? this.available,
amplitude: amplitude ?? this.amplitude,
);
}
@@ -100,6 +107,7 @@ class VoiceNotifier extends Notifier<VoiceState> {
// Voice mode callbacks
Future<void> Function(String transcript)? _onTranscript;
void Function(String message)? _onError;
bool _enableTts = false;
// Streaming TTS state
@@ -107,6 +115,13 @@ 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.
String _lastAssistantContent = '';
// Empty transcript counter — show feedback after consecutive blanks
int _emptyTranscriptCount = 0;
// TTS playback queue
final _ttsQueue = Queue<Uint8List>();
bool _ttsPlaying = false;
@@ -115,7 +130,6 @@ class VoiceNotifier extends Notifier<VoiceState> {
@override
VoiceState build() {
_recorder = AudioRecorder();
_player = AudioPlayer();
ref.onDispose(() {
_amplitudeSubscription?.cancel();
@@ -141,31 +155,38 @@ class VoiceNotifier extends Notifier<VoiceState> {
return;
}
// Check server availability
// Check server availability — STT is required, TTS is optional.
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;
}
// Downgrade to STT-only when TTS is unavailable
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;
// Re-check after user returns from settings
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);
@@ -184,6 +205,7 @@ class VoiceNotifier extends Notifier<VoiceState> {
_lastSeenLength = 0;
_streamComplete = false;
_onTranscript = null;
_onError = null;
state = const VoiceState();
}
@@ -202,6 +224,7 @@ class VoiceNotifier extends Notifier<VoiceState> {
_dispatchSentences(flush: isComplete);
if (isComplete) {
_lastAssistantContent = fullContent;
_streamComplete = true;
_checkRestartListening();
}
@@ -218,27 +241,59 @@ class VoiceNotifier extends Notifier<VoiceState> {
final dir = _tempDir ?? await getTemporaryDirectory();
final path =
'${dir.path}/voice_rec_${DateTime.now().millisecondsSinceEpoch}.webm';
'${dir.path}/voice_rec_${DateTime.now().millisecondsSinceEpoch}.m4a';
await _recorder!.start(
const RecordConfig(encoder: AudioEncoder.opus, sampleRate: 16000),
path: path,
);
try {
// Recreate the recorder each session — the record package can leave
// the native AudioRecord in a bad state after stop/error cycles,
// and a stale instance is the most common cause of "could not start".
_recorder?.dispose();
_recorder = AudioRecorder();
_amplitudeSubscription?.cancel();
_amplitudeSubscription = _recorder!
.onAmplitudeChanged(const Duration(milliseconds: 200))
.listen(_onAmplitude);
if (!await _recorder!.hasPermission()) {
_onError?.call('Microphone permission was revoked');
exitVoiceMode();
return;
}
await _recorder!.start(
const RecordConfig(encoder: AudioEncoder.aacLc, sampleRate: 16000),
path: path,
);
_amplitudeSubscription?.cancel();
_amplitudeSubscription = _recorder!
.onAmplitudeChanged(const Duration(milliseconds: 200))
.listen(_onAmplitude);
} catch (e) {
_onError?.call('Microphone error: $e');
exitVoiceMode();
}
}
void _onAmplitude(Amplitude event) {
if (!state.voiceModeActive) return;
final db = event.current;
final validDb = !(db.isNaN || db.isInfinite);
// Normalize dB to 0..1 for the pulse animation. -60dB is dead-quiet,
// 0dB is peak; we clamp and bias so the button visibly breathes even
// on soft speech without exploding on loud input.
if (validDb) {
final norm = ((db + 60.0) / 60.0).clamp(0.0, 1.0);
if ((norm - state.amplitude).abs() > 0.02) {
state = state.copyWith(amplitude: norm);
}
}
final elapsed =
DateTime.now().millisecondsSinceEpoch - _recordingStartMs;
if (elapsed < _minRecordingMs) return;
if (event.current < _silenceThresholdDb) {
final isSilent = !validDb || db < _silenceThresholdDb;
if (isSilent) {
_silenceMs += 200;
if (_silenceMs >= _silenceDurationMs) {
_amplitudeSubscription?.cancel();
@@ -263,16 +318,24 @@ class VoiceNotifier extends Notifier<VoiceState> {
if (!state.voiceModeActive) return;
final transcript =
await ref.read(voiceRepositoryProvider).transcribe(bytes);
final transcript = await ref.read(voiceRepositoryProvider).transcribe(
bytes,
context: _lastAssistantContent.isNotEmpty ? _lastAssistantContent : null,
);
if (!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');
exitVoiceMode();
return;
}
await _startListening();
return;
}
_emptyTranscriptCount = 0;
// Reset TTS state for this new turn
_sentenceBuffer = '';
@@ -289,8 +352,8 @@ class VoiceNotifier extends Notifier<VoiceState> {
if (!_enableTts && state.voiceModeActive) {
await _startListening();
}
} catch (_) {
// Network/API error — exit voice mode
} catch (e) {
_onError?.call('Voice error: transcription failed');
exitVoiceMode();
}
}
+57 -7
View File
@@ -41,7 +41,14 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
final wasBackground = !_appInForeground;
_appInForeground = state == AppLifecycleState.resumed;
// On resume, force a message refresh. This also unfreezes a stuck
// streaming state if the SSE socket died while the app was backgrounded
// — silentRefresh won't do that because it guards on isStreaming.
if (_appInForeground && wasBackground && mounted) {
ref.read(briefingProvider.notifier).refreshMessages();
}
}
void _pollSilently() {
@@ -51,6 +58,18 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
ref.read(briefingProvider.notifier).silentRefresh();
}
Future<void> _pullToRefresh() async {
try {
await ref.read(briefingProvider.notifier).refreshMessages();
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not refresh.')),
);
}
}
}
@override
void dispose() {
_pollTimer?.cancel();
@@ -93,6 +112,23 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
}
}
Future<void> _handleDiscuss(int convId, int itemId) async {
try {
await ref.read(briefingProvider.notifier).discussArticle(convId, itemId);
} on AppException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(e.message)));
}
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to start discussion.')),
);
}
}
}
Future<void> _handleReaction(int itemId, String reaction) async {
final current = _reactions[itemId];
final next = current == reaction ? null : reaction;
@@ -233,9 +269,14 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
return Column(
children: [
Expanded(
child: CustomScrollView(
controller: _scrollController,
slivers: [
child: RefreshIndicator(
onRefresh: _pullToRefresh,
child: CustomScrollView(
controller: _scrollController,
// AlwaysScrollable so pull-to-refresh fires even when
// the briefing is empty or shorter than the viewport.
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
if (conv.messages.isEmpty)
SliverFillRemaining(
child: Center(
@@ -268,13 +309,16 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
final msg = conv.messages[i];
return _BriefingMessageItem(
message: msg,
convId: conv.id,
reactions: _reactions,
onReaction: _handleReaction,
onDiscuss: _handleDiscuss,
);
},
),
),
],
],
),
),
),
@@ -332,6 +376,7 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
VoiceMicButton(
mode: voiceState.mode,
voiceModeActive: voiceState.voiceModeActive,
amplitude: voiceState.amplitude,
onTap: _toggleVoiceMode,
),
const SizedBox(width: 6),
@@ -370,13 +415,17 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
/// and RSS reaction buttons below it (for assistant messages with metadata).
class _BriefingMessageItem extends StatelessWidget {
final Message message;
final int convId;
final Map<int, String?> reactions;
final void Function(int itemId, String reaction) onReaction;
final void Function(int convId, int itemId) onDiscuss;
const _BriefingMessageItem({
required this.message,
required this.convId,
required this.reactions,
required this.onReaction,
required this.onDiscuss,
});
@override
@@ -388,11 +437,11 @@ 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
// 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).toList();
final rssItems = rssItemsRaw.map(RssItemMeta.fromJson).take(3).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -408,6 +457,7 @@ class _BriefingMessageItem extends StatelessWidget {
item: item,
reaction: reactions[item.id],
onReaction: onReaction,
onDiscuss: () => onDiscuss(convId, item.id),
)).toList(),
),
),
@@ -438,7 +488,7 @@ class _GradientSendButton extends StatelessWidget {
: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF6366F1), Color(0xFF4F46E5)],
colors: [Color(0xFF7C3AED), Color(0xFF5B21B6)],
),
color: disabled ? scheme.onSurface.withValues(alpha: 0.12) : null,
borderRadius: BorderRadius.circular(10),
+173
View File
@@ -0,0 +1,173 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:table_calendar/table_calendar.dart';
import '../../data/models/calendar_event.dart';
import '../../providers/calendar_provider.dart';
import 'event_form_sheet.dart';
class CalendarScreen extends ConsumerWidget {
const CalendarScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final calAsync = ref.watch(calendarProvider);
final notifier = ref.read(calendarProvider.notifier);
return Scaffold(
appBar: AppBar(title: const Text('Calendar')),
body: calAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Could not load calendar.'),
const SizedBox(height: 12),
FilledButton.tonal(
onPressed: () => ref.invalidate(calendarProvider),
child: const Text('Retry'),
),
],
),
),
data: (cal) => Column(
children: [
TableCalendar<CalendarEvent>(
firstDay: DateTime(2020),
lastDay: DateTime(2030),
focusedDay: cal.focusedMonth,
selectedDayPredicate: (day) => isSameDay(day, cal.selectedDay),
eventLoader: (day) =>
cal.eventsByDay[dateOnly(day)] ?? [],
calendarFormat: CalendarFormat.month,
headerStyle: const HeaderStyle(
formatButtonVisible: false,
titleCentered: true,
),
calendarStyle: CalendarStyle(
selectedDecoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
),
todayDecoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.3),
shape: BoxShape.circle,
),
markerDecoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondary,
shape: BoxShape.circle,
),
),
onDaySelected: (selected, _) => notifier.selectDay(selected),
onPageChanged: (focused) => notifier.loadMonth(focused),
),
const Divider(height: 1),
Expanded(
child: RefreshIndicator(
onRefresh: () => ref.read(calendarProvider.notifier).refresh(),
child: _AgendaList(
events:
cal.eventsByDay[dateOnly(cal.selectedDay)] ?? [],
notifier: notifier,
),
),
),
],
),
),
floatingActionButton: calAsync.hasValue
? FloatingActionButton(
onPressed: () => showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (_) => EventFormSheet(
event: null,
initialDate: calAsync.value!.selectedDay,
notifier: notifier,
),
),
child: const Icon(Icons.add),
)
: null,
);
}
}
// ── Agenda list ───────────────────────────────────────────────────────────────
class _AgendaList extends StatelessWidget {
final List<CalendarEvent> events;
final CalendarNotifier notifier;
const _AgendaList({required this.events, required this.notifier});
@override
Widget build(BuildContext context) {
if (events.isEmpty) {
return ListView(
children: const [
SizedBox(height: 80),
Center(child: Text('No events')),
],
);
}
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: events.length,
itemBuilder: (_, i) =>
_EventTile(event: events[i], notifier: notifier),
);
}
}
// ── Event tile ────────────────────────────────────────────────────────────────
class _EventTile extends StatelessWidget {
final CalendarEvent event;
final CalendarNotifier notifier;
const _EventTile({required this.event, required this.notifier});
Color _dotColor(BuildContext context) {
if (event.color.isEmpty) return Theme.of(context).colorScheme.primary;
try {
return Color(int.parse(event.color.replaceFirst('#', '0xFF')));
} catch (_) {
return Theme.of(context).colorScheme.primary;
}
}
String _timeLabel() {
if (event.allDay) return 'All day';
final h = event.startDt.hour.toString().padLeft(2, '0');
final m = event.startDt.minute.toString().padLeft(2, '0');
return '$h:$m';
}
@override
Widget build(BuildContext context) {
return ListTile(
leading: CircleAvatar(
radius: 6,
backgroundColor: _dotColor(context),
),
title: Text(event.title),
subtitle: Text(_timeLabel()),
onTap: () => showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (_) => EventFormSheet(
event: event,
initialDate: null,
notifier: notifier,
),
),
);
}
}
+473
View File
@@ -0,0 +1,473 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/models/calendar_event.dart';
import '../../providers/api_client_provider.dart';
import '../../providers/calendar_provider.dart';
class EventFormSheet extends ConsumerStatefulWidget {
/// null = create mode; non-null = edit mode.
final CalendarEvent? event;
/// Pre-fills start date in create mode. Ignored in edit mode.
final DateTime? initialDate;
final CalendarNotifier notifier;
const EventFormSheet({
super.key,
required this.event,
required this.initialDate,
required this.notifier,
});
@override
ConsumerState<EventFormSheet> createState() => _EventFormSheetState();
}
class _EventFormSheetState extends ConsumerState<EventFormSheet> {
late final TextEditingController _titleCtrl;
late final TextEditingController _descCtrl;
late final TextEditingController _locationCtrl;
late DateTime _startDt;
DateTime? _endDt;
late bool _allDay;
String? _recurrence; // null = None; one of the FREQ= strings otherwise
String _color = '';
bool _saving = false;
bool get _isCreate => widget.event == null;
/// Maps RRULE string (or null) to display label for the dropdown.
static const Map<String?, String> _knownRrules = {
null: 'None',
'FREQ=DAILY': 'Daily',
'FREQ=WEEKLY': 'Weekly',
'FREQ=MONTHLY': 'Monthly',
'FREQ=YEARLY': 'Yearly',
};
/// True when editing an event whose RRULE is not one of the 5 known patterns.
bool get _isCustomRrule =>
widget.event?.recurrence != null &&
!_knownRrules.containsKey(widget.event!.recurrence);
static const List<String> _presetColors = [
'#EF4444',
'#F59E0B',
'#10B981',
'#7C3AED',
'#8B5CF6',
'#EC4899',
];
@override
void initState() {
super.initState();
final e = widget.event;
_titleCtrl = TextEditingController(text: e?.title ?? '');
_descCtrl = TextEditingController(text: e?.description ?? '');
_locationCtrl = TextEditingController(text: e?.location ?? '');
if (e != null) {
_startDt = e.startDt.toLocal();
_endDt = e.endDt?.toLocal();
_allDay = e.allDay;
// Custom RRULEs are displayed as read-only; leave _recurrence = null.
_recurrence = _isCustomRrule ? null : e.recurrence;
_color = e.color;
} else {
final base = widget.initialDate ?? DateTime.now();
final now = DateTime.now();
final hour = now.minute >= 30 ? (now.hour + 1) % 24 : now.hour;
_startDt = DateTime(base.year, base.month, base.day, hour);
_allDay = false;
_recurrence = null;
}
}
@override
void dispose() {
_titleCtrl.dispose();
_descCtrl.dispose();
_locationCtrl.dispose();
super.dispose();
}
// ── Save ──────────────────────────────────────────────────────────────────
Future<void> _save() async {
if (_titleCtrl.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Title is required.')),
);
return;
}
setState(() => _saving = true);
try {
// Preserve unrecognised RRULEs unchanged on save.
final rrule = _isCustomRrule ? widget.event!.recurrence : _recurrence;
final payload = <String, dynamic>{
'title': _titleCtrl.text.trim(),
'start_dt': _startDt.toUtc().toIso8601String(),
if (_endDt != null) 'end_dt': _endDt!.toUtc().toIso8601String(),
'all_day': _allDay,
'description': _descCtrl.text.trim(),
'location': _locationCtrl.text.trim(),
'color': _color,
'recurrence': rrule,
};
final api = ref.read(eventsApiProvider);
if (_isCreate) {
final created = await api.createEvent(payload);
widget.notifier.addEvent(created);
} else {
final updated = await api.updateEvent(widget.event!.id, payload);
widget.notifier.updateEvent(updated);
}
if (mounted) Navigator.of(context).pop();
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to save event.')),
);
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
// ── Delete ────────────────────────────────────────────────────────────────
Future<void> _delete() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Delete this event?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: Text(
'Delete',
style: TextStyle(
color: Theme.of(dialogContext).colorScheme.error),
),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _saving = true);
try {
await ref.read(eventsApiProvider).deleteEvent(widget.event!.id);
widget.notifier.removeEvent(widget.event!.id, widget.event!.startDt);
if (mounted) Navigator.of(context).pop();
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to delete event.')),
);
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
// ── Date/time pickers ─────────────────────────────────────────────────────
Future<void> _pickStartDate() async {
final d = await showDatePicker(
context: context,
initialDate: _startDt,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (d != null) {
setState(() {
_startDt = DateTime(
d.year, d.month, d.day, _startDt.hour, _startDt.minute);
});
}
}
Future<void> _pickStartTime() async {
final t = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(_startDt),
);
if (t != null) {
setState(() {
_startDt = DateTime(
_startDt.year, _startDt.month, _startDt.day, t.hour, t.minute);
});
}
}
Future<void> _pickEndDate() async {
final d = await showDatePicker(
context: context,
initialDate: _endDt ?? _startDt,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (d != null) {
setState(() {
final prev = _endDt ?? _startDt;
_endDt =
DateTime(d.year, d.month, d.day, prev.hour, prev.minute);
});
}
}
Future<void> _pickEndTime() async {
final t = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(_endDt ?? _startDt),
);
if (t != null) {
setState(() {
final prev = _endDt ?? _startDt;
_endDt = DateTime(
prev.year, prev.month, prev.day, t.hour, t.minute);
});
}
}
// ── Formatting helpers ────────────────────────────────────────────────────
String _fmtDate(DateTime dt) {
const months = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
];
return '${months[dt.month - 1]} ${dt.day}, ${dt.year}';
}
String _fmtTime(DateTime dt) =>
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
// ── Build ─────────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
return Padding(
padding:
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Text(
_isCreate ? 'New Event' : 'Edit Event',
style: Theme.of(context).textTheme.titleLarge,
),
const Spacer(),
if (!_isCreate)
IconButton(
icon: const Icon(Icons.delete_outline),
color: Theme.of(context).colorScheme.error,
onPressed: _saving ? null : _delete,
),
],
),
const SizedBox(height: 16),
// Title
TextField(
controller: _titleCtrl,
decoration: const InputDecoration(
labelText: 'Title',
border: OutlineInputBorder(),
),
textCapitalization: TextCapitalization.sentences,
),
const SizedBox(height: 12),
// All-day toggle
SwitchListTile(
title: const Text('All day'),
value: _allDay,
onChanged: (v) => setState(() => _allDay = v),
contentPadding: EdgeInsets.zero,
),
// Start date
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.calendar_today_outlined),
title: Text(_fmtDate(_startDt)),
subtitle: const Text('Start date'),
onTap: _pickStartDate,
),
// Start time (hidden when all-day)
if (!_allDay)
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.access_time_outlined),
title: Text(_fmtTime(_startDt)),
subtitle: const Text('Start time'),
onTap: _pickStartTime,
),
// End date
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.event_outlined),
title: Text(
_endDt != null ? _fmtDate(_endDt!) : 'No end date'),
subtitle: const Text('End date'),
onTap: _pickEndDate,
trailing: _endDt != null
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () => setState(() => _endDt = null),
)
: null,
),
// End time (hidden when all-day or no end date)
if (!_allDay && _endDt != null)
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.access_time_outlined),
title: Text(_fmtTime(_endDt!)),
subtitle: const Text('End time'),
onTap: _pickEndTime,
),
const SizedBox(height: 8),
// Repeat — show read-only tile for custom RRULEs
if (_isCustomRrule)
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.repeat_outlined),
title: const Text('Custom (read-only)'),
subtitle: Text(widget.event!.recurrence ?? ''),
)
else
Row(
children: [
const Icon(Icons.repeat_outlined),
const SizedBox(width: 16),
DropdownButton<String?>(
value: _recurrence,
underline: const SizedBox.shrink(),
items: _knownRrules.entries
.map((entry) => DropdownMenuItem<String?>(
value: entry.key,
child: Text(entry.value),
))
.toList(),
onChanged: (v) => setState(() => _recurrence = v),
),
],
),
const SizedBox(height: 12),
// Description
TextField(
controller: _descCtrl,
decoration: const InputDecoration(
labelText: 'Description',
border: OutlineInputBorder(),
),
maxLines: 3,
textCapitalization: TextCapitalization.sentences,
),
const SizedBox(height: 12),
// Location
TextField(
controller: _locationCtrl,
decoration: const InputDecoration(
labelText: 'Location',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
// Color chips
Text('Color', style: Theme.of(context).textTheme.labelMedium),
const SizedBox(height: 8),
Row(
children: [
// "No color" chip
GestureDetector(
onTap: () => setState(() => _color = ''),
child: Container(
width: 32,
height: 32,
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: _color.isEmpty
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.outline,
width: _color.isEmpty ? 2 : 1,
),
),
child: const Icon(Icons.block, size: 16),
),
),
..._presetColors.map((hex) {
final c =
Color(int.parse(hex.replaceFirst('#', '0xFF')));
return GestureDetector(
onTap: () => setState(() => _color = hex),
child: Container(
width: 32,
height: 32,
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
color: c,
shape: BoxShape.circle,
border: _color == hex
? Border.all(
color: Theme.of(context)
.colorScheme
.primary,
width: 2,
)
: null,
),
),
);
}),
],
),
const SizedBox(height: 24),
// Save button
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(_isCreate ? 'Create' : 'Save'),
),
),
],
),
),
);
}
}
+87 -12
View File
@@ -16,12 +16,56 @@ class ChatScreen extends ConsumerStatefulWidget {
ConsumerState<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends ConsumerState<ChatScreen> {
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
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
ref.read(messagesProvider(widget.conversationId).notifier).refresh();
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_controller.dispose();
_scrollController.dispose();
// Exit voice mode if the user navigates away mid-session.
@@ -89,6 +133,8 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
Widget build(BuildContext context) {
final messagesAsync = ref.watch(messagesProvider(widget.conversationId));
final isStreaming = ref.watch(isStreamingProvider(widget.conversationId));
final streamingStatus =
ref.watch(streamingStatusProvider(widget.conversationId));
final voiceState = ref.watch(voiceProvider);
// Scroll when messages change.
@@ -119,6 +165,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: [
@@ -130,17 +189,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]),
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
: '',
),
),
);
},
),
@@ -193,6 +267,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
VoiceMicButton(
mode: voiceState.mode,
voiceModeActive: voiceState.voiceModeActive,
amplitude: voiceState.amplitude,
onTap: _toggleVoiceMode,
),
const SizedBox(width: 6),
+10 -8
View File
@@ -23,7 +23,7 @@ class ConversationsTabScreen extends ConsumerWidget {
onPressed: () async {
final conv = await ref
.read(conversationsProvider.notifier)
.create('New conversation');
.create('');
if (context.mounted) {
context.push(Routes.chat.replaceFirst(':id', '${conv.id}'));
}
@@ -52,7 +52,7 @@ class ConversationsTabScreen extends ConsumerWidget {
onPressed: () async {
final conv = await ref
.read(conversationsProvider.notifier)
.create('New conversation');
.create('');
if (context.mounted) {
context.push(
Routes.chat.replaceFirst(':id', '${conv.id}'));
@@ -64,15 +64,17 @@ class ConversationsTabScreen extends ConsumerWidget {
);
}
return RefreshIndicator(
onRefresh: () async => ref.invalidate(conversationsProvider),
onRefresh: () => ref.read(conversationsProvider.notifier).refresh(),
child: ListView.builder(
itemCount: convs.length,
itemBuilder: (ctx, i) {
final c = convs[i];
return ListTile(
leading: const Icon(Icons.chat_bubble_outline),
title: Text(c.title,
maxLines: 1, overflow: TextOverflow.ellipsis),
title: Text(
c.title.isEmpty ? 'New conversation' : c.title,
maxLines: 1,
overflow: TextOverflow.ellipsis),
subtitle: Text(
_relativeTime(c.updatedAt),
style: theme.textTheme.labelSmall,
@@ -97,15 +99,15 @@ class ConversationsTabScreen extends ConsumerWidget {
BuildContext context, WidgetRef ref, int id, String title) async {
final ok = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
builder: (dialogContext) => AlertDialog(
title: const Text('Delete conversation?'),
content: Text('"$title" will be permanently deleted.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel')),
FilledButton(
onPressed: () => Navigator.pop(context, true),
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Delete')),
],
),
+2 -10
View File
@@ -74,15 +74,7 @@ class _KnowledgeScreenState extends ConsumerState<KnowledgeScreen>
});
}
String _tabLabel(int index, Map<String, int> counts) {
final tab = _kTabs[index];
if (tab.type == null) {
final total = counts.values.fold(0, (a, b) => a + b);
return total > 0 ? 'All ($total)' : 'All';
}
final count = counts[tab.type];
return count != null && count > 0 ? '${tab.label} ($count)' : tab.label;
}
String _tabLabel(int index) => _kTabs[index].label;
@override
Widget build(BuildContext context) {
@@ -119,7 +111,7 @@ class _KnowledgeScreenState extends ConsumerState<KnowledgeScreen>
tabAlignment: TabAlignment.start,
tabs: List.generate(
_kTabs.length,
(i) => Tab(text: _tabLabel(i, state.counts)),
(i) => Tab(text: _tabLabel(i)),
),
),
),
+17 -6
View File
@@ -48,12 +48,18 @@ class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
}
}
void _openTask(int taskId) {
context
.push(Routes.taskEdit.replaceFirst(':id', '$taskId'))
.then((_) => ref.invalidate(projectTasksProvider(widget.projectId)));
}
Color _parseColor(String? hex) {
if (hex == null || hex.isEmpty) return const Color(0xFF6366F1);
if (hex == null || hex.isEmpty) return const Color(0xFF7C3AED);
try {
return Color(int.parse(hex.replaceFirst('#', '0xFF')));
} catch (_) {
return const Color(0xFF6366F1);
return const Color(0xFF7C3AED);
}
}
@@ -145,8 +151,10 @@ class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
return RefreshIndicator(
onRefresh: () async {
setState(() => _pendingStatus.clear());
ref.invalidate(projectTasksProvider(widget.projectId));
ref.invalidate(projectMilestonesProvider(widget.projectId));
await Future.wait([
ref.refresh(projectTasksProvider(widget.projectId).future),
ref.refresh(projectMilestonesProvider(widget.projectId).future),
]);
},
child: CustomScrollView(
slivers: [
@@ -171,6 +179,7 @@ class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
task: task,
effectiveStatus: _effectiveStatus(task),
onStatusTap: () => _cycleStatus(task),
onTap: () => _openTask(task.id),
);
},
),
@@ -198,6 +207,7 @@ class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
task: task,
effectiveStatus: _effectiveStatus(task),
onStatusTap: () => _cycleStatus(task),
onTap: () => _openTask(task.id),
);
},
),
@@ -282,11 +292,13 @@ class _TaskRow extends StatelessWidget {
final Task task;
final TaskStatus effectiveStatus;
final VoidCallback onStatusTap;
final VoidCallback onTap;
const _TaskRow({
required this.task,
required this.effectiveStatus,
required this.onStatusTap,
required this.onTap,
});
IconData get _statusIcon => switch (effectiveStatus) {
@@ -317,8 +329,7 @@ class _TaskRow extends StatelessWidget {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 3),
child: InkWell(
onTap: () => context
.push(Routes.taskEdit.replaceFirst(':id', '${task.id}')),
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.fromLTRB(4, 6, 14, 6),
+187
View File
@@ -0,0 +1,187 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/constants.dart';
import '../../data/models/briefing_feed.dart';
import '../../providers/api_client_provider.dart';
import '../../providers/news_provider.dart';
import '../../widgets/news_card.dart';
class NewsScreen extends ConsumerStatefulWidget {
const NewsScreen({super.key});
@override
ConsumerState<NewsScreen> createState() => _NewsScreenState();
}
class _NewsScreenState extends ConsumerState<NewsScreen> {
final Set<int> _openingChat = {};
Future<void> _handleDiscuss(int itemId) async {
if (_openingChat.contains(itemId)) return;
setState(() => _openingChat.add(itemId));
try {
final conversationId =
await ref.read(chatApiProvider).openArticleInChat(itemId);
if (mounted) {
context.push(Routes.chat.replaceFirst(':id', '$conversationId'));
}
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to open article in chat.')),
);
}
} finally {
if (mounted) setState(() => _openingChat.remove(itemId));
}
}
Future<void> _loadMore() async {
try {
await ref.read(newsProvider.notifier).loadMore();
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to load more articles.')),
);
}
}
}
@override
Widget build(BuildContext context) {
final newsAsync = ref.watch(newsProvider);
final feedsAsync = ref.watch(feedsProvider);
final scheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('News', style: Theme.of(context).textTheme.titleLarge),
Text(
'Last 90 days',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
),
),
body: newsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Could not load news.'),
const SizedBox(height: 12),
FilledButton.tonal(
onPressed: () => ref.invalidate(newsProvider),
child: const Text('Retry'),
),
],
),
),
data: (news) => Column(
children: [
_FeedFilter(
feeds: feedsAsync.value ?? [],
selectedFeedId: news.selectedFeedId,
onChanged: (feedId) =>
ref.read(newsProvider.notifier).setFeed(feedId),
),
const Divider(height: 1),
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'),
),
),
);
}
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),
);
},
),
),
),
],
),
),
);
}
}
class _FeedFilter extends StatelessWidget {
final List<BriefingFeed> feeds;
final int? selectedFeedId;
final void Function(int? feedId) onChanged;
const _FeedFilter({
required this.feeds,
required this.selectedFeedId,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 6),
child: Row(
children: [
Text(
'Feed:',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: 8),
DropdownButton<int?>(
value: selectedFeedId,
underline: const SizedBox.shrink(),
items: [
const DropdownMenuItem<int?>(
value: null,
child: Text('All feeds'),
),
...feeds.map(
(f) => DropdownMenuItem<int?>(
value: f.id,
child: Text(f.title),
),
),
],
onChanged: (v) => onChanged(v),
),
],
),
);
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ class ProjectsScreen extends ConsumerWidget {
data: (projects) => projects.isEmpty
? const Center(child: Text('No projects yet.'))
: RefreshIndicator(
onRefresh: () async => ref.invalidate(projectsProvider),
onRefresh: () => ref.read(projectsProvider.notifier).refresh(),
child: ListView.separated(
itemCount: projects.length,
separatorBuilder: (_, _) => const Divider(height: 1),
+106 -22
View File
@@ -4,16 +4,28 @@ import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import '../data/models/message.dart';
import 'tool_call_chip.dart';
class ChatMessageBubble extends StatelessWidget {
final Message message;
const ChatMessageBubble({super.key, required this.message});
final String streamingStatus;
const ChatMessageBubble({
super.key,
required this.message,
this.streamingStatus = '',
});
@override
Widget build(BuildContext context) {
final isUser = message.role == MessageRole.user;
final scheme = Theme.of(context).colorScheme;
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,
@@ -24,7 +36,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),
@@ -38,7 +49,6 @@ class ChatMessageBubble extends StatelessWidget {
),
)
: BoxDecoration(
// Assistant: elevated surface + left accent border
color: scheme.surfaceContainerHighest,
border: Border(
left: BorderSide(color: scheme.primary, width: 2),
@@ -52,28 +62,102 @@ class ChatMessageBubble extends StatelessWidget {
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: isGenerating && message.content.isEmpty
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: scheme.onSurfaceVariant,
),
)
: MarkdownBody(
data: message.content.isEmpty ? '' : message.content,
styleSheet: MarkdownStyleSheet(
p: TextStyle(
color: isUser
? scheme.onSurface.withValues(alpha: 0.75)
: scheme.onSurface,
fontSize: 14,
),
),
child: showSpinnerOnly
? _buildSpinner(scheme)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// 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),
],
),
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,
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,
),
),
),
],
],
);
}
}
+54 -6
View File
@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../data/models/news_item.dart';
class RssItemMeta {
final int id;
final String title;
@@ -29,6 +31,15 @@ class RssItemMeta {
: null,
);
factory RssItemMeta.fromNewsItem(NewsItem item) => RssItemMeta(
id: item.id,
title: item.title,
url: item.url,
source: item.source,
snippet: item.snippet,
publishedAt: item.publishedAt,
);
String get relativeDate {
if (publishedAt == null) return '';
final diff = DateTime.now().difference(publishedAt!);
@@ -42,12 +53,14 @@ class NewsCard extends StatelessWidget {
final RssItemMeta item;
final String? reaction; // 'up' | 'down' | null
final void Function(int itemId, String reaction) onReaction;
final VoidCallback? onDiscuss;
const NewsCard({
super.key,
required this.item,
required this.reaction,
required this.onReaction,
this.onDiscuss,
});
Future<void> _openUrl() async {
@@ -100,16 +113,20 @@ class NewsCard extends StatelessWidget {
],
),
const SizedBox(height: 4),
// Title — tappable if URL present
// Title — tappable if URL present. Text stays in onSurface for
// contrast; the primary-colored underline carries the "this is a
// link" signal without tanking readability.
GestureDetector(
onTap: item.url.isNotEmpty ? _openUrl : null,
child: Text(
item.title,
style: textTheme.bodyMedium?.copyWith(
style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: item.url.isNotEmpty ? scheme.primary : scheme.onSurface,
color: scheme.onSurface,
height: 1.3,
decoration: item.url.isNotEmpty ? TextDecoration.underline : null,
decorationColor: scheme.primary,
decorationColor: scheme.primary.withValues(alpha: 0.7),
decorationThickness: 1.5,
),
),
),
@@ -127,9 +144,8 @@ class NewsCard extends StatelessWidget {
),
],
const SizedBox(height: 8),
// Reaction buttons
// Actions row: reactions + discuss
Row(
mainAxisSize: MainAxisSize.min,
children: [
_ReactionButton(
emoji: '👍',
@@ -142,6 +158,10 @@ class NewsCard extends StatelessWidget {
active: reaction == 'down',
onTap: () => onReaction(item.id, 'down'),
),
if (onDiscuss != null) ...[
const Spacer(),
_DiscussButton(onTap: onDiscuss!),
],
],
),
],
@@ -151,6 +171,34 @@ class NewsCard extends StatelessWidget {
}
}
class _DiscussButton extends StatelessWidget {
final VoidCallback onTap;
const _DiscussButton({required this.onTap});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3),
decoration: BoxDecoration(
border: Border.all(color: scheme.primary.withValues(alpha: 0.5)),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'Discuss',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: scheme.primary,
),
),
),
);
}
}
class _ReactionButton extends StatelessWidget {
final String emoji;
final bool active;
+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,
);
}
}
@@ -8,6 +8,7 @@
#include <flutter_timezone/flutter_timezone_plugin.h>
#include <open_file_linux/open_file_linux_plugin.h>
#include <record_linux/record_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
@@ -17,6 +18,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) open_file_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "OpenFileLinuxPlugin");
open_file_linux_plugin_register_with_registrar(open_file_linux_registrar);
g_autoptr(FlPluginRegistrar) record_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin");
record_linux_plugin_register_with_registrar(record_linux_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
+1
View File
@@ -5,6 +5,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
flutter_timezone
open_file_linux
record_linux
url_launcher_linux
)
@@ -5,18 +5,24 @@
import FlutterMacOS
import Foundation
import audio_session
import flutter_inappwebview_macos
import flutter_timezone
import just_audio
import open_file_mac
import package_info_plus
import record_darwin
import shared_preferences_foundation
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin"))
FlutterTimezonePlugin.register(with: registry.registrar(forPlugin: "FlutterTimezonePlugin"))
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"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
}
+24
View File
@@ -408,6 +408,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.8.0"
intl:
dependency: transitive
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
source: hosted
version: "0.20.2"
io:
dependency: transitive
description:
@@ -952,6 +960,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.0"
simple_gesture_detector:
dependency: transitive
description:
name: simple_gesture_detector
sha256: ba2cd5af24ff20a0b8d609cec3f40e5b0744d2a71804a2616ae086b9c19d19a3
url: "https://pub.dev"
source: hosted
version: "0.2.1"
sky_engine:
dependency: transitive
description: flutter
@@ -1021,6 +1037,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.4.0"
table_calendar:
dependency: "direct main"
description:
name: table_calendar
sha256: "0c0c6219878b363a2d5f40c7afb159d845f253d061dc3c822aa0d5fe0f721982"
url: "https://pub.dev"
source: hosted
version: "3.2.0"
term_glyph:
dependency: transitive
description:
+1
View File
@@ -30,6 +30,7 @@ dependencies:
url_launcher: ^6.3.1
record: ^5.0.0
just_audio: ^0.9.39
table_calendar: ^3.1.2
dependency_overrides:
record_linux: ^1.3.0
+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"
+130
View File
@@ -1,7 +1,10 @@
import 'package:fabled_app/data/models/calendar_event.dart';
import 'package:fabled_app/data/api/voice_api.dart';
import 'package:fabled_app/providers/voice_provider.dart';
import 'package:fabled_app/data/models/knowledge_item.dart';
import 'package:fabled_app/data/models/note.dart';
import 'package:fabled_app/data/models/news_item.dart';
import 'package:fabled_app/data/models/briefing_feed.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
@@ -146,4 +149,131 @@ void main() {
equals('item one item two'));
});
});
group('NewsItem.fromJson', () {
test('parses all fields', () {
final json = {
'id': 42,
'title': 'Big news',
'url': 'https://example.com/article',
'snippet': 'A short summary.',
'source': 'Example News',
'published_at': '2026-01-15T10:00:00',
'topics': ['tech', 'ai'],
'reaction': 'up',
};
final item = NewsItem.fromJson(json);
expect(item.id, equals(42));
expect(item.title, equals('Big news'));
expect(item.url, equals('https://example.com/article'));
expect(item.snippet, equals('A short summary.'));
expect(item.source, equals('Example News'));
expect(item.publishedAt, equals(DateTime.parse('2026-01-15T10:00:00')));
expect(item.topics, equals(['tech', 'ai']));
expect(item.reaction, equals('up'));
});
test('handles null published_at and reaction', () {
final json = {
'id': 1,
'title': '',
'url': '',
'snippet': '',
'source': '',
'published_at': null,
'topics': <dynamic>[],
'reaction': null,
};
final item = NewsItem.fromJson(json);
expect(item.publishedAt, isNull);
expect(item.reaction, isNull);
expect(item.topics, isEmpty);
});
});
group('CalendarEvent.fromJson', () {
test('parses all fields', () {
final json = {
'id': 10,
'title': 'Team meeting',
'start_dt': '2026-04-07T09:00:00+00:00',
'end_dt': '2026-04-07T10:00:00+00:00',
'all_day': false,
'description': 'Weekly sync',
'location': 'Room 4',
'color': '#6366F1',
'recurrence': 'FREQ=WEEKLY',
'project_id': 3,
'reminder_minutes': 15,
};
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').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'));
expect(event.color, equals('#6366F1'));
expect(event.recurrence, equals('FREQ=WEEKLY'));
expect(event.projectId, equals(3));
expect(event.reminderMinutes, equals(15));
});
test('handles null optional fields', () {
final json = {
'id': 11,
'title': 'Birthday',
'start_dt': '2026-05-01T00:00:00+00:00',
'end_dt': null,
'all_day': true,
'description': '',
'location': '',
'color': '',
'recurrence': null,
'project_id': null,
'reminder_minutes': null,
};
final event = CalendarEvent.fromJson(json);
expect(event.endDt, isNull);
expect(event.allDay, isTrue);
expect(event.recurrence, isNull);
expect(event.projectId, isNull);
expect(event.reminderMinutes, isNull);
});
});
group('dateOnly', () {
test('strips time from datetime', () {
final dt = DateTime(2026, 4, 7, 14, 30, 45);
expect(dateOnly(dt), equals(DateTime(2026, 4, 7)));
});
});
group('BriefingFeed.fromJson', () {
test('parses all fields', () {
final json = {
'id': 7,
'title': 'Hacker News',
'url': 'https://news.ycombinator.com/rss',
'category': 'tech',
};
final feed = BriefingFeed.fromJson(json);
expect(feed.id, equals(7));
expect(feed.title, equals('Hacker News'));
expect(feed.url, equals('https://news.ycombinator.com/rss'));
expect(feed.category, equals('tech'));
});
test('handles null category', () {
final json = {
'id': 8,
'title': 'Feed',
'url': 'https://example.com/rss',
'category': null,
};
final feed = BriefingFeed.fromJson(json);
expect(feed.category, isNull);
});
});
}
@@ -9,6 +9,7 @@
#include <flutter_inappwebview_windows/flutter_inappwebview_windows_plugin_c_api.h>
#include <flutter_timezone/flutter_timezone_plugin_c_api.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h>
#include <record_windows/record_windows_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
@@ -18,6 +19,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FlutterTimezonePluginCApi"));
PermissionHandlerWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
RecordWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}
+1
View File
@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
flutter_inappwebview_windows
flutter_timezone
permission_handler_windows
record_windows
url_launcher_windows
)