Compare commits

...

99 Commits

Author SHA1 Message Date
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
bvandeusen 9944680c5b Release v26.04.05.1 — Voice I/O 2026-04-05 23:55:13 +00:00
bvandeusen 29ff9f821a fix: override record_linux to 1.3.0 for platform interface compatibility
record_linux 0.7.2 doesn't implement startStream from record_platform_interface 1.5.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 14:50:05 -04:00
bvandeusen 1b08b2fd9e feat(voice): integrate voice mode into Briefing screen
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 14:47:38 -04:00
bvandeusen 211bf0d658 feat(voice): integrate one-shot voice capture into Quick Capture bar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 14:46:34 -04:00
bvandeusen 2231c60bfb feat(voice): integrate voice mode into Chat screen
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 14:45:53 -04:00
bvandeusen 81077349a5 feat(voice): add VoiceMicButton animated widget
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 14:44:55 -04:00
bvandeusen 2cb566336b feat(voice): add VoiceNotifier with recording, silence detection, and streaming TTS
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 14:44:21 -04:00
bvandeusen 6e33d74178 feat(voice): add VoiceRepository and provider registration 2026-04-05 14:40:49 -04:00
bvandeusen c90b0b3d48 feat(voice): add VoiceApi and VoiceStatus model 2026-04-05 14:40:19 -04:00
bvandeusen e891e8ba52 feat(voice): add record + just_audio dependencies, RECORD_AUDIO permission 2026-04-05 14:39:35 -04:00
bvandeusen 89fe8994fb docs: add Android Voice I/O implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 14:36:48 -04:00
bvandeusen 3a3f44b00b docs: add Android Voice I/O design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 14:19:48 -04:00
bvandeusen d97f7b0ebd feat(knowledge): wire 4-tab shell, retire LibraryScreen, complete Knowledge View feature 2026-04-05 00:03:23 -04:00
bvandeusen 2ab24a99a8 feat(knowledge): add edit button to ProjectTasksScreen app bar 2026-04-04 23:56:40 -04:00
bvandeusen e39d31fe43 feat(knowledge): add ProjectsScreen and ProjectEditScreen, sort projects by updated_at 2026-04-04 23:56:18 -04:00
bvandeusen a50193dbc0 feat(knowledge): update Project model, ProjectsApi sort, and NoteEditScreen noteType support 2026-04-04 23:52:10 -04:00
bvandeusen f5ba2d25a3 feat(knowledge): add KnowledgeScreen with tabs, search, tag filters, and infinite scroll 2026-04-04 23:50:19 -04:00
bvandeusen b5d9efa3ec feat(knowledge): add KnowledgeItemCard widget 2026-04-04 23:49:18 -04:00
bvandeusen e0b56fc149 feat(knowledge): add KnowledgeNotifier with two-tier pagination state 2026-04-04 23:48:47 -04:00
bvandeusen 535833abfe test: add KnowledgeItem model unit tests 2026-04-04 23:40:29 -04:00
bvandeusen deec2318f7 feat(knowledge): add KnowledgeApi, KnowledgeRepository, wire providers 2026-04-04 23:39:43 -04:00
bvandeusen 2920252f13 feat(knowledge): add KnowledgeItem model 2026-04-04 23:38:19 -04:00
bvandeusen c8cdcbf230 feat(knowledge): add noteType field to Note model, api, repository, provider 2026-04-04 23:37:53 -04:00
bvandeusen e89626a782 feat(knowledge): add knowledge + projectEdit route constants 2026-04-04 22:55:50 -04:00
bvandeusen ac4b2359a5 docs: add Knowledge View design spec for Android app
Covers four-tab navigation (Briefing/Knowledge/Chat/Projects),
two-tier pagination (50 IDs / 12-item batches), type-aware cards,
bottom sheet type picker, Projects screen with sorted project list,
ProjectEditScreen, and manifest/config checklist.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:30:44 -04:00
bvandeusen f11e869a1b Merge pull request 'Release v26.03.27.1' (#11) from dev into main
Release v26.03.27.1
2026-03-27 04:12:30 +00:00
bvandeusen 23509adfa8 feat: news story cards in Android briefing screen
Replaces bare Story-N reaction rows with full NewsCard widgets: source label,
relative timestamp, linked headline, 2-line snippet, and 👍/👎 reactions.
Reads rss_items from message metadata (requires backend ≥ this sprint).

Adds url_launcher ^6.3.1 for opening article links in the browser.
Adds https/http <queries> entries to AndroidManifest for Android 11+.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 00:08:51 -04:00
bvandeusen 24cef2e78c Merge pull request 'Release v26.03.26.4' (#10) from dev into main
Release v26.03.26.4
2026-03-27 03:54:53 +00:00
bvandeusen 399da397da fix: add release keystore signing to CI to fix self-update installs
Without a consistent signing key, each CI build was signed with the
ephemeral debug keystore from the Flutter container — causing Android
to reject updates with INSTALL_FAILED_UPDATE_INCOMPATIBLE.

Secrets stored in repo: RELEASE_KEYSTORE_BASE64, RELEASE_KEYSTORE_PASSWORD,
RELEASE_KEY_ALIAS. CI decodes the keystore and writes key.properties
before building so every release APK is signed identically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 23:41:56 -04:00
bvandeusen 2bdd663719 chore: merge main into dev, keep consolidated ci.yml 2026-03-26 18:18:38 -04:00
bvandeusen ba889a38be Merge pull request 'dev → main' (#9) from dev into main
dev → main
2026-03-26 22:08:04 +00:00
bvandeusen 7fce19a37c feat: silent background polling for briefing screen
Timer.periodic every 60s calls silentRefresh() on BriefingNotifier.
silentRefresh() patches AsyncData directly — never triggers AsyncLoading —
so existing content stays on screen while the fetch is in flight.
WidgetsBindingObserver pauses polling when app is backgrounded.
Polling is also skipped while a reply is actively streaming.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 17:43:06 -04:00
bvandeusen 9f524e158d fix: weather card spacing and sizing consistency
- Consistent 8px vertical rhythm (was 6/4/10 mix)
- Larger current temp (36px vs 32px) for better visual weight
- Fixed 64px forecast column width prevents uneven wrapping
- Forecast condition text clipped at 2 lines with ellipsis
- 12px spacing around forecast divider

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 17:36:53 -04:00
bvandeusen 46d0427901 feat: weather card and RSS reactions in briefing screen
- Parse metadata field on Message model (weather, rss_item_ids)
- Add WeatherCard widget: location, current temp, condition, today hi/lo,
  delta from yesterday, scrollable 3-day forecast strip
- BriefingScreen shows WeatherCard above the first assistant message that
  carries weather metadata (mirrors web BriefingView.vue behaviour)
- RSS reaction buttons (👍/👎) shown below assistant messages with
  rss_item_ids; optimistic toggle with rollback on failure
- Add postRssReaction / deleteRssReaction to BriefingApi

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 17:30:26 -04:00
bvandeusen 45294ade31 feat: sync device timezone to backend on startup
On shell mount, reads the IANA timezone from the device via
flutter_timezone and POSTs it to PUT /api/settings as user_timezone.
Mirrors the web app's App.vue behaviour so briefing events and the
chat route use the correct local time on Android.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 17:24:59 -04:00
bvandeusen 89c31f1904 Merge pull request 'dev' (#8) from dev into main
Reviewed-on: #8
2026-03-16 15:58:46 +00:00
bvandeusen 9f1d2317af fix: request install-packages permission at runtime before APK install
Android 8+ requires canRequestPackageInstalls() to return true even when
REQUEST_INSTALL_PACKAGES is declared in the manifest. Add permission_handler
and check/request Permission.requestInstallPackages before downloading;
redirects user to Settings if not granted and shows a clear error message.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 11:56:24 -04:00
bvandeusen c3d9cc273f fix: quick capture incorrectly treated as offline on LLM timeout
The global receiveTimeout is 30s but quick capture runs LLM inference
that can take much longer. receiveTimeout fired, fell through to the
NetworkException fallback in dioToApp, and the work queue queued it as
an offline item.

- quick_capture_api.dart: override receiveTimeout to 120s for the
  /api/quick-capture request
- api_client.dart: handle receiveTimeout/sendTimeout explicitly in the
  error interceptor, converting them to AppException (not NetworkException)
  so slow responses are never mistaken for being offline

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 07:54:00 -04:00
bvandeusen 63e01389e8 Merge pull request 'dev to main: big fix and improvements' (#6) from dev into main
Reviewed-on: #6
2026-03-12 21:44:43 -04:00
bvandeusen 5ca5856f15 Project task view: tapping a project shows milestone-grouped task list
- ProjectLibraryCard.onTap navigates to /projects/:id/tasks
- New ProjectTasksScreen: milestone sections with coloured dot,
  done/total counter and thin progress bar; unassigned tasks at bottom
- Status cycle icon updates optimistically (pendingStatus map) so
  the list doesn't flash on every tap; syncs global tasksProvider
- New route /projects/:id/tasks registered in app.dart
- TasksApi.getByProject + TasksRepository + projectTasksProvider (family)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 21:40:14 -04:00
bvandeusen a337c3fda3 Fix update dialog: show errors, handle OpenFile result correctly
- Check OpenResult.type before resetting state — previously a failed
  open_file call (e.g. installer blocked on emulator) silently called
  state = const UpdateState(), nulling latestVersion and downloadUrl,
  causing "Version null" and a non-functional Download button
- Show errorMessage in dialog with error colour so failures are visible
- Guard Download button on downloadUrl != null (no button if URL lost)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 21:01:01 -04:00
bvandeusen fc1c7cade2 chore: rename APK output to Fabled-<buildNumber>.apk
e.g. Fabled-2603122.apk for tag v26.03.12.2

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 20:52:39 -04:00
bvandeusen 0971433c7a refactor: remove briefing digest card, show full message list directly
The summary card duplicated the first assistant message and consumed
screen real estate without benefit on small screens. Replaced with:
- Full message list via ChatMessageBubble from the top
- SliverFillRemaining empty state ("No briefing yet today" + generate button)
  when no messages exist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 19:08:21 -04:00
bvandeusen 844f68d376 fix: update loop, ghost queue item, briefing card scroll trap
- Update dialog no longer re-appears on every shell re-mount; check()
  is skipped if status is already available/upToDate/downloading
- Offline queue dequeue now happens before the mounted check, preventing
  ghost items when the widget disposes mid-drain
- Briefing digest card and messages now share a CustomScrollView so
  expanding the card doesn't trap the user without scroll access

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 08:23:24 -04:00
bvandeusen cab8d7104f Merge pull request 'changes to PR process and build number implementation' (#5) from dev into main
Reviewed-on: #5
2026-03-12 07:55:52 -04:00
bvandeusen baba5c3462 feat: inject version from release tag at build time
pubspec.yaml now holds a placeholder (0.0.0+0) that is never updated.
CI strips the v* tag to derive --build-name (e.g. 26.03.12) and
--build-number (e.g. 260312) and passes them to flutter build apk.
2026-03-12 07:52:26 -04:00
bvandeusen 97c049e453 fix: guard release signingConfig behind key.properties existence check
signingConfigs.create() was running unconditionally, casting null
properties to String before the buildTypes guard could take effect.
CI has no key.properties so the cast threw. Now falls back to debug
signing when the keystore file is absent.
2026-03-12 07:51:11 -04:00
bvandeusen a19af3388d Merge pull request 'bug fix' (#4) from dev into main
Reviewed-on: #4
2026-03-12 06:48:30 -04:00
bvandeusen bae6597ec2 fix: remove double status-bar gap between capture bar and AppBar
The narrow-layout body Column had no SafeArea, so the child screen's
AppBar was adding the full status bar height on top of the height already
consumed by _QuickCaptureBar's own SafeArea. MediaQuery.removePadding
(removeTop: true) on the Expanded child tells the inner AppBar the top
inset is already handled.
2026-03-12 06:28:34 -04:00
bvandeusen bc48a49de8 feat: upgrade Riverpod 3, go_router 17, google_fonts 8, package_info_plus 9
Patch/minor (via flutter pub upgrade):
- dio 5.9.1 → 5.9.2
- dio_cookie_manager 3.3.0 → 3.4.0
- dio_web_adapter 2.1.1 → 2.1.2
- hooks 1.0.1 → 1.0.2
- native_toolchain_c 0.17.4 → 0.17.5

Major (via flutter pub upgrade --major-versions + code migration):
- flutter_riverpod 2.6.1 → 3.3.1 + riverpod 2.6.1 → 3.2.1
  - StateNotifierProvider + StateNotifier → NotifierProvider + Notifier
  - StateProvider → NotifierProvider with simple Notifier
  - FamilyAsyncNotifier build(arg) → build() with this.arg
  - AsyncValue.valueOrNull → .value (now T?)
- go_router 14.8.1 → 17.1.0
- google_fonts 6.3.3 → 8.0.2
- package_info_plus 8.3.1 → 9.0.0
2026-03-12 06:21:44 -04:00
bvandeusen a687e3637f feat: upgrade Riverpod 3, go_router 17, google_fonts 8, package_info_plus 9
Migrate StateNotifierProvider/StateNotifier → NotifierProvider/Notifier,
StateProvider → NotifierProvider with simple notifier, FamilyAsyncNotifier
removed in Riverpod 3 (replaced with constructor-arg pattern), and rename
.valueOrNull → .value throughout all providers and screens.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 06:21:17 -04:00
bvandeusen c8becd6afd fix: revert null-aware map entries; disable use_null_aware_elements lint
The ? operator on map entries applies to the key, not the value.
String literal keys can never be null, so ?'key': value was flagged as
invalid_null_aware_operator. Reverted to if (x != null) 'key': x form
and disabled the lint project-wide since it doesn't apply here.
2026-03-12 00:44:04 -04:00
bvandeusen e63370bf0a Merge pull request 'v26.03.12.1' (#3) from dev into main
Reviewed-on: #3
2026-03-12 00:41:40 -04:00
bvandeusen f39b8ddb30 chore: bump version to 26.03.12+1 2026-03-12 00:40:10 -04:00
bvandeusen def7519feb fix: resolve all flutter analyze warnings and infos
- app.dart: add braces to single-statement if body
- briefing_api.dart: escape <id> in doc comment (unintended HTML)
- notes_api.dart, tasks_api.dart, projects_api.dart: use null-aware
  map elements (?'key': value) instead of if-null guards
- task_edit_screen.dart: remove unused tasks_api.dart import
- task_edit_screen.dart, project_selector.dart: suppress
  deprecated_member_use on DropdownButtonFormField.value (value is
  the controlled-widget param; switching to initialValue would break
  current-selection display)
- project_selector.dart: use _ instead of __ for ignored error params
2026-03-12 00:36:05 -04:00
bvandeusen e86d1a59af fix: redirect to /briefing after auth (was /notes which no longer exists) 2026-03-12 00:32:38 -04:00
bvandeusen c8f3861eb5 ci: only trigger on v* release tags, never on branch pushes 2026-03-12 00:24:59 -04:00
bvandeusen a2e734c498 Merge pull request 'ci: replace JS actions with shell steps — Flutter container has no Node' (#2) from dev into main
Reviewed-on: #2
2026-03-12 00:23:06 -04:00
bvandeusen 3a336ddf88 ci: replace JS actions with shell steps — Flutter container has no Node
actions/checkout and actions/upload-artifact are JavaScript actions.
When container: is set, act runs them inside the container (not on the
host runner), but ghcr.io/cirruslabs/flutter:stable has no Node.js.

Replace both with equivalent shell commands:
- checkout: git clone + git checkout SHA
- artifact upload: curl to Forgejo API (best-effort, non-blocking)
The release APK attach step was already a pure shell step and is unchanged.
2026-03-12 00:21:47 -04:00
bvandeusen 6af23fc853 Bump actions/checkout v4 → v6
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 21:44:37 -04:00
90 changed files with 13513 additions and 931 deletions
+47 -45
View File
@@ -1,12 +1,14 @@
# CI runs first; build only proceeds if analyze and test pass.
# CI runs only on release tags.
#
# Push to dev: analyze + test → build APK → upload artifact (fabledapp-dev-<sha>)
# Push to main: analyze + test only (no build — wait for release tag)
# Tag v26.03.11: analyze + test → build APK → upload artifact + create Forgejo Release
# Pull request: analyze + test only
# Tag v*: analyze + test → build APK → attach to Forgejo Release
#
# To cut a release:
# git tag v26.03.11 && git push origin v26.03.11
# Create a release via the Forgejo UI on main with a v* tag name.
# The tag push triggers this workflow; the build job attaches the APK.
#
# Note: JavaScript actions (actions/checkout, actions/upload-artifact) cannot run
# inside the Flutter container because it has no Node.js. All steps use shell
# commands directly instead.
#
# Required secrets (repo → Settings → Secrets → Actions):
# RELEASE_TOKEN — Forgejo PAT with write:repository scope
@@ -14,25 +16,7 @@ name: CI & Build
on:
push:
branches: [main, dev]
tags: ["v*"]
paths:
- "lib/**"
- "test/**"
- "pubspec.yaml"
- "pubspec.lock"
- "analysis_options.yaml"
- "android/**"
- "assets/**"
- ".forgejo/workflows/ci.yml"
pull_request:
paths:
- "lib/**"
- "test/**"
- "pubspec.yaml"
- "pubspec.lock"
- "analysis_options.yaml"
- ".forgejo/workflows/ci.yml"
jobs:
analyze:
@@ -41,7 +25,10 @@ jobs:
container:
image: ghcr.io/cirruslabs/flutter:stable
steps:
- uses: actions/checkout@v6
- name: Checkout
run: |
git clone "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" .
git checkout "$GITHUB_SHA"
- name: Install dependencies
run: flutter pub get
@@ -55,42 +42,57 @@ jobs:
build:
name: Build release APK
needs: [analyze]
# Build on dev branch pushes and version tag pushes only.
# main branch pushes run CI for safety but do not build —
# the release tag (v*) is the sole trigger for a production APK.
if: |
github.event_name == 'push' &&
(github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/'))
runs-on: py3.12-node22
container:
image: ghcr.io/cirruslabs/flutter:stable
steps:
- uses: actions/checkout@v6
- name: Checkout
run: |
git clone "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" .
git checkout "$GITHUB_SHA"
- name: Install dependencies
run: flutter pub get
- name: Set up release signing
env:
KEYSTORE_B64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }}
STORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
run: |
echo "$KEYSTORE_B64" | base64 -d > android/app/release.jks
printf 'storePassword=%s\nkeyPassword=%s\nkeyAlias=%s\nstoreFile=release.jks\n' \
"$STORE_PASSWORD" "$STORE_PASSWORD" "$KEY_ALIAS" > android/key.properties
- name: Build release APK
run: flutter build apk --release
run: |
# Derive version from the tag (e.g. v26.03.12 → name=26.03.12 number=260312)
TAG="${{ github.ref_name }}"
BUILD_NAME="${TAG#v}"
BUILD_NUMBER=$(echo "$BUILD_NAME" | tr -d '.')
flutter build apk --release \
--build-name="$BUILD_NAME" \
--build-number="$BUILD_NUMBER"
- name: Set artifact name
id: artifact
run: |
if [[ "${{ github.ref }}" == "refs/heads/dev" ]]; then
echo "name=fabledapp-dev-${{ github.sha }}" >> $GITHUB_OUTPUT
else
echo "name=fabledapp-${{ github.sha }}" >> $GITHUB_OUTPUT
fi
echo "name=fabledapp-${{ github.ref_name }}-${{ github.sha }}" >> $GITHUB_OUTPUT
- name: Upload artifact
uses: actions/upload-artifact@v6
with:
name: ${{ steps.artifact.outputs.name }}
path: build/app/outputs/flutter-apk/app-release.apk
retention-days: 30
- 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
if: startsWith(github.ref, 'refs/tags/')
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
TAG: ${{ github.ref_name }}
+1
View File
@@ -49,3 +49,4 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
.superpowers/
+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.
+3
View File
@@ -23,6 +23,9 @@ linter:
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# ?'key': value applies ? to the key (never null for string literals);
# the if (x != null) form is correct for nullable-value map entries.
use_null_aware_elements: false
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+1
View File
@@ -10,3 +10,4 @@ GeneratedPluginRegistrant.java
# Signing secrets — never commit these
key.properties
fabled-release-key.jks
app/release.jks
+8 -6
View File
@@ -37,11 +37,13 @@ android {
}
signingConfigs {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
if (keystorePropertiesFile.exists()) {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
}
}
}
@@ -59,7 +61,7 @@ android {
val variant = this
outputs.all {
val output = this as? com.android.build.gradle.internal.api.BaseVariantOutputImpl
output?.outputFileName = "Fabled-${variant.versionName}.${variant.versionCode}.apk"
output?.outputFileName = "Fabled-${variant.versionCode}.apk"
}
}
}
+10
View File
@@ -1,6 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
@@ -70,5 +71,14 @@
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
<!-- url_launcher: open http/https links in browser -->
<intent>
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="https"/>
</intent>
<intent>
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="http"/>
</intent>
</queries>
</manifest>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
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,455 @@
# Knowledge View — Android App Design Spec
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Replace the Library tab with a typed Knowledge view and add a dedicated Projects tab, bringing the Android app to parity with the web Knowledge view while using the existing backend `/api/knowledge` and `/api/projects` endpoints.
**Architecture:** Two-tier pagination — fetch IDs cheaply (50 at a time), hydrate visible items in batches of 12. Type tabs drive server-side filtering. A new Projects tab replaces the project section formerly in Library. All data access goes through the existing Riverpod/Dio/repository pattern.
**Tech Stack:** Flutter 3, Riverpod 3, GoRouter 17, Dio 5, existing backend REST API (`/api/knowledge`, `/api/projects`, `/api/notes`)
---
## Manifest & Configuration Checklist
These must be verified before any code is written. Failures here cause silent runtime errors.
- [ ] `android/app/src/main/AndroidManifest.xml` — confirm `<uses-permission android:name="android.permission.INTERNET" />` is present
- [ ] `android/app/src/main/AndroidManifest.xml` — confirm `android:usesCleartextTraffic="true"` is set on the `<application>` tag (required for HTTP dev server connections; if already using HTTPS only, leave as-is but document the decision)
- [ ] `pubspec.yaml` — no new dependencies required for Knowledge View
- [ ] `android/app/build.gradle` — no changes required for Knowledge View
---
## File Map
### New files
| Path | Responsibility |
|---|---|
| `lib/data/models/knowledge_item.dart` | Unified model for all knowledge types (note, person, place, list, task) |
| `lib/data/api/knowledge_api.dart` | Two API methods: fetch IDs, batch-hydrate items |
| `lib/data/repositories/knowledge_repository.dart` | Thin repo wrapping KnowledgeApi |
| `lib/providers/knowledge_provider.dart` | StateNotifier with two-tier pagination state |
| `lib/screens/knowledge/knowledge_screen.dart` | Main Knowledge screen with type tabs, tag chips, search, infinite scroll |
| `lib/widgets/knowledge_item_card.dart` | Type-aware card widget (different icon/subtitle per type) |
| `lib/screens/projects/projects_screen.dart` | Project list sorted by updated_at desc |
| `lib/screens/projects/project_edit_screen.dart` | Create/edit project (title, description, goal, status) |
### Modified files
| Path | Change |
|---|---|
| `lib/data/models/note.dart` | Add `noteType` field (String, default `'note'`) |
| `lib/data/models/project.dart` | Add `status`, `goal`, `color`, `autoSummary`, `updatedAt` fields |
| `lib/data/api/notes_api.dart` | Pass `note_type` in create and update request bodies |
| `lib/data/api/projects_api.dart` | Add `sort=updated_at&order=desc` to list call; add `status`, `goal`, `color` to create/update |
| `lib/screens/notes/note_edit_screen.dart` | Accept optional `noteType` parameter; show type badge in app bar; pass `note_type` to API |
| `lib/screens/library/project_tasks_screen.dart` | Add edit button in app bar pushing to `ProjectEditScreen` |
| `lib/app.dart` | Replace `Routes.library` with `Routes.knowledge` + `Routes.projects`; update shell to 4 tabs |
| `lib/core/constants.dart` | Add `Routes.knowledge`, `Routes.projects`; remove `Routes.library` |
### Retired files
| Path | Replacement |
|---|---|
| `lib/screens/library/library_screen.dart` | `KnowledgeScreen` + `ProjectsScreen` |
| `lib/widgets/library_item_card.dart` | `KnowledgeItemCard` |
---
## Section 1: Navigation
Four tabs replace the existing three:
```
Briefing | Knowledge | Chat | Projects
```
**`lib/core/constants.dart`:**
- Remove `static const library = '/library'`
- Add `static const knowledge = '/knowledge'`
- `projects` already exists as `'/projects'` — no change needed
- Add `static const projectEdit = '/projects/:id/edit'`
**`lib/app.dart``_ShellState`:**
```dart
static const _tabs = [
Routes.briefing,
Routes.knowledge,
Routes.conversations,
Routes.projects,
];
```
Shell `NavigationBar` / `NavigationRail` entries:
```dart
// Bottom nav
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'),
```
**`_QuickCaptureBar._hintForLocation` update:**
```dart
String _hintForLocation(String location) {
if (location.startsWith(Routes.knowledge)) return 'Capture a note…';
if (location.startsWith(Routes.projects)) return 'Capture a note…';
if (location.startsWith(Routes.conversations)) return 'Ask Fabled…';
return 'Capture a note…';
}
```
**GoRouter shell routes** — replace `Routes.library` route with:
```dart
GoRoute(path: Routes.knowledge, builder: (_, _) => const KnowledgeScreen()),
GoRoute(path: Routes.projects, builder: (_, _) => const ProjectsScreen()),
```
---
## Section 2: Data Models
### `lib/data/models/knowledge_item.dart`
```dart
class KnowledgeItem {
final int id;
final String noteType; // 'note' | 'person' | 'place' | 'list' | 'task'
final String title;
final String body;
final List<String> tags;
final int? projectId;
final int? milestoneId;
final int? parentId;
// Task-only fields (null for non-tasks)
final String? status; // 'todo' | 'in_progress' | 'done' | 'cancelled'
final String? priority; // 'low' | 'normal' | 'high'
final String? dueDate;
final DateTime createdAt;
final DateTime updatedAt;
const KnowledgeItem({...});
factory KnowledgeItem.fromJson(Map<String, dynamic> json) => KnowledgeItem(
id: json['id'] as int,
noteType: json['note_type'] as String? ?? 'note',
title: json['title'] as String? ?? '',
body: json['body'] as String? ?? '',
tags: (json['tags'] as List<dynamic>?)?.map((e) => e as String).toList() ?? [],
projectId: json['project_id'] as int?,
milestoneId: json['milestone_id'] as int?,
parentId: json['parent_id'] as int?,
status: json['status'] as String?,
priority: json['priority'] as String?,
dueDate: json['due_date'] as String?,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
);
}
```
### `lib/data/models/note.dart` — add `noteType`
```dart
final String noteType; // new field
// In constructor:
required this.noteType,
// In fromJson:
noteType: json['note_type'] as String? ?? 'note',
// In toJson:
'note_type': noteType,
// In copyWith: add noteType parameter
```
### `lib/data/models/project.dart` — add missing fields
```dart
final String status; // 'active' | 'completed' | 'archived'
final String? goal;
final String? color;
final String? autoSummary;
final DateTime updatedAt;
// In fromJson:
status: json['status'] as String? ?? 'active',
goal: json['goal'] as String?,
color: json['color'] as String?,
autoSummary: json['auto_summary'] as String?,
updatedAt: DateTime.parse(json['updated_at'] as String),
```
---
## Section 3: API Layer
### `lib/data/api/knowledge_api.dart`
```dart
class KnowledgeApi {
final Dio _dio;
const KnowledgeApi(this._dio);
/// Fetch page of IDs. Returns (ids, total).
Future<(List<int>, int)> fetchIds({
String? noteType,
List<String> tags = const [],
String sort = 'modified',
String? q,
int limit = 50,
int offset = 0,
}) async {
final params = <String, dynamic>{
'limit': limit,
'offset': offset,
'sort': sort,
if (noteType != null) 'type': noteType,
if (tags.isNotEmpty) 'tags': tags.join(','),
if (q != null && q.isNotEmpty) 'q': q,
};
final response = await _dio.get('/api/knowledge/ids', queryParameters: params);
final data = response.data as Map<String, dynamic>;
final ids = (data['ids'] as List<dynamic>).map((e) => e as int).toList();
final total = data['total'] as int;
return (ids, total);
}
/// Batch-hydrate up to 100 IDs into full items.
Future<List<KnowledgeItem>> fetchBatch(List<int> ids) async {
if (ids.isEmpty) return [];
final response = await _dio.get(
'/api/knowledge/batch',
queryParameters: {'ids': ids.join(',')},
);
final data = response.data as Map<String, dynamic>;
return (data['items'] as List<dynamic>)
.map((e) => KnowledgeItem.fromJson(e as Map<String, dynamic>))
.toList();
}
/// Per-type counts for tab labels.
Future<Map<String, int>> fetchCounts({List<String> tags = const []}) async {
final params = <String, dynamic>{
if (tags.isNotEmpty) 'tags': tags.join(','),
};
final response = await _dio.get('/api/knowledge/counts', queryParameters: params);
return (response.data as Map<String, dynamic>).map(
(k, v) => MapEntry(k, (v as num).toInt()),
);
}
/// All tags for the current type filter.
Future<List<String>> fetchTags({String? noteType}) async {
final response = await _dio.get(
'/api/knowledge/tags',
queryParameters: {if (noteType != null) 'type': noteType},
);
return (response.data['tags'] as List<dynamic>).map((e) => e as String).toList();
}
}
```
### `lib/data/api/projects_api.dart` — add sort params + missing fields
Add `sort: 'updated_at'` and `order: 'desc'` to the `getProjects` query parameters.
Add `status`, `goal`, `color` to `createProject` and `updateProject` request bodies.
### `lib/data/api/notes_api.dart` — pass `note_type`
```dart
// In createNote and updateNote bodies:
if (noteType != null) 'note_type': noteType,
```
---
## Section 4: Provider
### `lib/providers/knowledge_provider.dart`
```dart
@immutable
class KnowledgeState {
final List<int> ids; // all fetched IDs so far
final Map<int, KnowledgeItem> items; // hydrated items
final int totalIds;
final bool isLoadingIds;
final bool isLoadingBatch;
final bool hasMore;
final String? noteType; // active type filter
final List<String> activeTags;
final String? searchQuery;
final Map<String, int> counts; // per-type counts
bool get canLoadMore => hasMore && !isLoadingIds;
// Items in ID order, only those hydrated
List<KnowledgeItem> get orderedItems =>
ids.where(items.containsKey).map((id) => items[id]!).toList();
}
class KnowledgeNotifier extends StateNotifier<KnowledgeState> {
// On filter change: reset state, fetch IDs from offset 0
void setTypeFilter(String? noteType) { ... }
void toggleTag(String tag) { ... }
void setSearch(String? q) { ... } // debounce handled in UI
// Fetch next page of IDs (50 at a time)
Future<void> loadMoreIds() { ... }
// Hydrate the next 12 un-hydrated IDs from the current list
Future<void> hydrateNext() { ... }
Future<void> refresh() { ... } // reset + reload
}
// Provider
final knowledgeProvider =
StateNotifierProvider<KnowledgeNotifier, KnowledgeState>(...);
```
**Scroll trigger:** `KnowledgeScreen` attaches a `ScrollController` listener. When `position.pixels >= maxScrollExtent - 300`:
1. Call `hydrateNext()` if there are un-hydrated IDs in the list
2. Call `loadMoreIds()` if all fetched IDs are hydrated and `hasMore` is true
---
## Section 5: Knowledge Screen
### `lib/screens/knowledge/knowledge_screen.dart`
**Structure:**
```
Scaffold
AppBar
title: 'Knowledge'
actions: [search icon → expand TextField]
Column
TabBar (All | Notes | People | Places | Lists | Tasks)
— tab labels show counts: "Notes (12)"
AnimatedContainer (tag filter chip row, hidden when empty)
— horizontal SingleChildScrollView of FilterChip widgets
Expanded
ListView.builder
— items from knowledgeState.orderedItems
— trailing: loading indicator when isLoadingBatch
FAB (pen icon)
— Tasks tab → push TaskEditScreen
— all other tabs → showModalBottomSheet (type picker)
```
**Pull-to-refresh:** `RefreshIndicator` wrapping the `ListView`.
**Empty state:** Centered column with type-appropriate icon and "No [type] yet" text.
**Error state:** Centered text with retry button calling `ref.invalidate(knowledgeProvider)`.
### `lib/widgets/knowledge_item_card.dart`
`ListTile`-based card. Leading icon varies by type:
- note → `Icons.description_outlined`
- person → `Icons.person_outlined`
- place → `Icons.place_outlined`
- list → `Icons.checklist_outlined`
- task → `Icons.task_alt_outlined` (with status colour on leading)
Subtitle shows truncated body (max 2 lines) or due date for tasks.
Trailing shows tag chips (up to 2, then "+N more").
Tap → `NoteDetailScreen(noteId: item.id)` for knowledge types; `TaskEditScreen(taskId: item.id)` for tasks.
---
## Section 6: Type Picker Bottom Sheet
Shown when FAB is tapped on any non-Tasks tab.
```dart
showModalBottomSheet(
context: context,
builder: (_) => Column(
mainAxisSize: MainAxisSize.min,
children: [
_TypeRow(Icons.description_outlined, 'Note', 'General note or document', 'note'),
_TypeRow(Icons.person_outlined, 'Person', 'Contact, colleague, or reference person', 'person'),
_TypeRow(Icons.place_outlined, 'Place', 'Location, venue, or place of interest', 'place'),
_TypeRow(Icons.checklist_outlined, 'List', 'Checklist or structured list', 'list'),
],
),
);
```
Each `_TypeRow` on tap:
1. `Navigator.pop(context)`
2. `context.push(Routes.noteNew, extra: {'noteType': selectedType})`
### `lib/screens/notes/note_edit_screen.dart` changes
- Accept `noteType` from `GoRouterState.extra` or route parameter (default `'note'`)
- Display a read-only type badge chip in the app bar subtitle
- Pass `noteType` to `notes_api.createNote` / `notes_api.updateNote`
---
## Section 7: Projects Screen
### `lib/screens/projects/projects_screen.dart`
```
Scaffold
AppBar: 'Projects'
RefreshIndicator
ListView.builder
— projects from projectsProvider (sorted updated_at desc via API param)
— each item: _ProjectCard
FAB → push ProjectEditScreen()
```
`_ProjectCard` (inline widget):
- Title + status badge (`active` = green, `completed` = blue, `archived` = grey)
- Description (1 line, truncated)
- Goal text if present (italic, muted)
- Milestone progress: `"3 / 5 milestones done"` using milestone count from project data if available, otherwise omitted
Tap → `context.push('/projects/${project.id}/tasks')``ProjectTasksScreen` reads `projectId` from the path parameter, unchanged except for the added edit button.
### `lib/screens/projects/project_edit_screen.dart`
Fields: Title (required), Description, Goal, Status dropdown (`active` / `completed` / `archived`).
Used for both create (no `projectId`) and edit (with `projectId`).
On save: POST `/api/projects` or PATCH `/api/projects/:id` via `projectsApi`.
On success: `ref.invalidate(projectsProvider)` then `Navigator.pop`.
### `lib/screens/library/project_tasks_screen.dart` change
Add an edit `IconButton` in the `AppBar.actions`:
```dart
IconButton(
icon: const Icon(Icons.edit_outlined),
onPressed: () => context.push('/projects/$projectId/edit'),
)
```
---
## Section 8: Provider & API Wiring
New providers to add to `lib/providers/`:
```dart
// knowledge_api_provider.dart (or in api_client_provider.dart)
final knowledgeApiProvider = Provider((ref) =>
KnowledgeApi(ref.watch(dioProvider)));
final knowledgeRepositoryProvider = Provider((ref) =>
KnowledgeRepository(ref.watch(knowledgeApiProvider)));
```
`projectsProvider` — add query parameters to the underlying `getProjects` call:
```dart
await api.getProjects(sort: 'updated_at', order: 'desc');
```
---
## What This Does NOT Include
- Voice I/O (separate spec)
- Backend parity pass (separate spec)
- Editing knowledge item type after creation
- Bulk delete / multi-select in Knowledge screen
- Knowledge graph view
- Milestone detail screen (projects show milestone count only)
@@ -0,0 +1,180 @@
# Android Voice I/O Design
## Goal
Add voice input (STT) and output (TTS) to the Android app. All audio processing runs server-side via existing backend endpoints — no on-device STT or TTS APIs. Voice input is available in three locations: the Chat screen, the Quick Capture bar, and the Briefing follow-up bar. TTS auto-plays when voice mode is active.
---
## Architecture
### New files
| File | Responsibility |
|---|---|
| `lib/data/api/voice_api.dart` | Dio wrapper: `checkStatus()`, `transcribe(Uint8List)`, `synthesise(String)` |
| `lib/data/repositories/voice_repository.dart` | Thin wrapper around `VoiceApi` |
| `lib/providers/voice_provider.dart` | `VoiceState` + `VoiceNotifier extends Notifier<VoiceState>` — owns full recording/TTS lifecycle |
| `lib/widgets/voice_mic_button.dart` | Shared mic button widget used by all three screens; animates across all states |
### Modified files
| File | Change |
|---|---|
| `pubspec.yaml` | Add `record: ^6.x`, `just_audio: ^0.9.x` |
| `android/app/src/main/AndroidManifest.xml` | Add `RECORD_AUDIO` permission |
| `lib/providers/api_client_provider.dart` | Add `voiceApiProvider`, `voiceRepositoryProvider` |
| `lib/screens/chat/chat_screen.dart` | Add `VoiceMicButton` to input bar; wire streaming TTS watcher |
| `lib/app.dart` (`_QuickCaptureBar`) | Add `VoiceMicButton` next to capture send button |
| `lib/screens/briefing/briefing_screen.dart` | Add `VoiceMicButton` to follow-up input row; wire streaming TTS watcher |
### Packages
- **`record` ^6.x** — records audio on Android, outputs WebM/Opus (matches what the backend Whisper STT expects)
- **`just_audio` ^0.9.x** — queue-based audio player; plays WAV bytes returned by `/api/voice/synthesise`
---
## State model
```dart
enum VoiceMode { idle, recording, transcribing, playing }
class VoiceState {
final VoiceMode mode;
final bool voiceModeActive; // whether the voice loop is running
final bool available; // from /api/voice/status check
}
```
`VoiceNotifier` is a `Notifier<VoiceState>` (Riverpod 3). It owns the `AudioRecorder` and `AudioPlayer` instances.
---
## Backend endpoints (no changes needed)
All existing, no backend work required:
- `GET /api/voice/status``{enabled, stt, tts}` — checked before entering voice mode
- `POST /api/voice/transcribe``multipart/form-data`, field `audio` (WebM/Opus bytes) → `{transcript, duration_ms}`
- `POST /api/voice/synthesise``{"text": "..."}``audio/wav` bytes
---
## Data flow
### Chat / Briefing — continuous voice loop
1. User taps mic → `VoiceNotifier.enterVoiceMode()`
2. Call `GET /api/voice/status`; if unavailable → show snackbar, abort
3. Request `RECORD_AUDIO` permission (via `permission_handler`); if denied → snackbar, abort
4. Set `voiceModeActive = true`; input field disabled, send button dimmed, red banner shown
5. Start recording via `record` package; poll amplitude every 200ms
6. Silence detected (amplitude < 40 dB for 1500ms) → stop recording
7. `POST /api/voice/transcribe` with WebM bytes → transcript
8. If transcript is empty → restart listening from step 5 silently
9. Call `sendMessage(transcript)` on `messagesProvider` (identical path to typed text)
10. As the SSE response streams in, `VoiceNotifier` watches the streaming content:
- Buffer incoming text; extract completed sentences at `.`, `!`, `?` boundaries
- Strip markdown (code fences, headers, bold/italic markers) before synthesising
- For each sentence: `POST /api/voice/synthesise` → enqueue WAV blob in `just_audio` player
11. After all audio plays → restart listening from step 5
12. User taps mic again → `VoiceNotifier.exitVoiceMode()` → stop recording, cancel pending TTS, reset state
### Quick Capture — one-shot, no TTS loop
Steps 18 identical. Then instead of `sendMessage`, the transcript is passed to `captureWorkQueueProvider.enqueue(transcript)` — identical to typing in the capture bar. Mic returns to idle. No TTS playback, no loop.
---
## Silence detection
The `record` package emits `onAmplitudeChanged` events. `VoiceNotifier` tracks consecutive below-threshold samples:
- Threshold: 40 dB
- Required duration: 1500ms of continuous silence
- Minimum recording length: 300ms (ignore silence before user has spoken)
---
## Streaming TTS (mirrors web app)
Matches the behaviour of `useStreamingTts` in the web frontend:
1. Watch `messagesProvider` for streaming assistant content
2. Accumulate new characters into a sentence buffer
3. On each sentence boundary → strip markdown → `POST /api/voice/synthesise` → enqueue WAV
4. `just_audio` plays queued WAVs sequentially
5. On new message start → cancel in-flight synthesis, clear queue, stop playback
6. On stream end → flush remaining buffer fragment if ≥ 3 characters
Markdown stripping removes: code blocks (`` ``` ``), inline code, `#` headers, `**bold**`, `*italic*`, `[link](url)` → link text only, leading list markers.
---
## UX
### VoiceMicButton states
| State | Visual |
|---|---|
| Idle (voice mode off) | Plain mic icon, muted background |
| Recording | Red filled circle, pulsing shadow ring |
| Transcribing | Indigo filled circle, spinner overlay |
| Playing TTS | Indigo filled circle, speaker wave icon |
### Voice mode banner (Chat + Briefing only)
A thin red banner appears above the input row while voice mode is active:
> "🎤 Listening… tap mic to exit voice mode"
Input field shows "Listening…" hint in italic. Send button dimmed but still tappable as an override.
### Quick Capture bar
No banner. The capture bar's background shifts to a subtle red tint while recording. Returns to normal after capture.
---
## Error handling
| Scenario | Behaviour |
|---|---|
| Voice unavailable on server | Snackbar "Voice not available on this server", mic stays idle |
| Mic permission denied | Snackbar "Microphone permission required", mic stays idle |
| Empty transcript | Stay in voice mode, restart listening silently |
| Transcription API error | Stay in voice mode, restart listening; log warning |
| TTS synthesis failure for a sentence | Skip that sentence, continue playback queue |
| Network error during voice loop | Exit voice mode, show snackbar "Voice error — check connection" |
| User navigates away while in voice mode | `VoiceNotifier` disposes recording + playback cleanly |
---
## Permissions
Add to `android/app/src/main/AndroidManifest.xml`:
```xml
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
```
Runtime permission requested via `permission_handler` at first mic tap. If permanently denied, open app settings.
---
## Testing
- **Unit — `VoiceNotifier`:** Mock `VoiceRepository`; verify state transitions: idle → recording → transcribing → idle (on empty transcript), idle → recording → transcribing → playing → recording (happy path)
- **Unit — silence detection:** Feed synthetic amplitude stream; assert stop fires after 1500ms below threshold, not before
- **Unit — streaming TTS sentence extraction:** Feed streaming content strings; assert correct sentences extracted, markdown stripped
- **Widget — `VoiceMicButton`:** Verify correct icon/colour/animation for each `VoiceMode` value
- **Integration — permission flow:** Mock `permission_handler`; assert denied path shows snackbar and stays idle
---
## Out of scope
- Wake word / always-on listening
- On-device STT or TTS
- Voice settings UI in the Android app (voice and TTS settings managed via the web Settings page)
- iOS support (Android only per project constraints)
@@ -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)
+252 -38
View File
@@ -2,28 +2,40 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
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';
import 'screens/library/project_tasks_screen.dart';
import 'screens/chat/chat_screen.dart';
import 'screens/chat/conversations_tab_screen.dart';
import 'screens/library/library_screen.dart';
import 'screens/notes/note_detail_screen.dart';
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';
// ChangeNotifier that fires when auth or server URL changes,
// used as GoRouter.refreshListenable so the router re-evaluates redirects
@@ -79,7 +91,10 @@ final routerProvider = Provider<GoRouter>((ref) {
),
GoRoute(
path: Routes.noteNew,
builder: (_, _) => const NoteEditScreen(),
builder: (_, state) {
final extra = state.extra as Map<String, dynamic>?;
return NoteEditScreen(noteType: extra?['noteType'] as String?);
},
),
GoRoute(
path: Routes.noteDetail,
@@ -107,6 +122,22 @@ final routerProvider = Provider<GoRouter>((ref) {
taskId: int.parse(state.pathParameters['id']!),
),
),
GoRoute(
path: Routes.projectTasks,
builder: (_, state) => ProjectTasksScreen(
projectId: int.parse(state.pathParameters['id']!),
),
),
GoRoute(
path: '/projects/new',
builder: (_, _) => const ProjectEditScreen(),
),
GoRoute(
path: Routes.projectEdit,
builder: (_, state) => ProjectEditScreen(
projectId: int.parse(state.pathParameters['id']!),
),
),
GoRoute(
path: Routes.chat,
builder: (_, state) => ChatScreen(
@@ -121,8 +152,8 @@ final routerProvider = Provider<GoRouter>((ref) {
builder: (_, _) => const BriefingScreen(),
),
GoRoute(
path: Routes.library,
builder: (_, _) => const LibraryScreen(),
path: Routes.knowledge,
builder: (_, _) => const KnowledgeScreen(),
),
GoRoute(
path: Routes.conversations,
@@ -130,6 +161,18 @@ final routerProvider = Provider<GoRouter>((ref) {
),
],
),
GoRoute(
path: Routes.projects,
builder: (_, _) => const ProjectsScreen(),
),
GoRoute(
path: Routes.news,
builder: (_, _) => const NewsScreen(),
),
GoRoute(
path: Routes.calendar,
builder: (_, _) => const CalendarScreen(),
),
],
);
});
@@ -142,32 +185,137 @@ 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.library,
Routes.knowledge,
Routes.conversations,
];
// 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();
// Silent update check on first app load.
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) {
// Silent update check — only if we haven't already checked this session.
final repoUrl = ref.read(forgejoRepoUrlProvider);
if (repoUrl != null && repoUrl.isNotEmpty) {
ref.read(updateProvider.notifier).check(repoUrl);
final status = ref.read(updateProvider).status;
if (status == UpdateStatus.idle || status == UpdateStatus.error) {
ref.read(updateProvider.notifier).check(repoUrl);
}
}
// Sync device timezone to backend so briefing and chat use local time.
_syncTimezone();
});
}
@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.invalidate(conversationsProvider);
ref.invalidate(calendarProvider);
ref.invalidate(newsProvider);
// Notifier (not AsyncNotifier) — needs explicit refresh call.
ref.read(knowledgeProvider.notifier).refresh();
// briefingProvider is an AsyncNotifier family; invalidating the family
// is safe even if no conversation is open.
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.invalidate(conversationsProvider);
}
}
Future<void> _syncTimezone() async {
try {
final tzInfo = await FlutterTimezone.getLocalTimezone();
await ref.read(settingsApiProvider).syncTimezone(tzInfo.identifier);
} catch (_) {
// Best-effort — failure is non-critical.
}
}
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;
}
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,
@@ -181,7 +329,7 @@ class _ShellState extends ConsumerState<_Shell> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Version ${state.latestVersion} is ready to install.'),
Text('Version ${state.latestVersion ?? '?'} is ready to install.'),
if (state.currentVersion != null)
Text(
'Installed: v${state.currentVersion}',
@@ -201,6 +349,16 @@ class _ShellState extends ConsumerState<_Shell> {
style: Theme.of(context).textTheme.bodySmall,
),
],
if (state.status == UpdateStatus.error &&
state.errorMessage != null) ...[
const SizedBox(height: 12),
Text(
state.errorMessage!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
],
],
),
actions: [
@@ -208,7 +366,7 @@ class _ShellState extends ConsumerState<_Shell> {
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Later'),
),
if (!isDownloading)
if (!isDownloading && state.downloadUrl != null)
FilledButton(
onPressed: () => ref
.read(updateProvider.notifier)
@@ -234,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;
@@ -248,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(
@@ -257,15 +428,20 @@ class _ShellState extends ConsumerState<_Shell> {
label: Text('Briefing'),
),
NavigationRailDestination(
icon: Icon(Icons.library_books_outlined),
selectedIcon: Icon(Icons.library_books),
label: Text('Library'),
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'),
),
],
),
const VerticalDivider(width: 1),
@@ -283,12 +459,24 @@ class _ShellState extends ConsumerState<_Shell> {
body: Column(
children: [
const _QuickCaptureBar(),
Expanded(child: child),
Expanded(
child: MediaQuery.removePadding(
context: context,
removeTop: true,
child: child,
),
),
],
),
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),
@@ -296,15 +484,20 @@ class _ShellState extends ConsumerState<_Shell> {
label: 'Briefing',
),
NavigationDestination(
icon: Icon(Icons.library_books_outlined),
selectedIcon: Icon(Icons.library_books),
label: 'Library',
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',
),
],
),
);
@@ -330,6 +523,7 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
@override
void dispose() {
_controller.dispose();
ref.read(voiceProvider.notifier).exitVoiceMode();
super.dispose();
}
@@ -345,33 +539,46 @@ 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);
if (!mounted) break;
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);
switch (result.type) {
case 'note':
ref.invalidate(notesProvider);
case 'task':
case 'todo':
ref.invalidate(tasksProvider);
}
} on NetworkException {
break;
} catch (_) {
if (mounted) {
await ref.read(captureQueueProvider.notifier).dequeue(text);
}
// Server error — drop from queue to prevent ghost items.
await ref.read(captureQueueProvider.notifier).dequeue(text);
}
}
}
Future<void> _toggleCaptureMic() async {
final voice = ref.read(voiceProvider);
if (voice.voiceModeActive) {
ref.read(voiceProvider.notifier).exitVoiceMode();
return;
}
await ref.read(voiceProvider.notifier).enterVoiceMode(
onTranscript: (transcript) async {
ref.read(captureWorkQueueProvider.notifier).enqueue(transcript);
},
enableTts: false,
onError: (msg) {
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(msg)));
},
);
}
String _hintForLocation(String location) {
if (location.startsWith(Routes.library) &&
location.contains('tasks')) return 'Add a task';
if (location.startsWith(Routes.knowledge)) return 'Capture a note…';
if (location.startsWith(Routes.projects)) return 'Capture a note';
if (location.startsWith(Routes.conversations)) return 'Ask Fabled…';
return 'Capture a note…';
}
@@ -411,7 +618,9 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
onSubmitted: (_) => _submit(),
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
hintText: _hintForLocation(location),
hintText: ref.watch(voiceProvider).voiceModeActive
? 'Listening…'
: _hintForLocation(location),
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 10),
@@ -441,6 +650,11 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
),
),
),
VoiceMicButton(
mode: ref.watch(voiceProvider).mode,
voiceModeActive: ref.watch(voiceProvider).voiceModeActive,
onTap: _toggleCaptureMic,
),
IconButton(
icon: const Icon(Icons.settings_outlined),
tooltip: 'Settings',
+5 -1
View File
@@ -9,11 +9,15 @@ abstract class Routes {
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 library = '/library';
static const news = '/news';
static const calendar = '/calendar';
static const projectTasks = '/projects/:id/tasks';
}
+10
View File
@@ -44,6 +44,16 @@ class _ErrorInterceptor extends Interceptor {
));
return;
}
if (err.type == DioExceptionType.receiveTimeout ||
err.type == DioExceptionType.sendTimeout) {
handler.reject(DioException(
requestOptions: err.requestOptions,
error: const AppException('Request timed out. The server is taking too long to respond.'),
type: err.type,
response: err.response,
));
return;
}
handler.next(err);
}
}
+36 -1
View File
@@ -35,7 +35,7 @@ class BriefingApi {
}
}
/// GET /api/briefing/conversations/<id>/messages
/// GET /api/briefing/conversations/`<id>`/messages
Future<List<Message>> getMessages(int convId) async {
try {
final response =
@@ -59,4 +59,39 @@ class BriefingApi {
throw dioToApp(e);
}
}
/// POST /api/briefing/rss-reactions body: {rss_item_id, reaction: "up"|"down"}
Future<void> postRssReaction(int rssItemId, String reaction) async {
try {
await _dio.post('/api/briefing/rss-reactions',
data: {'rss_item_id': rssItemId, 'reaction': reaction});
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// 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 {
await _dio.delete('/api/briefing/rss-reactions/$rssItemId');
} on DioException catch (e) {
throw dioToApp(e);
}
}
}
+38 -5
View File
@@ -6,6 +6,18 @@ 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);
}
class ChatApi {
final Dio _dio;
const ChatApi(this._dio);
@@ -71,8 +83,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 +120,21 @@ 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 (line.isEmpty) {
@@ -127,4 +146,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);
}
}
}
+86
View File
@@ -0,0 +1,86 @@
import 'package:dio/dio.dart';
import '../models/knowledge_item.dart';
import 'api_client.dart';
class KnowledgeApi {
final Dio _dio;
const KnowledgeApi(this._dio);
/// Fetch a page of IDs. Returns (ids, total).
Future<(List<int>, int)> fetchIds({
String? noteType,
List<String> tags = const [],
String sort = 'modified',
String? q,
int limit = 50,
int offset = 0,
}) async {
try {
final params = <String, dynamic>{
'limit': limit,
'offset': offset,
'sort': sort,
if (noteType != null) 'type': noteType,
if (tags.isNotEmpty) 'tags': tags.join(','),
if (q != null && q.isNotEmpty) 'q': q,
};
final response =
await _dio.get('/api/knowledge/ids', queryParameters: params);
final data = response.data as Map<String, dynamic>;
final ids =
(data['ids'] as List<dynamic>).map((e) => e as int).toList();
final total = data['total'] as int;
return (ids, total);
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// Batch-hydrate up to 100 IDs into full items.
Future<List<KnowledgeItem>> fetchBatch(List<int> ids) async {
if (ids.isEmpty) return [];
try {
final response = await _dio.get(
'/api/knowledge/batch',
queryParameters: {'ids': ids.join(',')},
);
final data = response.data as Map<String, dynamic>;
return (data['items'] as List<dynamic>)
.map((e) => KnowledgeItem.fromJson(e as Map<String, dynamic>))
.toList();
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// Per-type counts for tab labels.
Future<Map<String, int>> fetchCounts({List<String> tags = const []}) async {
try {
final params = <String, dynamic>{
if (tags.isNotEmpty) 'tags': tags.join(','),
};
final response = await _dio.get('/api/knowledge/counts',
queryParameters: params);
return (response.data as Map<String, dynamic>)
.map((k, v) => MapEntry(k, (v as num).toInt()));
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// All tags for the current type filter.
Future<List<String>> fetchTags({String? noteType}) async {
try {
final response = await _dio.get(
'/api/knowledge/tags',
queryParameters: {if (noteType != null) 'type': noteType},
);
return (response.data['tags'] as List<dynamic>)
.map((e) => e as String)
.toList();
} 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);
}
}
}
+4
View File
@@ -32,12 +32,14 @@ class NotesApi {
String body, {
List<String> tags = const [],
int? projectId,
String noteType = 'note',
}) async {
try {
final response = await _dio.post('/api/notes', data: {
'title': title,
'body': body,
'tags': tags,
'note_type': noteType,
if (projectId != null) 'project_id': projectId,
});
return Note.fromJson(response.data as Map<String, dynamic>);
@@ -53,12 +55,14 @@ class NotesApi {
List<String> tags = const [],
int? projectId,
bool clearProject = false,
String noteType = 'note',
}) async {
try {
final response = await _dio.put('/api/notes/$id', data: {
'title': title,
'body': body,
'tags': tags,
'note_type': noteType,
if (clearProject) 'project_id': null else if (projectId != null) 'project_id': projectId,
});
return Note.fromJson(response.data as Map<String, dynamic>);
+12 -2
View File
@@ -7,11 +7,19 @@ class ProjectsApi {
final Dio _dio;
const ProjectsApi(this._dio);
Future<List<Project>> getAll({String? status}) async {
Future<List<Project>> getAll({
String? status,
String sort = 'updated_at',
String order = 'desc',
}) async {
try {
final response = await _dio.get(
'/api/projects',
queryParameters: status != null ? {'status': status} : null,
queryParameters: {
'sort': sort,
'order': order,
if (status != null) 'status': status,
},
);
final data = response.data as Map<String, dynamic>;
final list = data['projects'] as List<dynamic>;
@@ -37,10 +45,12 @@ class ProjectsApi {
String? description,
String? goal,
String? color,
String status = 'active',
}) async {
try {
final response = await _dio.post('/api/projects', data: {
'title': title,
'status': status,
if (description != null && description.isNotEmpty)
'description': description,
if (goal != null && goal.isNotEmpty) 'goal': goal,
+1
View File
@@ -40,6 +40,7 @@ class QuickCaptureApi {
final response = await _dio.post(
'/api/quick-capture',
data: {'text': text},
options: Options(receiveTimeout: const Duration(seconds: 120)),
);
return CaptureResult.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
+13
View File
@@ -0,0 +1,13 @@
import 'package:dio/dio.dart';
class SettingsApi {
const SettingsApi(this._dio);
final Dio _dio;
Future<void> syncTimezone(String ianaTimezone) async {
await _dio.put<void>(
'/api/settings',
data: {'user_timezone': ianaTimezone},
);
}
}
+18
View File
@@ -52,6 +52,24 @@ class TasksApi {
}
}
Future<List<Task>> getByProject(int projectId) async {
try {
final response = await _dio.get(
'/api/notes',
queryParameters: {
'project_id': projectId,
'is_task': 'true',
'limit': 500,
},
);
final data = response.data as Map<String, dynamic>;
final list = data['notes'] as List<dynamic>;
return list.map((e) => Task.fromJson(e as Map<String, dynamic>)).toList();
} on DioException catch (e) {
throw dioToApp(e);
}
}
Future<List<Task>> getSubTasks(int parentId) async {
try {
final response = await _dio.get(
+85
View File
@@ -0,0 +1,85 @@
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'api_client.dart';
class VoiceStatus {
final bool enabled;
final bool stt;
final bool tts;
const VoiceStatus({
required this.enabled,
required this.stt,
required this.tts,
});
/// True only when voice is enabled AND both STT and TTS are ready.
bool get fullyAvailable => enabled && stt && tts;
factory VoiceStatus.fromJson(Map<String, dynamic> json) => VoiceStatus(
enabled: json['enabled'] as bool? ?? false,
stt: json['stt'] as bool? ?? false,
tts: json['tts'] as bool? ?? false,
);
}
class VoiceApi {
final Dio _dio;
const VoiceApi(this._dio);
/// Check whether voice features are available on this server.
Future<VoiceStatus> checkStatus() async {
try {
final response = await _dio.get('/api/voice/status');
return VoiceStatus.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// 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, {String? context}) async {
try {
final fields = <String, dynamic>{
'audio': MultipartFile.fromBytes(
audioBytes,
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,
options: Options(
receiveTimeout: const Duration(seconds: 60),
contentType: 'multipart/form-data',
),
);
final data = response.data as Map<String, dynamic>;
return (data['transcript'] as String? ?? '').trim();
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// POST text and return raw WAV bytes.
Future<Uint8List> synthesise(String text) async {
try {
final response = await _dio.post(
'/api/voice/synthesise',
data: {'text': text},
options: Options(responseType: ResponseType.bytes),
);
return Uint8List.fromList(response.data as List<int>);
} on DioException catch (e) {
throw dioToApp(e);
}
}
}
+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);
+69
View File
@@ -0,0 +1,69 @@
import 'task.dart';
class KnowledgeItem {
final int id;
final String noteType; // 'note' | 'person' | 'place' | 'list' | 'task'
final String title;
final String body;
final List<String> tags;
final int? projectId;
final int? milestoneId;
final int? parentId;
// Task-only fields (null for non-tasks)
final String? status; // 'todo' | 'in_progress' | 'done' | 'cancelled'
final String? priority; // 'low' | 'normal' | 'high'
final String? dueDate;
final DateTime createdAt;
final DateTime updatedAt;
const KnowledgeItem({
required this.id,
required this.noteType,
required this.title,
required this.body,
required this.tags,
this.projectId,
this.milestoneId,
this.parentId,
this.status,
this.priority,
this.dueDate,
required this.createdAt,
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',
title: json['title'] as String? ?? '',
body: json['body'] as String? ?? '',
tags: (json['tags'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
[],
projectId: json['project_id'] as int?,
milestoneId: json['milestone_id'] as int?,
parentId: json['parent_id'] as int?,
status: json['status'] as String?,
priority: json['priority'] as String?,
dueDate: json['due_date'] as String?,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
);
}
+4
View File
@@ -7,6 +7,7 @@ class Message {
final String content;
final String status; // "complete" | "generating"
final DateTime? createdAt;
final Map<String, dynamic>? metadata;
const Message({
this.id,
@@ -15,6 +16,7 @@ class Message {
required this.content,
this.status = 'complete',
this.createdAt,
this.metadata,
});
factory Message.fromJson(Map<String, dynamic> json) => Message(
@@ -26,6 +28,7 @@ class Message {
createdAt: json['created_at'] != null
? DateTime.parse(json['created_at'] as String)
: null,
metadata: json['metadata'] as Map<String, dynamic>?,
);
Message copyWith({String? content, String? status}) => Message(
@@ -35,5 +38,6 @@ class Message {
content: content ?? this.content,
status: status ?? this.status,
createdAt: createdAt,
metadata: metadata,
);
}
+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?,
);
}
+6
View File
@@ -3,6 +3,7 @@ class Note {
final String title;
final String body;
final List<String> tags;
final String noteType;
final int? projectId;
final int? milestoneId;
final DateTime createdAt;
@@ -13,6 +14,7 @@ class Note {
required this.title,
required this.body,
required this.tags,
this.noteType = 'note',
this.projectId,
this.milestoneId,
required this.createdAt,
@@ -27,6 +29,7 @@ class Note {
?.map((e) => e as String)
.toList() ??
[],
noteType: json['note_type'] as String? ?? 'note',
projectId: json['project_id'] as int?,
milestoneId: json['milestone_id'] as int?,
createdAt: DateTime.parse(json['created_at'] as String),
@@ -37,6 +40,7 @@ class Note {
'title': title,
'body': body,
'tags': tags,
'note_type': noteType,
'project_id': projectId,
'milestone_id': milestoneId,
};
@@ -45,6 +49,7 @@ class Note {
String? title,
String? body,
List<String>? tags,
String? noteType,
Object? projectId = _undefined,
Object? milestoneId = _undefined,
}) =>
@@ -53,6 +58,7 @@ class Note {
title: title ?? this.title,
body: body ?? this.body,
tags: tags ?? this.tags,
noteType: noteType ?? this.noteType,
projectId: identical(projectId, _undefined)
? this.projectId
: projectId as int?,
+4
View File
@@ -5,6 +5,7 @@ class Project {
final String? goal;
final String status; // active | completed | archived
final String? color;
final String? autoSummary;
final DateTime createdAt;
final DateTime updatedAt;
@@ -15,6 +16,7 @@ class Project {
this.goal,
required this.status,
this.color,
this.autoSummary,
required this.createdAt,
required this.updatedAt,
});
@@ -26,6 +28,7 @@ class Project {
goal: json['goal'] as String?,
status: json['status'] as String? ?? 'active',
color: json['color'] as String?,
autoSummary: json['auto_summary'] as String?,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
);
@@ -36,5 +39,6 @@ class Project {
'goal': goal,
'status': status,
'color': color,
'auto_summary': autoSummary,
};
}
+2 -1
View File
@@ -1,4 +1,5 @@
import '../api/chat_api.dart';
export '../api/chat_api.dart' show ChatStreamEvent, ChatTextChunk, ChatStatusUpdate;
import '../models/conversation.dart';
import '../models/message.dart';
@@ -14,6 +15,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);
}
@@ -0,0 +1,33 @@
import '../api/knowledge_api.dart';
import '../models/knowledge_item.dart';
class KnowledgeRepository {
final KnowledgeApi _api;
const KnowledgeRepository(this._api);
Future<(List<int>, int)> fetchIds({
String? noteType,
List<String> tags = const [],
String sort = 'modified',
String? q,
int limit = 50,
int offset = 0,
}) =>
_api.fetchIds(
noteType: noteType,
tags: tags,
sort: sort,
q: q,
limit: limit,
offset: offset,
);
Future<List<KnowledgeItem>> fetchBatch(List<int> ids) =>
_api.fetchBatch(ids);
Future<Map<String, int>> fetchCounts({List<String> tags = const []}) =>
_api.fetchCounts(tags: tags);
Future<List<String>> fetchTags({String? noteType}) =>
_api.fetchTags(noteType: noteType);
}
+8 -2
View File
@@ -13,8 +13,10 @@ class NotesRepository {
String body, {
List<String> tags = const [],
int? projectId,
String noteType = 'note',
}) =>
_api.create(title, body, tags: tags, projectId: projectId);
_api.create(title, body,
tags: tags, projectId: projectId, noteType: noteType);
Future<Note> update(
int id,
@@ -23,9 +25,13 @@ class NotesRepository {
List<String> tags = const [],
int? projectId,
bool clearProject = false,
String noteType = 'note',
}) =>
_api.update(id, title, body,
tags: tags, projectId: projectId, clearProject: clearProject);
tags: tags,
projectId: projectId,
clearProject: clearProject,
noteType: noteType);
Future<void> delete(int id) => _api.delete(id);
}
+12 -2
View File
@@ -5,16 +5,26 @@ class ProjectsRepository {
final ProjectsApi _api;
const ProjectsRepository(this._api);
Future<List<Project>> getAll({String? status}) => _api.getAll(status: status);
Future<List<Project>> getAll({
String? status,
String sort = 'updated_at',
String order = 'desc',
}) =>
_api.getAll(status: status, sort: sort, order: order);
Future<Project> getOne(int id) => _api.getOne(id);
Future<Project> create({
required String title,
String? description,
String? goal,
String? color,
String status = 'active',
}) =>
_api.create(
title: title, description: description, goal: goal, color: color);
title: title,
description: description,
goal: goal,
color: color,
status: status);
Future<Project> update(int id, Map<String, dynamic> fields) =>
_api.update(id, fields);
Future<void> delete(int id) => _api.delete(id);
@@ -27,6 +27,7 @@ class TasksRepository {
parentId: parentId,
);
Future<List<Task>> getByProject(int projectId) => _api.getByProject(projectId);
Future<List<Task>> getSubTasks(int parentId) => _api.getSubTasks(parentId);
Future<Task> update(int id, Map<String, dynamic> fields) =>
@@ -0,0 +1,13 @@
import 'dart:typed_data';
import '../api/voice_api.dart';
class VoiceRepository {
final VoiceApi _api;
const VoiceRepository(this._api);
Future<VoiceStatus> checkStatus() => _api.checkStatus();
Future<String> transcribe(Uint8List audioBytes, {String? context}) =>
_api.transcribe(audioBytes, context: context);
Future<Uint8List> synthesise(String text) => _api.synthesise(text);
}
+35
View File
@@ -6,13 +6,20 @@ import '../data/api/api_client.dart';
import '../data/api/auth_api.dart';
import '../data/api/briefing_api.dart';
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';
import '../data/api/settings_api.dart';
import '../data/api/tasks_api.dart';
import '../data/repositories/auth_repository.dart';
import '../data/repositories/chat_repository.dart';
import '../data/repositories/knowledge_repository.dart';
import '../data/repositories/voice_repository.dart';
import '../data/repositories/milestones_repository.dart';
import '../data/repositories/notes_repository.dart';
import '../data/repositories/projects_repository.dart';
@@ -82,6 +89,34 @@ final milestonesRepositoryProvider = Provider<MilestonesRepository>((ref) {
return MilestonesRepository(ref.watch(milestonesApiProvider));
});
final knowledgeApiProvider = Provider<KnowledgeApi>((ref) {
return KnowledgeApi(ref.watch(dioProvider));
});
final knowledgeRepositoryProvider = Provider<KnowledgeRepository>((ref) {
return KnowledgeRepository(ref.watch(knowledgeApiProvider));
});
final briefingApiProvider = Provider<BriefingApi>((ref) {
return BriefingApi(ref.watch(dioProvider));
});
final settingsApiProvider = Provider<SettingsApi>((ref) {
return SettingsApi(ref.watch(dioProvider));
});
final voiceApiProvider = Provider<VoiceApi>((ref) {
return VoiceApi(ref.watch(dioProvider));
});
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));
});
+7 -10
View File
@@ -4,18 +4,15 @@ import 'api_client_provider.dart';
enum AuthStatus { unknown, authenticated, unauthenticated }
final authProvider = StateNotifierProvider<AuthNotifier, AuthStatus>((ref) {
return AuthNotifier(ref);
});
final authProvider = NotifierProvider<AuthNotifier, AuthStatus>(AuthNotifier.new);
class AuthNotifier extends StateNotifier<AuthStatus> {
final Ref _ref;
AuthNotifier(this._ref) : super(AuthStatus.unknown);
class AuthNotifier extends Notifier<AuthStatus> {
@override
AuthStatus build() => AuthStatus.unknown;
Future<void> verify() async {
try {
final repo = _ref.read(authRepositoryProvider);
final repo = ref.read(authRepositoryProvider);
final ok = await repo.verify();
state = ok ? AuthStatus.authenticated : AuthStatus.unauthenticated;
} catch (_) {
@@ -24,14 +21,14 @@ class AuthNotifier extends StateNotifier<AuthStatus> {
}
Future<void> login(String username, String password) async {
final repo = _ref.read(authRepositoryProvider);
final repo = ref.read(authRepositoryProvider);
await repo.login(username, password);
state = AuthStatus.authenticated;
}
Future<void> logout() async {
try {
final repo = _ref.read(authRepositoryProvider);
final repo = ref.read(authRepositoryProvider);
await repo.logout();
} finally {
state = AuthStatus.unauthenticated;
+114 -7
View File
@@ -1,11 +1,18 @@
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';
/// Drives the loading indicator in BriefingScreen's reply area.
final isBriefingStreamingProvider = StateProvider<bool>((ref) => false);
final isBriefingStreamingProvider =
NotifierProvider<_BoolNotifier, bool>(_BoolNotifier.new);
class _BoolNotifier extends Notifier<bool> {
@override
bool build() => false;
}
final briefingProvider =
AsyncNotifierProvider<BriefingNotifier, BriefingConversation>(
@@ -17,6 +24,24 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
return ref.read(briefingApiProvider).getToday();
}
/// Silently fetch the latest briefing and patch state without triggering
/// AsyncLoading — existing content stays visible while the fetch is in flight.
Future<void> silentRefresh() async {
final current = state.value;
if (current == null) return;
try {
final fresh = await ref.read(briefingApiProvider).getToday();
final curLast = current.messages.isNotEmpty ? current.messages.last : null;
final newLast = fresh.messages.isNotEmpty ? fresh.messages.last : null;
if (fresh.messages.length != current.messages.length ||
newLast?.content != curLast?.content) {
state = AsyncData(fresh);
}
} catch (_) {
// Network hiccup — silently ignore, keep existing content
}
}
/// Trigger a briefing slot (e.g. "compilation") then reload.
Future<void> refresh(String slot) async {
await ref.read(briefingApiProvider).triggerSlot(slot);
@@ -24,6 +49,86 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
await future;
}
/// 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;
}
// SSE stream (best-effort)
bool streamedContent = false;
try {
await for (final event in chatApi.streamGeneration(convId)) {
if (event is! ChatTextChunk) continue;
streamedContent = true;
final current = state.value;
if (current == null) break;
final msgs = current.messages;
if (msgs.isEmpty) continue;
final updated =
msgs.last.copyWith(content: msgs.last.content + event.text);
state = AsyncData(current.copyWith(
messages: [...msgs.sublist(0, msgs.length - 1), updated]));
}
} catch (_) {
// Fall through to polling.
}
// Poll until complete (max 20 attempts, 2s apart)
try {
for (var attempt = 0; attempt < 20; attempt++) {
if (attempt > 0) await Future.delayed(const Duration(seconds: 2));
final fresh = await briefingApi.getMessages(convId);
final done = fresh.any(
(m) => m.role == MessageRole.assistant && m.status != 'generating',
);
final hasContent = fresh.any(
(m) => m.role == MessageRole.assistant && m.content.isNotEmpty,
);
final current = state.value;
if (current != null && (!streamedContent || done || hasContent)) {
state = AsyncData(current.copyWith(messages: fresh));
}
if (done) break;
}
} catch (_) {
final current = state.value;
if (current != null) {
final msgs = current.messages;
if (msgs.isNotEmpty && msgs.last.status == 'generating') {
state = AsyncData(current.copyWith(messages: [
...msgs.sublist(0, msgs.length - 1),
msgs.last.copyWith(status: 'complete'),
]));
}
}
} finally {
ref.read(isBriefingStreamingProvider.notifier).state = false;
}
}
/// Send a reply to today's briefing conversation.
///
/// Mirrors MessagesNotifier.sendMessage():
@@ -32,7 +137,7 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
/// 3. SSE stream (best-effort)
/// 4. Poll until complete
Future<void> sendReply(String content) async {
final conv = state.valueOrNull;
final conv = state.value;
if (conv == null) return;
final convId = conv.id;
final chatApi = ref.read(chatApiProvider);
@@ -63,13 +168,15 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
// SSE stream (best-effort)
bool streamedContent = false;
try {
await for (final chunk in chatApi.streamGeneration(convId)) {
await for (final event in chatApi.streamGeneration(convId)) {
if (event is! ChatTextChunk) continue;
streamedContent = true;
final current = state.valueOrNull;
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);
final updated =
msgs.last.copyWith(content: msgs.last.content + event.text);
state = AsyncData(current.copyWith(
messages: [...msgs.sublist(0, msgs.length - 1), updated]));
}
@@ -88,7 +195,7 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
final hasContent = fresh.any(
(m) => m.role == MessageRole.assistant && m.content.isNotEmpty,
);
final current = state.valueOrNull;
final current = state.value;
if (current != null && (!streamedContent || done || hasContent)) {
state = AsyncData(current.copyWith(messages: fresh));
}
@@ -96,7 +203,7 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
}
} catch (_) {
// Clear the generating placeholder so UI doesn't spin forever.
final current = state.valueOrNull;
final current = state.value;
if (current != null) {
final msgs = current.messages;
if (msgs.isNotEmpty && msgs.last.status == 'generating') {
+151
View File
@@ -0,0 +1,151 @@
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),
);
}
/// 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;
}
+10 -6
View File
@@ -4,16 +4,20 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'settings_provider.dart';
final captureQueueProvider =
StateNotifierProvider<CaptureQueueNotifier, List<String>>(
(ref) => CaptureQueueNotifier(ref.watch(sharedPreferencesProvider)),
NotifierProvider<CaptureQueueNotifier, List<String>>(
CaptureQueueNotifier.new,
);
class CaptureQueueNotifier extends StateNotifier<List<String>> {
class CaptureQueueNotifier extends Notifier<List<String>> {
static const _key = 'capture_queue';
final SharedPreferences _prefs;
CaptureQueueNotifier(this._prefs)
: super(_prefs.getStringList(_key) ?? []);
SharedPreferences get _prefs => ref.read(sharedPreferencesProvider);
@override
List<String> build() {
final prefs = ref.watch(sharedPreferencesProvider);
return prefs.getStringList(_key) ?? [];
}
Future<void> enqueue(String text) async {
final updated = [...state, text];
+32 -41
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 {
@@ -15,20 +14,26 @@ class CaptureResult {
/// The most recent capture result. UI watches this to show snackbars.
/// Reset to null by the notifier before each new item so listeners always fire.
final captureResultProvider = StateProvider<CaptureResult?>((_) => null);
final captureResultProvider =
NotifierProvider<_CaptureResultNotifier, CaptureResult?>(
_CaptureResultNotifier.new);
class _CaptureResultNotifier extends Notifier<CaptureResult?> {
@override
CaptureResult? build() => null;
}
/// In-memory sequential work queue for quick captures.
/// Separate from [captureQueueProvider] (which is the offline persistence queue).
final captureWorkQueueProvider =
StateNotifierProvider<CaptureWorkQueueNotifier, List<String>>(
(ref) => CaptureWorkQueueNotifier(ref),
NotifierProvider<CaptureWorkQueueNotifier, List<String>>(
CaptureWorkQueueNotifier.new,
);
class CaptureWorkQueueNotifier extends StateNotifier<List<String>> {
final Ref _ref;
class CaptureWorkQueueNotifier extends Notifier<List<String>> {
bool _running = false;
CaptureWorkQueueNotifier(this._ref) : super([]);
@override
List<String> build() => [];
/// Add text to the queue and start the drain loop if not already running.
void enqueue(String text) {
@@ -43,56 +48,42 @@ class CaptureWorkQueueNotifier extends StateNotifier<List<String>> {
while (state.isNotEmpty) {
final text = state.first;
// Signal "no result yet" so the same result value can re-trigger watch.
_ref.read(captureResultProvider.notifier).state = null;
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);
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;
} on AppException catch (e) {
state = state.length > 1 ? state.sublist(1) : [];
_ref.read(captureResultProvider.notifier).state =
ref.read(captureResultProvider.notifier).state =
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,
};
}
+58 -19
View File
@@ -2,10 +2,36 @@ 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 StateProvider so UI re-builds immediately when streaming starts/stops.
final isStreamingProvider = StateProvider.family<bool, int>((ref, _) => false);
// Separate NotifierProvider.family so UI re-builds immediately when streaming starts/stops.
final isStreamingProvider =
NotifierProvider.family<_IsStreamingNotifier, bool, int>(
(convId) => _IsStreamingNotifier(convId),
);
class _IsStreamingNotifier extends Notifier<bool> {
// ignore: avoid_unused_constructor_parameters
_IsStreamingNotifier(int convId);
@override
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>>(
@@ -20,14 +46,14 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
Future<Conversation> create(String title) async {
final conv =
await ref.read(chatRepositoryProvider).createConversation(title);
state = AsyncData([conv, ...state.valueOrNull ?? []]);
state = AsyncData([conv, ...state.value ?? []]);
return conv;
}
Future<void> delete(int id) async {
await ref.read(chatRepositoryProvider).deleteConversation(id);
state = AsyncData([
for (final c in state.valueOrNull ?? [])
for (final c in state.value ?? [])
if (c.id != id) c,
]);
}
@@ -35,7 +61,7 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
// 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) {
final list = state.valueOrNull;
final list = state.value;
if (list == null) return;
state = AsyncData([
for (final c in list)
@@ -44,21 +70,26 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
}
}
final messagesProvider = AsyncNotifierProvider.family<MessagesNotifier,
List<Message>, int>(MessagesNotifier.new);
final messagesProvider =
AsyncNotifierProvider.family<MessagesNotifier, List<Message>, int>(
(convId) => MessagesNotifier(convId),
);
class MessagesNotifier extends AsyncNotifier<List<Message>> {
final int _convId;
MessagesNotifier(this._convId);
class MessagesNotifier extends FamilyAsyncNotifier<List<Message>, int> {
@override
Future<List<Message>> build(int arg) async {
Future<List<Message>> build() async {
final (_, messages) =
await ref.watch(chatRepositoryProvider).getMessages(arg);
await ref.watch(chatRepositoryProvider).getMessages(_convId);
return messages;
}
Future<void> sendMessage(String content) async {
final convId = arg;
final convId = _convId;
final repo = ref.read(chatRepositoryProvider);
final previousMessages = state.valueOrNull ?? [];
final previousMessages = state.value ?? [];
// Optimistic UI: show the user message + assistant placeholder immediately.
final userMsg = Message(
@@ -87,12 +118,19 @@ class MessagesNotifier extends FamilyAsyncNotifier<List<Message>, int> {
// ── Step 2: Stream the response (best effort — silent on failure). ──
bool streamedContent = false;
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]);
await for (final event in repo.streamGeneration(convId)) {
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;
}
}
} catch (_) {
// SSE failed — fall through to the polling reload below.
@@ -132,7 +170,7 @@ class MessagesNotifier extends FamilyAsyncNotifier<List<Message>, int> {
} catch (_) {
// Polling failed entirely — clear the generating placeholder so the UI
// doesn't spin forever.
final msgs = state.valueOrNull;
final msgs = state.value;
if (msgs != null && msgs.isNotEmpty && msgs.last.status == 'generating') {
state = AsyncData([
...msgs.sublist(0, msgs.length - 1),
@@ -141,6 +179,7 @@ class MessagesNotifier extends FamilyAsyncNotifier<List<Message>, int> {
}
} finally {
ref.read(isStreamingProvider(convId).notifier).state = false;
ref.read(streamingStatusProvider(convId).notifier).state = '';
}
}
}
+239
View File
@@ -0,0 +1,239 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
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;
final Map<int, KnowledgeItem> items;
final int totalIds;
final bool isLoadingIds;
final bool isLoadingBatch;
final bool hasMore;
final String? noteType; // null = All
final List<String> activeTags;
final String? searchQuery;
final Map<String, int> counts;
final List<String> availableTags;
final String? error;
const KnowledgeState({
this.ids = const [],
this.items = const {},
this.totalIds = 0,
this.isLoadingIds = false,
this.isLoadingBatch = false,
this.hasMore = false,
this.noteType,
this.activeTags = const [],
this.searchQuery,
this.counts = const {},
this.availableTags = const [],
this.error,
});
/// Items in server-defined ID order, only those already hydrated.
List<KnowledgeItem> get orderedItems =>
ids.where(items.containsKey).map((id) => items[id]!).toList();
/// IDs that have been fetched but not yet hydrated.
List<int> get unhydratedIds =>
ids.where((id) => !items.containsKey(id)).toList();
KnowledgeState copyWith({
List<int>? ids,
Map<int, KnowledgeItem>? items,
int? totalIds,
bool? isLoadingIds,
bool? isLoadingBatch,
bool? hasMore,
Object? noteType = _keep,
List<String>? activeTags,
Object? searchQuery = _keep,
Map<String, int>? counts,
List<String>? availableTags,
Object? error = _keep,
}) =>
KnowledgeState(
ids: ids ?? this.ids,
items: items ?? this.items,
totalIds: totalIds ?? this.totalIds,
isLoadingIds: isLoadingIds ?? this.isLoadingIds,
isLoadingBatch: isLoadingBatch ?? this.isLoadingBatch,
hasMore: hasMore ?? this.hasMore,
noteType:
identical(noteType, _keep) ? this.noteType : noteType as String?,
activeTags: activeTags ?? this.activeTags,
searchQuery: identical(searchQuery, _keep)
? this.searchQuery
: searchQuery as String?,
counts: counts ?? this.counts,
availableTags: availableTags ?? this.availableTags,
error: identical(error, _keep) ? this.error : error as String?,
);
static const _keep = Object();
}
class KnowledgeNotifier extends Notifier<KnowledgeState> {
@override
KnowledgeState build() => const KnowledgeState();
KnowledgeRepository get _repo => ref.read(knowledgeRepositoryProvider);
// ── Filter setters — each resets and re-fetches ──────────────────────────
Future<void> setTypeFilter(String? noteType) async {
state = KnowledgeState(noteType: noteType, activeTags: state.activeTags);
await _fetchFromScratch();
}
Future<void> toggleTag(String tag) async {
final tags = state.activeTags.contains(tag)
? state.activeTags.where((t) => t != tag).toList()
: [...state.activeTags, tag];
state = KnowledgeState(
noteType: state.noteType,
activeTags: tags,
searchQuery: state.searchQuery,
);
await _fetchFromScratch();
}
Future<void> setSearch(String? q) async {
final query = (q?.trim().isEmpty ?? true) ? null : q?.trim();
state = KnowledgeState(
noteType: state.noteType,
activeTags: state.activeTags,
searchQuery: query,
);
await _fetchFromScratch();
}
Future<void> refresh() async {
state = KnowledgeState(
noteType: state.noteType,
activeTags: state.activeTags,
searchQuery: state.searchQuery,
);
await _fetchFromScratch();
}
// ── Scroll-triggered loaders ─────────────────────────────────────────────
/// Hydrate the next 12 un-hydrated IDs. Call when approaching scroll end.
Future<void> hydrateNext() async {
if (state.isLoadingBatch) return;
final toFetch = state.unhydratedIds.take(12).toList();
if (toFetch.isEmpty) {
// All fetched IDs are hydrated — try loading more IDs.
if (state.hasMore && !state.isLoadingIds) await _loadMoreIds();
return;
}
state = state.copyWith(isLoadingBatch: true);
try {
final fetched = await _repo.fetchBatch(toFetch);
final updated = Map<int, KnowledgeItem>.from(state.items);
for (final item in fetched) {
updated[item.id] = item;
}
state = state.copyWith(items: updated, isLoadingBatch: false);
} catch (_) {
state = state.copyWith(isLoadingBatch: false);
}
}
// ── Private helpers ──────────────────────────────────────────────────────
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,
q: state.searchQuery,
limit: 50,
offset: 0,
);
state = state.copyWith(
ids: ids,
items: {},
totalIds: total,
isLoadingIds: false,
hasMore: ids.length < total,
);
// Load counts and tags in parallel with the first batch hydration.
await Future.wait([
hydrateNext(),
_loadCounts(),
_loadTags(),
]);
} catch (e) {
state = state.copyWith(
isLoadingIds: false,
error: e.toString(),
);
}
}
Future<void> _loadMoreIds() async {
if (!state.hasMore || state.isLoadingIds) return;
state = state.copyWith(isLoadingIds: true);
try {
final (newIds, total) = await _repo.fetchIds(
noteType: state.noteType,
tags: state.activeTags,
q: state.searchQuery,
limit: 50,
offset: state.ids.length,
);
final combined = [...state.ids, ...newIds];
state = state.copyWith(
ids: combined,
totalIds: total,
isLoadingIds: false,
hasMore: combined.length < total,
);
} catch (_) {
state = state.copyWith(isLoadingIds: false);
}
}
Future<void> _loadCounts() async {
try {
final counts = await _repo.fetchCounts(tags: state.activeTags);
state = state.copyWith(counts: counts);
} catch (_) {}
}
Future<void> _loadTags() async {
try {
final tags = await _repo.fetchTags(noteType: state.noteType);
state = state.copyWith(availableTags: tags);
} catch (_) {}
}
}
final knowledgeProvider =
NotifierProvider<KnowledgeNotifier, KnowledgeState>(KnowledgeNotifier.new);
+158
View File
@@ -0,0 +1,158 @@
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},
);
}
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();
}
}
+12 -6
View File
@@ -17,11 +17,15 @@ class NotesNotifier extends AsyncNotifier<List<Note>> {
String body, {
List<String> tags = const [],
int? projectId,
String noteType = 'note',
}) async {
final note = await ref
.read(notesRepositoryProvider)
.create(title, body, tags: tags, projectId: projectId);
state = AsyncData([...state.valueOrNull ?? [], note]);
final note = await ref.read(notesRepositoryProvider).create(
title, body,
tags: tags,
projectId: projectId,
noteType: noteType,
);
state = AsyncData([...state.value ?? [], note]);
return note;
}
@@ -32,6 +36,7 @@ class NotesNotifier extends AsyncNotifier<List<Note>> {
List<String> tags = const [],
int? projectId,
bool clearProject = false,
String noteType = 'note',
}) async {
final updated = await ref.read(notesRepositoryProvider).update(
id,
@@ -40,9 +45,10 @@ class NotesNotifier extends AsyncNotifier<List<Note>> {
tags: tags,
projectId: projectId,
clearProject: clearProject,
noteType: noteType,
);
state = AsyncData([
for (final n in state.valueOrNull ?? [])
for (final n in state.value ?? [])
if (n.id == id) updated else n,
]);
return updated;
@@ -51,7 +57,7 @@ class NotesNotifier extends AsyncNotifier<List<Note>> {
Future<void> delete(int id) async {
await ref.read(notesRepositoryProvider).delete(id);
state = AsyncData([
for (final n in state.valueOrNull ?? [])
for (final n in state.value ?? [])
if (n.id != id) n,
]);
}
+6 -4
View File
@@ -10,7 +10,9 @@ final projectsProvider =
class ProjectsNotifier extends AsyncNotifier<List<Project>> {
@override
Future<List<Project>> build() async {
return ref.watch(projectsRepositoryProvider).getAll();
return ref
.watch(projectsRepositoryProvider)
.getAll(sort: 'updated_at', order: 'desc');
}
Future<Project> create({
@@ -25,7 +27,7 @@ class ProjectsNotifier extends AsyncNotifier<List<Project>> {
goal: goal,
color: color,
);
state = AsyncData([...state.valueOrNull ?? [], project]);
state = AsyncData([...state.value ?? [], project]);
return project;
}
@@ -33,7 +35,7 @@ class ProjectsNotifier extends AsyncNotifier<List<Project>> {
final updated =
await ref.read(projectsRepositoryProvider).update(id, fields);
state = AsyncData([
for (final p in state.valueOrNull ?? [])
for (final p in state.value ?? [])
if (p.id == id) updated else p,
]);
return updated;
@@ -42,7 +44,7 @@ class ProjectsNotifier extends AsyncNotifier<List<Project>> {
Future<void> delete(int id) async {
await ref.read(projectsRepositoryProvider).delete(id);
state = AsyncData([
for (final p in state.valueOrNull ?? [])
for (final p in state.value ?? [])
if (p.id != id) p,
]);
}
+28 -29
View File
@@ -15,16 +15,14 @@ final cookiesPathProvider = Provider<String>((ref) {
});
final themeModeProvider =
StateNotifierProvider<ThemeModeNotifier, ThemeMode>((ref) {
final prefs = ref.watch(sharedPreferencesProvider);
return ThemeModeNotifier(prefs);
});
NotifierProvider<ThemeModeNotifier, ThemeMode>(ThemeModeNotifier.new);
class ThemeModeNotifier extends StateNotifier<ThemeMode> {
final SharedPreferences _prefs;
ThemeModeNotifier(this._prefs)
: super(_fromString(_prefs.getString(_kThemeMode)));
class ThemeModeNotifier extends Notifier<ThemeMode> {
@override
ThemeMode build() {
final prefs = ref.watch(sharedPreferencesProvider);
return _fromString(prefs.getString(_kThemeMode));
}
static ThemeMode _fromString(String? value) => switch (value) {
'light' => ThemeMode.light,
@@ -38,48 +36,49 @@ class ThemeModeNotifier extends StateNotifier<ThemeMode> {
ThemeMode.dark => 'dark',
_ => 'system',
};
await _prefs.setString(_kThemeMode, value);
await ref.read(sharedPreferencesProvider).setString(_kThemeMode, value);
state = mode;
}
}
final forgejoRepoUrlProvider =
StateNotifierProvider<ForgejoRepoUrlNotifier, String?>((ref) {
return ForgejoRepoUrlNotifier(ref.watch(sharedPreferencesProvider));
});
NotifierProvider<ForgejoRepoUrlNotifier, String?>(
ForgejoRepoUrlNotifier.new);
class ForgejoRepoUrlNotifier extends StateNotifier<String?> {
final SharedPreferences _prefs;
ForgejoRepoUrlNotifier(this._prefs)
: super(_prefs.getString(_kForgejoRepoUrl) ??
'https://git.fabledsword.com/bvandeusen/FabledApp');
class ForgejoRepoUrlNotifier extends Notifier<String?> {
@override
String? build() {
final prefs = ref.watch(sharedPreferencesProvider);
return prefs.getString(_kForgejoRepoUrl) ??
'https://git.fabledsword.com/bvandeusen/FabledApp';
}
Future<void> setUrl(String url) async {
final clean = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
await _prefs.setString(_kForgejoRepoUrl, clean);
await ref.read(sharedPreferencesProvider).setString(_kForgejoRepoUrl, clean);
state = clean;
}
}
final serverUrlProvider = StateNotifierProvider<ServerUrlNotifier, String?>((ref) {
final prefs = ref.watch(sharedPreferencesProvider);
return ServerUrlNotifier(prefs);
});
final serverUrlProvider =
NotifierProvider<ServerUrlNotifier, String?>(ServerUrlNotifier.new);
class ServerUrlNotifier extends StateNotifier<String?> {
final SharedPreferences _prefs;
ServerUrlNotifier(this._prefs) : super(_prefs.getString(_kServerUrl));
class ServerUrlNotifier extends Notifier<String?> {
@override
String? build() {
final prefs = ref.watch(sharedPreferencesProvider);
return prefs.getString(_kServerUrl);
}
Future<void> setUrl(String url) async {
// Strip trailing slash
final clean = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
await _prefs.setString(_kServerUrl, clean);
await ref.read(sharedPreferencesProvider).setString(_kServerUrl, clean);
state = clean;
}
Future<void> clear() async {
await _prefs.remove(_kServerUrl);
await ref.read(sharedPreferencesProvider).remove(_kServerUrl);
state = null;
}
}
+8 -3
View File
@@ -6,6 +6,11 @@ import 'api_client_provider.dart';
final tasksProvider =
AsyncNotifierProvider<TasksNotifier, List<Task>>(TasksNotifier.new);
final projectTasksProvider =
FutureProvider.family<List<Task>, int>((ref, projectId) {
return ref.watch(tasksRepositoryProvider).getByProject(projectId);
});
class TasksNotifier extends AsyncNotifier<List<Task>> {
@override
Future<List<Task>> build() async {
@@ -28,14 +33,14 @@ class TasksNotifier extends AsyncNotifier<List<Task>> {
dueDate: dueDate,
projectId: projectId,
);
state = AsyncData([...state.valueOrNull ?? [], task]);
state = AsyncData([...state.value ?? [], task]);
return task;
}
Future<Task> updateTask(int id, Map<String, dynamic> fields) async {
final updated = await ref.read(tasksRepositoryProvider).update(id, fields);
state = AsyncData([
for (final t in state.valueOrNull ?? [])
for (final t in state.value ?? [])
if (t.id == id) updated else t,
]);
return updated;
@@ -44,7 +49,7 @@ class TasksNotifier extends AsyncNotifier<List<Task>> {
Future<void> delete(int id) async {
await ref.read(tasksRepositoryProvider).delete(id);
state = AsyncData([
for (final t in state.valueOrNull ?? [])
for (final t in state.value ?? [])
if (t.id != id) t,
]);
}
+27 -4
View File
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:open_file/open_file.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
enum UpdateStatus { idle, checking, available, downloading, upToDate, error }
@@ -102,6 +103,22 @@ class UpdateNotifier extends Notifier<UpdateState> {
Future<void> downloadAndInstall() async {
if (state.downloadUrl == null) return;
// Android 8+ requires explicit per-app "Install unknown apps" approval
// beyond the manifest declaration. Check and redirect to Settings if needed.
final installPermission = await Permission.requestInstallPackages.status;
if (!installPermission.isGranted) {
final result = await Permission.requestInstallPackages.request();
if (!result.isGranted) {
state = state.copyWith(
status: UpdateStatus.error,
errorMessage:
'Grant "Install unknown apps" permission for Fabled in Settings, then try again.',
);
return;
}
}
state = state.copyWith(status: UpdateStatus.downloading, downloadProgress: 0);
try {
@@ -119,14 +136,20 @@ class UpdateNotifier extends Notifier<UpdateState> {
},
);
await OpenFile.open(
final result = await OpenFile.open(
path,
type: 'application/vnd.android.package-archive',
);
// Transition to idle — the system installer is now open.
// Going back to `available` would re-trigger the update dialog loop.
state = const UpdateState();
if (result.type == ResultType.done) {
// Installer launched — reset to idle so the dialog closes naturally.
state = const UpdateState();
} else {
state = state.copyWith(
status: UpdateStatus.error,
errorMessage: 'Could not open installer: ${result.message}',
);
}
} catch (e) {
state = state.copyWith(
status: UpdateStatus.error,
+393
View File
@@ -0,0 +1,393 @@
import 'dart:async';
import 'dart:collection';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:just_audio/just_audio.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:record/record.dart';
import 'api_client_provider.dart';
// ── Public helpers (also used by tests) ──────────────────────────────────────
class SentenceResult {
final List<String> sentences;
final String remainder;
const SentenceResult({required this.sentences, required this.remainder});
}
/// Extract completed sentences from [text] at `.`, `!`, `?` boundaries.
/// Returns the completed sentences and the unconsumed remainder.
SentenceResult extractSentences(String text) {
final boundary = RegExp(r'[.!?]+(?=\s|$)');
final sentences = <String>[];
var remaining = text;
RegExpMatch? match;
while ((match = boundary.firstMatch(remaining)) != null) {
final end = match!.end;
final sentence = remaining.substring(0, end).trim();
if (sentence.isNotEmpty) sentences.add(sentence);
remaining = remaining.substring(end).trimLeft();
}
return SentenceResult(sentences: sentences, remainder: remaining);
}
/// Strip markdown formatting before sending text to TTS.
String stripMarkdownForTts(String text) {
return text
.replaceAll(RegExp(r'```[\s\S]*?```'), '') // fenced code blocks
.replaceAllMapped(RegExp(r'`([^`]+)`'), (m) => m[1]!) // inline code
.replaceAll(RegExp(r'#{1,6}\s+'), '') // headings
.replaceAllMapped(RegExp(r'\*\*([^*]+)\*\*'), (m) => m[1]!) // bold
.replaceAllMapped(RegExp(r'\*([^*]+)\*'), (m) => m[1]!) // italic
.replaceAllMapped(
RegExp(r'\[([^\]]+)\]\([^)]+\)'), (m) => m[1]!) // links → text
.replaceAll(RegExp(r'^\s*[-*+]\s+', multiLine: true), '') // list markers
.replaceAll(RegExp(r'\n{2,}'), ' ') // multiple newlines → space
.replaceAll('\n', ' ')
.trim();
}
// ── State ─────────────────────────────────────────────────────────────────────
enum VoiceMode { idle, recording, transcribing, playing }
class VoiceState {
final VoiceMode mode;
final bool voiceModeActive;
final bool available;
const VoiceState({
this.mode = VoiceMode.idle,
this.voiceModeActive = false,
this.available = true,
});
VoiceState copyWith({
VoiceMode? mode,
bool? voiceModeActive,
bool? available,
}) =>
VoiceState(
mode: mode ?? this.mode,
voiceModeActive: voiceModeActive ?? this.voiceModeActive,
available: available ?? this.available,
);
}
// ── Provider ──────────────────────────────────────────────────────────────────
final voiceProvider =
NotifierProvider<VoiceNotifier, VoiceState>(VoiceNotifier.new);
// ── Notifier ──────────────────────────────────────────────────────────────────
class VoiceNotifier extends Notifier<VoiceState> {
// Audio I/O
AudioRecorder? _recorder;
AudioPlayer? _player;
StreamSubscription<Amplitude>? _amplitudeSubscription;
// Recording / silence detection
int _recordingStartMs = 0;
int _silenceMs = 0;
static const _silenceThresholdDb = -40.0;
static const _silenceDurationMs = 1500;
static const _minRecordingMs = 300;
// Voice mode callbacks
Future<void> Function(String transcript)? _onTranscript;
void Function(String message)? _onError;
bool _enableTts = false;
// Streaming TTS state
String _sentenceBuffer = '';
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 = '';
// TTS playback queue
final _ttsQueue = Queue<Uint8List>();
bool _ttsPlaying = false;
int _ttsCounter = 0;
Directory? _tempDir;
@override
VoiceState build() {
_recorder = AudioRecorder();
_player = AudioPlayer();
ref.onDispose(() {
_amplitudeSubscription?.cancel();
_recorder?.dispose();
_player?.dispose();
});
return const VoiceState();
}
// ── Public API ──────────────────────────────────────────────────────────────
/// Enter voice mode. Checks server availability and mic permission first.
/// [onTranscript] is called with the transcript when a recording completes.
/// [enableTts] — if true, TTS plays when [feedContent] is called.
/// [onError] — called with a human-readable message on failure.
Future<void> enterVoiceMode({
required Future<void> Function(String transcript) onTranscript,
bool enableTts = false,
required void Function(String message) onError,
}) async {
if (state.voiceModeActive) {
exitVoiceMode();
return;
}
// Check server availability
try {
final status = await ref.read(voiceRepositoryProvider).checkStatus();
if (!status.fullyAvailable) {
onError('Voice not available on this server');
return;
}
} catch (_) {
onError('Voice not available on this server');
return;
}
// Check microphone permission
final permStatus = await Permission.microphone.request();
if (permStatus == PermissionStatus.denied ||
permStatus == PermissionStatus.permanentlyDenied) {
onError('Microphone permission required');
if (permStatus == PermissionStatus.permanentlyDenied) {
await openAppSettings();
}
return;
}
_onTranscript = onTranscript;
_onError = onError;
_enableTts = enableTts;
_tempDir = await getTemporaryDirectory();
state = state.copyWith(voiceModeActive: true, available: true);
await _startListening();
}
/// Exit voice mode, stop all recording and TTS.
void exitVoiceMode() {
_amplitudeSubscription?.cancel();
_amplitudeSubscription = null;
_recorder?.stop();
_player?.stop();
_ttsQueue.clear();
_ttsPlaying = false;
_sentenceBuffer = '';
_lastSeenLength = 0;
_streamComplete = false;
_onTranscript = null;
_onError = null;
state = const VoiceState();
}
/// Feed streaming assistant content for TTS synthesis.
/// Call from screens with the full [fullContent] string on each update.
/// Set [isComplete] to true when the stream has finished.
void feedContent(String fullContent, {required bool isComplete}) {
if (!state.voiceModeActive || !_enableTts) return;
final delta = fullContent.length > _lastSeenLength
? fullContent.substring(_lastSeenLength)
: '';
_lastSeenLength = fullContent.length;
_sentenceBuffer += delta;
_dispatchSentences(flush: isComplete);
if (isComplete) {
_lastAssistantContent = fullContent;
_streamComplete = true;
_checkRestartListening();
}
}
// ── Internal recording ──────────────────────────────────────────────────────
Future<void> _startListening() async {
if (!state.voiceModeActive) return;
_silenceMs = 0;
_recordingStartMs = DateTime.now().millisecondsSinceEpoch;
state = state.copyWith(mode: VoiceMode.recording);
final dir = _tempDir ?? await getTemporaryDirectory();
// AAC/M4A is reliably supported on Android (unlike WebM/Opus which can
// produce OGG bytes in a .webm file, confusing server-side decoders).
final path =
'${dir.path}/voice_rec_${DateTime.now().millisecondsSinceEpoch}.m4a';
try {
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: could not start recording');
exitVoiceMode();
}
}
void _onAmplitude(Amplitude event) {
if (!state.voiceModeActive) return;
final elapsed =
DateTime.now().millisecondsSinceEpoch - _recordingStartMs;
if (elapsed < _minRecordingMs) return;
final db = event.current;
// Guard against NaN / ±Infinity which can arrive on some Android devices
// when the recorder is initialising. Treat invalid readings as silence.
final isSilent = db.isNaN || db.isInfinite || db < _silenceThresholdDb;
if (isSilent) {
_silenceMs += 200;
if (_silenceMs >= _silenceDurationMs) {
_amplitudeSubscription?.cancel();
_amplitudeSubscription = null;
_handleSilence();
}
} else {
_silenceMs = 0;
}
}
Future<void> _handleSilence() async {
if (!state.voiceModeActive) return;
state = state.copyWith(mode: VoiceMode.transcribing);
final path = await _recorder!.stop();
if (path == null || !state.voiceModeActive) return;
try {
final bytes = await File(path).readAsBytes();
await File(path).delete().catchError((_) => File(path));
if (!state.voiceModeActive) return;
final transcript = await ref.read(voiceRepositoryProvider).transcribe(
bytes,
context: _lastAssistantContent.isNotEmpty ? _lastAssistantContent : null,
);
if (!state.voiceModeActive) return;
if (transcript.isEmpty) {
// Empty transcript — restart silently
await _startListening();
return;
}
// Reset TTS state for this new turn
_sentenceBuffer = '';
_lastSeenLength = 0;
_streamComplete = false;
if (_enableTts) {
state = state.copyWith(mode: VoiceMode.playing);
}
await _onTranscript?.call(transcript);
// If TTS is not enabled, loop immediately
if (!_enableTts && state.voiceModeActive) {
await _startListening();
}
} catch (e) {
_onError?.call('Voice error: transcription failed');
exitVoiceMode();
}
}
// ── Internal TTS ────────────────────────────────────────────────────────────
void _dispatchSentences({required bool flush}) {
final result = extractSentences(_sentenceBuffer);
_sentenceBuffer = flush ? '' : result.remainder;
for (final sentence in result.sentences) {
_enqueueSentence(sentence);
}
if (flush && result.remainder.trim().length >= 3) {
_enqueueSentence(result.remainder.trim());
}
}
void _enqueueSentence(String sentence) {
final stripped = stripMarkdownForTts(sentence);
if (stripped.length < 3) return;
_synthesiseSentence(stripped);
}
Future<void> _synthesiseSentence(String text) async {
try {
final wavBytes =
await ref.read(voiceRepositoryProvider).synthesise(text);
if (!state.voiceModeActive) return;
_ttsQueue.add(wavBytes);
if (!_ttsPlaying) _drainTtsQueue();
} catch (_) {
// Skip failed sentence — TTS errors are non-fatal
}
}
Future<void> _drainTtsQueue() async {
if (_ttsPlaying) return;
_ttsPlaying = true;
final dir = _tempDir ?? await getTemporaryDirectory();
try {
while (_ttsQueue.isNotEmpty && state.voiceModeActive) {
final wavBytes = _ttsQueue.removeFirst();
final path = '${dir.path}/tts_${_ttsCounter++}.wav';
final file = File(path);
await file.writeAsBytes(wavBytes);
try {
await _player!.setFilePath(path);
await _player!.play();
await _player!.processingStateStream.firstWhere(
(s) =>
s == ProcessingState.completed || s == ProcessingState.idle,
);
} finally {
await file.delete().catchError((_) => file);
}
}
} finally {
_ttsPlaying = false;
}
_checkRestartListening();
}
void _checkRestartListening() {
if (_streamComplete &&
_ttsQueue.isEmpty &&
!_ttsPlaying &&
state.voiceModeActive) {
_streamComplete = false;
_lastSeenLength = 0;
_sentenceBuffer = '';
_startListening();
}
}
}
+2 -2
View File
@@ -72,7 +72,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
_usernameController.text.trim(),
_passwordController.text,
);
if (mounted) context.go(Routes.notes);
if (mounted) context.go(Routes.briefing);
} on AuthException catch (e) {
setState(() => _error = e.message);
} on AppException catch (e) {
@@ -90,7 +90,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
cookieJar: ref.read(cookieJarProvider),
onSuccess: () async {
await ref.read(authProvider.notifier).verify();
if (mounted) context.go(Routes.notes);
if (mounted) context.go(Routes.briefing);
},
),
));
+238 -47
View File
@@ -1,12 +1,18 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/exceptions.dart';
import '../../data/models/message.dart';
import '../../providers/briefing_provider.dart';
import '../../widgets/briefing_digest_card.dart';
import '../../providers/api_client_provider.dart';
import '../../widgets/chat_message_bubble.dart';
import '../../widgets/weather_card.dart';
import '../../widgets/news_card.dart';
import 'briefing_history_screen.dart';
import '../../providers/voice_provider.dart';
import '../../widgets/voice_mic_button.dart';
class BriefingScreen extends ConsumerStatefulWidget {
const BriefingScreen({super.key});
@@ -15,15 +21,43 @@ class BriefingScreen extends ConsumerStatefulWidget {
ConsumerState<BriefingScreen> createState() => _BriefingScreenState();
}
class _BriefingScreenState extends ConsumerState<BriefingScreen> {
class _BriefingScreenState extends ConsumerState<BriefingScreen>
with WidgetsBindingObserver {
final _controller = TextEditingController();
final _scrollController = ScrollController();
bool _refreshing = false;
// rss_item_id -> 'up' | 'down' | null
final Map<int, String?> _reactions = {};
Timer? _pollTimer;
bool _appInForeground = true;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_pollTimer = Timer.periodic(const Duration(seconds: 60), (_) => _pollSilently());
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
_appInForeground = state == AppLifecycleState.resumed;
}
void _pollSilently() {
if (!_appInForeground || !mounted) return;
final isStreaming = ref.read(isBriefingStreamingProvider);
if (isStreaming) return;
ref.read(briefingProvider.notifier).silentRefresh();
}
@override
void dispose() {
_pollTimer?.cancel();
WidgetsBinding.instance.removeObserver(this);
_controller.dispose();
_scrollController.dispose();
ref.read(voiceProvider.notifier).exitVoiceMode();
super.dispose();
}
@@ -59,6 +93,59 @@ 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;
setState(() => _reactions[itemId] = next);
final api = ref.read(briefingApiProvider);
try {
if (next == null) {
await api.deleteRssReaction(itemId);
} else {
await api.postRssReaction(itemId, reaction);
}
} catch (_) {
setState(() => _reactions[itemId] = current);
}
}
Future<void> _toggleVoiceMode() async {
final voice = ref.read(voiceProvider);
if (voice.voiceModeActive) {
ref.read(voiceProvider.notifier).exitVoiceMode();
return;
}
await ref.read(voiceProvider.notifier).enterVoiceMode(
onTranscript: (transcript) async {
await ref.read(briefingProvider.notifier).sendReply(transcript);
},
enableTts: true,
onError: (msg) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(msg)));
}
},
);
}
Future<void> _refresh() async {
setState(() => _refreshing = true);
try {
@@ -78,10 +165,24 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen> {
Widget build(BuildContext context) {
final briefingAsync = ref.watch(briefingProvider);
final isStreaming = ref.watch(isBriefingStreamingProvider);
final voiceState = ref.watch(voiceProvider);
final scheme = Theme.of(context).colorScheme;
// Scroll to bottom when messages change
ref.listen(briefingProvider, (_, _) => _scrollToBottom());
ref.listen(briefingProvider, (prev, next) => _scrollToBottom());
// Feed streaming assistant content to VoiceNotifier for TTS.
ref.listen(briefingProvider, (prev, next) {
if (!voiceState.voiceModeActive) return;
final conv = next.value;
if (conv == null || conv.messages.isEmpty) return;
final last = conv.messages.last;
if (last.role != MessageRole.assistant) return;
final isComplete = last.status != 'generating';
ref
.read(voiceProvider.notifier)
.feedContent(last.content, isComplete: isComplete);
});
return Scaffold(
appBar: AppBar(
@@ -132,7 +233,7 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen> {
),
body: briefingAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, _) => Center(
error: (err, stack) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -146,47 +247,53 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen> {
),
),
data: (conv) {
// First assistant message for the digest card (null if none yet)
final Message? firstAssistant = conv.messages
.where((m) => m.role == MessageRole.assistant)
.toList()
.firstOrNull;
return Column(
children: [
// Digest card header
BriefingDigestCard(
message: firstAssistant,
onGenerateNow: _refresh,
),
// Divider + label
if (conv.messages.isNotEmpty) ...[
const SizedBox(height: 4),
Row(children: [
const Expanded(child: Divider()),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Text(
'Conversation',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
),
const Expanded(child: Divider()),
]),
],
// Message list
Expanded(
child: ListView.builder(
child: CustomScrollView(
controller: _scrollController,
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 8),
itemCount: conv.messages.length,
itemBuilder: (_, i) =>
ChatMessageBubble(message: conv.messages[i]),
slivers: [
if (conv.messages.isEmpty)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'No briefing yet today.',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: scheme.onSurfaceVariant),
),
const SizedBox(height: 12),
FilledButton.tonal(
onPressed: _refresh,
child: const Text('Generate now'),
),
],
),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 8),
sliver: SliverList.builder(
itemCount: conv.messages.length,
itemBuilder: (_, i) {
final msg = conv.messages[i];
return _BriefingMessageItem(
message: msg,
convId: conv.id,
reactions: _reactions,
onReaction: _handleReaction,
onDiscuss: _handleDiscuss,
);
},
),
),
],
),
),
@@ -197,6 +304,21 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen> {
color: scheme.primary,
),
// Voice mode banner
if (voiceState.voiceModeActive)
Container(
width: double.infinity,
color: const Color(0xFFEF4444).withValues(alpha: 0.12),
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 6),
child: const Text(
'🎤 Listening… tap mic to exit voice mode',
style: TextStyle(
fontSize: 12,
color: Color(0xFFF87171),
),
),
),
// Reply bar
const Divider(height: 1),
SafeArea(
@@ -207,22 +329,35 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen> {
Expanded(
child: TextField(
controller: _controller,
decoration: const InputDecoration(
hintText: 'Reply to your briefing…',
border: OutlineInputBorder(),
decoration: InputDecoration(
hintText: voiceState.voiceModeActive
? 'Listening…'
: 'Reply to your briefing…',
hintStyle: voiceState.voiceModeActive
? const TextStyle(fontStyle: FontStyle.italic)
: null,
border: const OutlineInputBorder(),
isDense: true,
contentPadding: EdgeInsets.symmetric(
contentPadding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 10),
),
minLines: 1,
maxLines: 4,
textInputAction: TextInputAction.newline,
enabled: !isStreaming,
enabled: !isStreaming && !voiceState.voiceModeActive,
),
),
const SizedBox(width: 8),
VoiceMicButton(
mode: voiceState.mode,
voiceModeActive: voiceState.voiceModeActive,
onTap: _toggleVoiceMode,
),
const SizedBox(width: 6),
_GradientSendButton(
onPressed: isStreaming ? null : _sendReply,
onPressed: (isStreaming || voiceState.voiceModeActive)
? null
: _sendReply,
isStreaming: isStreaming,
),
],
@@ -250,6 +385,62 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen> {
}
}
/// Renders a single briefing message with optional WeatherCard above it
/// 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
Widget build(BuildContext context) {
final meta = message.metadata;
final isAssistant = message.role == MessageRole.assistant;
// Weather: show card above when metadata.weather key is present (even if null value)
final bool hasWeatherKey = isAssistant && meta != null && meta.containsKey('weather');
final weatherData = hasWeatherKey ? meta['weather'] as Map<String, dynamic>? : null;
// RSS news cards — cap at 3
final rssItemsRaw = isAssistant && meta != null
? (meta['rss_items'] as List<dynamic>?)?.cast<Map<String, dynamic>>() ?? []
: <Map<String, dynamic>>[];
final rssItems = rssItemsRaw.map(RssItemMeta.fromJson).take(3).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (hasWeatherKey) WeatherCard(weather: weatherData),
ChatMessageBubble(message: message),
if (rssItems.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 4, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: rssItems.map((item) => NewsCard(
item: item,
reaction: reactions[item.id],
onReaction: onReaction,
onDiscuss: () => onDiscuss(convId, item.id),
)).toList(),
),
),
],
);
}
}
class _GradientSendButton extends StatelessWidget {
final VoidCallback? onPressed;
final bool isStreaming;
+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: () async => ref.invalidate(calendarProvider),
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',
'#6366F1',
'#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'),
),
),
],
),
),
);
}
}
+104 -14
View File
@@ -2,8 +2,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/exceptions.dart';
import '../../data/models/message.dart';
import '../../providers/chat_provider.dart';
import '../../providers/voice_provider.dart';
import '../../widgets/chat_message_bubble.dart';
import '../../widgets/voice_mic_button.dart';
class ChatScreen extends ConsumerStatefulWidget {
final int conversationId;
@@ -13,14 +16,31 @@ 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();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
ref.invalidate(messagesProvider(widget.conversationId));
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_controller.dispose();
_scrollController.dispose();
// Exit voice mode if the user navigates away mid-session.
ref.read(voiceProvider.notifier).exitVoiceMode();
super.dispose();
}
@@ -58,19 +78,57 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
}
}
Future<void> _toggleVoiceMode() async {
final voice = ref.read(voiceProvider);
if (voice.voiceModeActive) {
ref.read(voiceProvider.notifier).exitVoiceMode();
return;
}
await ref.read(voiceProvider.notifier).enterVoiceMode(
onTranscript: (transcript) async {
await ref
.read(messagesProvider(widget.conversationId).notifier)
.sendMessage(transcript);
},
enableTts: true,
onError: (msg) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(msg)));
}
},
);
}
@override
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
ref.listen(messagesProvider(widget.conversationId), (_, _) {
// Scroll when messages change.
ref.listen(messagesProvider(widget.conversationId), (prev, next) {
_scrollToBottom();
});
// Feed streaming content to VoiceNotifier for TTS.
ref.listen(messagesProvider(widget.conversationId), (prev, next) {
if (!voiceState.voiceModeActive) return;
final messages = next.value;
if (messages == null || messages.isEmpty) return;
final last = messages.last;
if (last.role != MessageRole.assistant) return;
final isComplete = last.status != 'generating';
ref
.read(voiceProvider.notifier)
.feedContent(last.content, isComplete: isComplete);
});
final convTitle = ref
.watch(conversationsProvider)
.valueOrNull
.value
?.where((c) => c.id == widget.conversationId)
.firstOrNull
?.title;
@@ -85,7 +143,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
child: messagesAsync.when(
loading: () =>
const Center(child: CircularProgressIndicator()),
error: (_, _) => const Center(
error: (err, stack) => const Center(
child: Text('Could not load messages.'),
),
data: (messages) {
@@ -98,12 +156,32 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 12),
itemCount: messages.length,
itemBuilder: (context, i) =>
ChatMessageBubble(message: messages[i]),
itemBuilder: (context, i) => ChatMessageBubble(
message: messages[i],
streamingStatus: (i == messages.length - 1 &&
messages[i].status == 'generating')
? streamingStatus
: '',
),
);
},
),
),
// Voice mode banner
if (voiceState.voiceModeActive)
Container(
width: double.infinity,
color: const Color(0xFFEF4444).withValues(alpha: 0.12),
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: const Text(
'🎤 Listening… tap mic to exit voice mode',
style: TextStyle(
fontSize: 12,
color: Color(0xFFF87171),
),
),
),
const Divider(height: 1),
SafeArea(
child: Padding(
@@ -114,23 +192,36 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
Expanded(
child: TextField(
controller: _controller,
decoration: const InputDecoration(
hintText: 'Message...',
border: OutlineInputBorder(),
decoration: InputDecoration(
hintText: voiceState.voiceModeActive
? 'Listening…'
: 'Message…',
hintStyle: voiceState.voiceModeActive
? const TextStyle(fontStyle: FontStyle.italic)
: null,
border: const OutlineInputBorder(),
isDense: true,
contentPadding: EdgeInsets.symmetric(
contentPadding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 10),
),
maxLines:
MediaQuery.of(context).size.width >= 600 ? 2 : 4,
minLines: 1,
textInputAction: TextInputAction.newline,
enabled: !isStreaming,
enabled: !isStreaming && !voiceState.voiceModeActive,
),
),
const SizedBox(width: 8),
VoiceMicButton(
mode: voiceState.mode,
voiceModeActive: voiceState.voiceModeActive,
onTap: _toggleVoiceMode,
),
const SizedBox(width: 6),
IconButton.filled(
onPressed: isStreaming ? null : _send,
onPressed: (isStreaming || voiceState.voiceModeActive)
? null
: _send,
icon: isStreaming
? const SizedBox(
width: 20,
@@ -149,4 +240,3 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
);
}
}
@@ -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}'));
@@ -71,8 +71,10 @@ class ConversationsTabScreen extends ConsumerWidget {
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')),
],
),
+287
View File
@@ -0,0 +1,287 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../providers/knowledge_provider.dart';
import '../../widgets/knowledge_item_card.dart';
// Type tab configuration: (label, noteType filter value)
const _kTabs = [
(label: 'All', type: null),
(label: 'Notes', type: 'note'),
(label: 'People', type: 'person'),
(label: 'Places', type: 'place'),
(label: 'Lists', type: 'list'),
(label: 'Tasks', type: 'task'),
];
class KnowledgeScreen extends ConsumerStatefulWidget {
const KnowledgeScreen({super.key});
@override
ConsumerState<KnowledgeScreen> createState() => _KnowledgeScreenState();
}
class _KnowledgeScreenState extends ConsumerState<KnowledgeScreen>
with SingleTickerProviderStateMixin {
late final TabController _tabController;
late final ScrollController _scrollController;
bool _searchActive = false;
final _searchController = TextEditingController();
var _debounce = DateTime.now();
@override
void initState() {
super.initState();
_tabController = TabController(length: _kTabs.length, vsync: this);
_scrollController = ScrollController();
_scrollController.addListener(_onScroll);
// Trigger initial load
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(knowledgeProvider.notifier).refresh();
});
_tabController.addListener(_onTabChanged);
}
@override
void dispose() {
_tabController.dispose();
_scrollController.dispose();
_searchController.dispose();
super.dispose();
}
void _onTabChanged() {
if (!_tabController.indexIsChanging) return;
final newType = _kTabs[_tabController.index].type;
ref.read(knowledgeProvider.notifier).setTypeFilter(newType);
}
void _onScroll() {
final pos = _scrollController.position;
if (pos.pixels >= pos.maxScrollExtent - 300) {
ref.read(knowledgeProvider.notifier).hydrateNext();
}
}
void _onSearchChanged(String q) {
final now = DateTime.now();
_debounce = now;
Future.delayed(const Duration(milliseconds: 400), () {
if (_debounce == now && mounted) {
ref.read(knowledgeProvider.notifier).setSearch(q);
}
});
}
String _tabLabel(int index) => _kTabs[index].label;
@override
Widget build(BuildContext context) {
final state = ref.watch(knowledgeProvider);
return Scaffold(
appBar: AppBar(
title: _searchActive
? TextField(
controller: _searchController,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Search knowledge…',
border: InputBorder.none,
),
onChanged: _onSearchChanged,
)
: const Text('Knowledge'),
actions: [
IconButton(
icon: Icon(_searchActive ? Icons.close : Icons.search),
onPressed: () {
setState(() => _searchActive = !_searchActive);
if (!_searchActive) {
_searchController.clear();
ref.read(knowledgeProvider.notifier).setSearch(null);
}
},
),
],
bottom: TabBar(
controller: _tabController,
isScrollable: true,
tabAlignment: TabAlignment.start,
tabs: List.generate(
_kTabs.length,
(i) => Tab(text: _tabLabel(i)),
),
),
),
body: Column(
children: [
// Tag filter chips
if (state.availableTags.isNotEmpty)
SizedBox(
height: 48,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
itemCount: state.availableTags.length,
separatorBuilder: (_, _) => const SizedBox(width: 6),
itemBuilder: (_, i) {
final tag = state.availableTags[i];
final active = state.activeTags.contains(tag);
return FilterChip(
label: Text(tag),
selected: active,
onSelected: (_) =>
ref.read(knowledgeProvider.notifier).toggleTag(tag),
visualDensity: VisualDensity.compact,
);
},
),
),
// Main list
Expanded(
child: _buildList(state),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => _onFabTapped(context),
tooltip: 'New',
child: const Icon(Icons.edit_outlined),
),
);
}
Widget _buildList(KnowledgeState state) {
if (state.isLoadingIds && state.ids.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (state.error != null && state.ids.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Failed to load',
style: Theme.of(context).textTheme.bodyLarge),
const SizedBox(height: 8),
TextButton(
onPressed: () =>
ref.read(knowledgeProvider.notifier).refresh(),
child: const Text('Retry'),
),
],
),
);
}
final items = state.orderedItems;
if (items.isEmpty && !state.isLoadingIds && !state.isLoadingBatch) {
return Center(
child: Text(
'No ${_kTabs[_tabController.index].label.toLowerCase()} yet.',
style: Theme.of(context).textTheme.bodyLarge,
),
);
}
return RefreshIndicator(
onRefresh: () => ref.read(knowledgeProvider.notifier).refresh(),
child: ListView.separated(
controller: _scrollController,
itemCount: items.length + (state.isLoadingBatch ? 1 : 0),
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (_, i) {
if (i >= items.length) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
return KnowledgeItemCard(item: items[i]);
},
),
);
}
void _onFabTapped(BuildContext context) {
final currentType = _kTabs[_tabController.index].type;
if (currentType == 'task') {
context.push('/tasks/new');
return;
}
_showTypePicker(context);
}
void _showTypePicker(BuildContext context) {
showModalBottomSheet<void>(
context: context,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_TypePickerRow(
icon: Icons.description_outlined,
label: 'Note',
description: 'General note or document',
onTap: () {
Navigator.pop(sheetContext);
context.push('/notes/new', extra: {'noteType': 'note'});
},
),
_TypePickerRow(
icon: Icons.person_outlined,
label: 'Person',
description: 'Contact, colleague, or reference person',
onTap: () {
Navigator.pop(sheetContext);
context.push('/notes/new', extra: {'noteType': 'person'});
},
),
_TypePickerRow(
icon: Icons.place_outlined,
label: 'Place',
description: 'Location, venue, or place of interest',
onTap: () {
Navigator.pop(sheetContext);
context.push('/notes/new', extra: {'noteType': 'place'});
},
),
_TypePickerRow(
icon: Icons.checklist_outlined,
label: 'List',
description: 'Checklist or structured list',
onTap: () {
Navigator.pop(sheetContext);
context.push('/notes/new', extra: {'noteType': 'list'});
},
),
],
),
),
);
}
}
class _TypePickerRow extends StatelessWidget {
final IconData icon;
final String label;
final String description;
final VoidCallback onTap;
const _TypePickerRow({
required this.icon,
required this.label,
required this.description,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return ListTile(
leading: Icon(icon),
title: Text(label),
subtitle: Text(description),
onTap: onTap,
);
}
}
-294
View File
@@ -1,294 +0,0 @@
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/note.dart';
import '../../data/models/task.dart';
import '../../providers/notes_provider.dart';
import '../../providers/projects_provider.dart';
import '../../providers/tasks_provider.dart';
import '../../widgets/library_item_card.dart';
enum _LibraryFilter { all, notes, tasks, projects }
enum _TaskStatusFilter { all, todo, inProgress, done }
class LibraryScreen extends ConsumerStatefulWidget {
const LibraryScreen({super.key});
@override
ConsumerState<LibraryScreen> createState() => _LibraryScreenState();
}
class _LibraryScreenState extends ConsumerState<LibraryScreen> {
_LibraryFilter _filter = _LibraryFilter.all;
_TaskStatusFilter _taskStatus = _TaskStatusFilter.all;
bool _searchActive = false;
String _searchQuery = '';
final _searchController = TextEditingController();
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
bool _matchesSearch(String text) {
if (_searchQuery.isEmpty) return true;
return text.toLowerCase().contains(_searchQuery.toLowerCase());
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final notesAsync = ref.watch(notesProvider);
final tasksAsync = ref.watch(tasksProvider);
final projectsAsync = ref.watch(projectsProvider);
return Scaffold(
appBar: AppBar(
title: _searchActive
? TextField(
controller: _searchController,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Search…',
border: InputBorder.none,
isDense: true,
),
onChanged: (q) => setState(() => _searchQuery = q),
)
: Text('Library', style: theme.textTheme.titleLarge),
actions: [
IconButton(
icon: Icon(_searchActive ? Icons.close : Icons.search),
onPressed: () => setState(() {
_searchActive = !_searchActive;
if (!_searchActive) {
_searchQuery = '';
_searchController.clear();
}
}),
),
],
),
body: Column(
children: [
// ── Filter pills ──────────────────────────────────────────────────
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
child: Row(
children: _LibraryFilter.values.map((f) {
final label = switch (f) {
_LibraryFilter.all => 'All',
_LibraryFilter.notes => 'Notes',
_LibraryFilter.tasks => 'Tasks',
_LibraryFilter.projects => 'Projects',
};
final selected = _filter == f;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: FilterChip(
label: Text(label),
selected: selected,
onSelected: (_) => setState(() {
_filter = f;
_taskStatus = _TaskStatusFilter.all;
}),
selectedColor:
theme.colorScheme.primary.withValues(alpha: 0.18),
checkmarkColor: theme.colorScheme.primary,
),
);
}).toList(),
),
),
// ── Task status sub-filter (Tasks pill only) ───────────────────
if (_filter == _LibraryFilter.tasks)
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.fromLTRB(12, 0, 12, 4),
child: Row(
children: _TaskStatusFilter.values.map((s) {
final label = switch (s) {
_TaskStatusFilter.all => 'All',
_TaskStatusFilter.todo => 'To Do',
_TaskStatusFilter.inProgress => 'In Progress',
_TaskStatusFilter.done => 'Done',
};
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(label),
selected: _taskStatus == s,
onSelected: (_) => setState(() => _taskStatus = s),
selectedColor:
theme.colorScheme.secondary.withValues(alpha: 0.15),
),
);
}).toList(),
),
),
const Divider(height: 1),
// ── Content ───────────────────────────────────────────────────────
Expanded(
child: switch (_filter) {
_LibraryFilter.notes => _buildNotesList(notesAsync),
_LibraryFilter.tasks => _buildTasksList(tasksAsync),
_LibraryFilter.projects => _buildProjectsList(projectsAsync),
_LibraryFilter.all => _buildAllList(notesAsync, tasksAsync),
},
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showCreateSheet(context),
child: const Icon(Icons.add),
),
);
}
Widget _buildNotesList(AsyncValue<List<Note>> notesAsync) {
return notesAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (notes) {
final filtered = notes
.where((n) => _matchesSearch(n.title) || _matchesSearch(n.body))
.toList();
if (filtered.isEmpty) {
return const Center(child: Text('No notes found'));
}
return RefreshIndicator(
onRefresh: () async => ref.invalidate(notesProvider),
child: ListView.builder(
itemCount: filtered.length,
itemBuilder: (_, i) => NoteLibraryCard(note: filtered[i]),
),
);
},
);
}
Widget _buildTasksList(AsyncValue<List<Task>> tasksAsync) {
return tasksAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (tasks) {
var filtered = tasks.where((t) => _matchesSearch(t.title)).toList();
if (_taskStatus != _TaskStatusFilter.all) {
final status = switch (_taskStatus) {
_TaskStatusFilter.todo => TaskStatus.todo,
_TaskStatusFilter.inProgress => TaskStatus.inProgress,
_TaskStatusFilter.done => TaskStatus.done,
_ => TaskStatus.todo,
};
filtered = filtered.where((t) => t.status == status).toList();
}
if (filtered.isEmpty) {
return const Center(child: Text('No tasks found'));
}
return RefreshIndicator(
onRefresh: () async => ref.invalidate(tasksProvider),
child: ListView.builder(
itemCount: filtered.length,
itemBuilder: (_, i) => TaskLibraryCard(task: filtered[i]),
),
);
},
);
}
Widget _buildProjectsList(AsyncValue<List<dynamic>> projectsAsync) {
return projectsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (projects) {
if (projects.isEmpty) {
return const Center(child: Text('No projects'));
}
return RefreshIndicator(
onRefresh: () async => ref.invalidate(projectsProvider),
child: ListView.builder(
itemCount: projects.length,
itemBuilder: (_, i) =>
ProjectLibraryCard(project: projects[i]),
),
);
},
);
}
Widget _buildAllList(
AsyncValue<List<Note>> notesAsync,
AsyncValue<List<Task>> tasksAsync,
) {
final notes = notesAsync.valueOrNull ?? [];
final tasks = tasksAsync.valueOrNull ?? [];
// Merge and sort by updatedAt desc
final items = <(DateTime, Widget)>[];
for (final n in notes) {
if (_matchesSearch(n.title) || _matchesSearch(n.body)) {
items.add((n.updatedAt, NoteLibraryCard(note: n)));
}
}
for (final t in tasks) {
if (_matchesSearch(t.title)) {
items.add((t.updatedAt, TaskLibraryCard(task: t)));
}
}
items.sort((a, b) => b.$1.compareTo(a.$1));
if (notesAsync.isLoading || tasksAsync.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (items.isEmpty) {
return const Center(child: Text('Nothing here yet'));
}
return RefreshIndicator(
onRefresh: () async {
ref.invalidate(notesProvider);
ref.invalidate(tasksProvider);
},
child: ListView.builder(
itemCount: items.length,
itemBuilder: (_, i) => items[i].$2,
),
);
}
void _showCreateSheet(BuildContext context) {
showModalBottomSheet<void>(
context: context,
builder: (_) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.article_outlined),
title: const Text('New note'),
onTap: () {
Navigator.pop(context);
context.push(Routes.noteNew);
},
),
ListTile(
leading: const Icon(Icons.check_box_outlined),
title: const Text('New task'),
onTap: () {
Navigator.pop(context);
context.push(Routes.taskNew);
},
),
],
),
),
);
}
}
@@ -0,0 +1,398 @@
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/milestone.dart';
import '../../data/models/project.dart';
import '../../data/models/task.dart';
import '../../providers/api_client_provider.dart';
import '../../providers/milestones_provider.dart';
import '../../providers/projects_provider.dart';
import '../../providers/tasks_provider.dart';
class ProjectTasksScreen extends ConsumerStatefulWidget {
final int projectId;
const ProjectTasksScreen({super.key, required this.projectId});
@override
ConsumerState<ProjectTasksScreen> createState() => _ProjectTasksScreenState();
}
class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
// Local optimistic status overrides — avoids a reload flash on every cycle tap.
final Map<int, TaskStatus> _pendingStatus = {};
TaskStatus _effectiveStatus(Task task) =>
_pendingStatus[task.id] ?? task.status;
TaskStatus _nextStatus(TaskStatus s) => switch (s) {
TaskStatus.todo => TaskStatus.inProgress,
TaskStatus.inProgress => TaskStatus.done,
TaskStatus.done => TaskStatus.todo,
};
Future<void> _cycleStatus(Task task) async {
final current = _effectiveStatus(task);
final next = _nextStatus(current);
setState(() => _pendingStatus[task.id] = next);
try {
await ref
.read(tasksRepositoryProvider)
.update(task.id, {'status': next.value});
// Sync the global tasks list so the library view stays consistent.
ref.invalidate(tasksProvider);
} catch (_) {
// Revert optimistic change on error.
setState(() => _pendingStatus.remove(task.id));
}
}
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);
try {
return Color(int.parse(hex.replaceFirst('#', '0xFF')));
} catch (_) {
return const Color(0xFF6366F1);
}
}
@override
Widget build(BuildContext context) {
final tasksAsync = ref.watch(projectTasksProvider(widget.projectId));
final milestonesAsync = ref.watch(projectMilestonesProvider(widget.projectId));
final project = ref.watch(projectsProvider).value
?.whereType<Project>()
.where((p) => p.id == widget.projectId)
.firstOrNull;
final color = _parseColor(project?.color);
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
project?.title ?? 'Project',
style: Theme.of(context).textTheme.titleLarge,
),
if (project?.description?.isNotEmpty == true)
Text(
project!.description!,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
actions: [
IconButton(
icon: const Icon(Icons.edit_outlined),
tooltip: 'Edit project',
onPressed: () =>
context.push('/projects/${widget.projectId}/edit'),
),
],
),
body: tasksAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error loading tasks: $e')),
data: (tasks) => _buildBody(context, tasks, milestonesAsync, color),
),
);
}
Widget _buildBody(
BuildContext context,
List<Task> tasks,
AsyncValue<List<Milestone>> milestonesAsync,
Color color,
) {
final milestones = (milestonesAsync.value ?? []).toList()
..sort((a, b) => a.orderIndex.compareTo(b.orderIndex));
// Group tasks by milestoneId.
final byMilestone = <int?, List<Task>>{};
for (final t in tasks) {
(byMilestone[t.milestoneId] ??= []).add(t);
}
final unassigned = byMilestone[null] ?? [];
if (tasks.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_box_outlined,
size: 48,
color: Theme.of(context).colorScheme.onSurfaceVariant),
const SizedBox(height: 12),
Text(
'No tasks in this project yet.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
);
}
return RefreshIndicator(
onRefresh: () async {
setState(() => _pendingStatus.clear());
ref.invalidate(projectTasksProvider(widget.projectId));
ref.invalidate(projectMilestonesProvider(widget.projectId));
},
child: CustomScrollView(
slivers: [
// Top colour strip.
SliverToBoxAdapter(child: Container(height: 4, color: color)),
// Milestone sections.
for (final ms in milestones) ...[
SliverToBoxAdapter(
child: _MilestoneHeader(
milestone: ms,
tasks: byMilestone[ms.id] ?? [],
color: color,
),
),
if ((byMilestone[ms.id] ?? []).isNotEmpty)
SliverList.builder(
itemCount: byMilestone[ms.id]!.length,
itemBuilder: (_, i) {
final task = byMilestone[ms.id]![i];
return _TaskRow(
task: task,
effectiveStatus: _effectiveStatus(task),
onStatusTap: () => _cycleStatus(task),
onTap: () => _openTask(task.id),
);
},
),
],
// Unassigned tasks.
if (unassigned.isNotEmpty) ...[
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 6),
child: Text(
'No milestone',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
letterSpacing: 0.5,
),
),
),
),
SliverList.builder(
itemCount: unassigned.length,
itemBuilder: (_, i) {
final task = unassigned[i];
return _TaskRow(
task: task,
effectiveStatus: _effectiveStatus(task),
onStatusTap: () => _cycleStatus(task),
onTap: () => _openTask(task.id),
);
},
),
],
const SliverToBoxAdapter(child: SizedBox(height: 16)),
],
),
);
}
}
// ── Milestone section header ──────────────────────────────────────────────────
class _MilestoneHeader extends StatelessWidget {
final Milestone milestone;
final List<Task> tasks;
final Color color;
const _MilestoneHeader({
required this.milestone,
required this.tasks,
required this.color,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final done = tasks.where((t) => t.status == TaskStatus.done).length;
final total = tasks.length;
final pct = total > 0 ? done / total : 0.0;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 8),
Expanded(
child: Text(
milestone.title,
style: theme.textTheme.titleSmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Text(
'$done / $total',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
if (total > 0) ...[
const SizedBox(height: 4),
LinearProgressIndicator(
value: pct,
minHeight: 2,
color: color,
backgroundColor: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(1),
),
],
],
),
);
}
}
// ── Task row ──────────────────────────────────────────────────────────────────
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) {
TaskStatus.done => Icons.check_circle,
TaskStatus.inProgress => Icons.timelapse,
TaskStatus.todo => Icons.radio_button_unchecked,
};
Color _statusColor(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return switch (effectiveStatus) {
TaskStatus.done => const Color(0xFF22C55E),
TaskStatus.inProgress => cs.primary,
TaskStatus.todo => cs.onSurfaceVariant,
};
}
Color _priorityColor(BuildContext context) => switch (task.priority) {
TaskPriority.high => const Color(0xFFEF4444),
TaskPriority.medium => const Color(0xFFF59E0B),
_ => Colors.transparent,
};
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 3),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.fromLTRB(4, 6, 14, 6),
child: Row(
children: [
IconButton(
icon: Icon(_statusIcon, color: _statusColor(context)),
onPressed: onStatusTap,
tooltip: 'Cycle status',
visualDensity: VisualDensity.compact,
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
task.title.isNotEmpty ? task.title : 'Untitled',
style: theme.textTheme.titleSmall?.copyWith(
decoration: effectiveStatus == TaskStatus.done
? TextDecoration.lineThrough
: null,
color: effectiveStatus == TaskStatus.done
? theme.colorScheme.onSurfaceVariant
: null,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (task.dueDate != null) ...[
const SizedBox(height: 2),
Text(
'Due ${_formatDate(task.dueDate!)}',
style: theme.textTheme.labelSmall?.copyWith(
color: task.dueDate!.isBefore(DateTime.now()) &&
effectiveStatus != TaskStatus.done
? const Color(0xFFEF4444)
: theme.colorScheme.onSurfaceVariant,
),
),
],
],
),
),
if (task.priority == TaskPriority.high ||
task.priority == TaskPriority.medium)
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: _priorityColor(context),
shape: BoxShape.circle,
),
),
],
),
),
),
);
}
}
String _formatDate(DateTime dt) {
const months = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
];
return '${months[dt.month - 1]} ${dt.day}';
}
+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: () async => ref.invalidate(newsProvider),
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
@@ -16,7 +16,7 @@ class NoteDetailScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final noteAsync = ref.watch(noteDetailProvider(noteId));
final allNotes = ref.watch(notesProvider).valueOrNull ?? [];
final allNotes = ref.watch(notesProvider).value ?? [];
void navigateByTitle(String title) {
final matches = allNotes.where(
+23 -2
View File
@@ -11,7 +11,8 @@ import '../../widgets/project_selector.dart';
class NoteEditScreen extends ConsumerStatefulWidget {
final int? noteId;
const NoteEditScreen({super.key, this.noteId});
final String? noteType; // passed when creating a typed note from KnowledgeScreen
const NoteEditScreen({super.key, this.noteId, this.noteType});
@override
ConsumerState<NoteEditScreen> createState() => _NoteEditScreenState();
@@ -25,12 +26,14 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
int? _projectId;
bool _preview = false;
bool _saving = false;
late String _noteType;
late final Future<void> _initFuture;
@override
void initState() {
super.initState();
_noteType = widget.noteType ?? 'note';
_initFuture =
widget.noteId != null ? _loadExisting() : Future.value();
}
@@ -50,6 +53,7 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
_contentController.text = note.body;
_tags = List<String>.from(note.tags);
_projectId = note.projectId;
_noteType = note.noteType;
}
void _addTag(String raw) {
@@ -108,6 +112,7 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
body,
tags: _tags,
projectId: _projectId,
noteType: _noteType,
);
if (mounted) context.pop();
} else {
@@ -118,6 +123,7 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
tags: _tags,
projectId: _projectId,
clearProject: _projectId == null,
noteType: _noteType,
);
if (mounted) context.pop();
}
@@ -138,7 +144,22 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
builder: (context, snapshot) {
return Scaffold(
appBar: AppBar(
title: Text(widget.noteId == null ? 'New Note' : 'Edit Note'),
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.noteId == null ? 'New Note' : 'Edit Note'),
if (_noteType != 'note')
Chip(
label: Text(
_noteType,
style: const TextStyle(fontSize: 11),
),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
],
),
actions: [
if (widget.noteId != null)
IconButton(
@@ -0,0 +1,164 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/exceptions.dart';
import '../../providers/projects_provider.dart';
class ProjectEditScreen extends ConsumerStatefulWidget {
final int? projectId;
const ProjectEditScreen({super.key, this.projectId});
@override
ConsumerState<ProjectEditScreen> createState() => _ProjectEditScreenState();
}
class _ProjectEditScreenState extends ConsumerState<ProjectEditScreen> {
final _titleController = TextEditingController();
final _descController = TextEditingController();
final _goalController = TextEditingController();
String _status = 'active';
bool _saving = false;
late final Future<void> _initFuture;
@override
void initState() {
super.initState();
_initFuture =
widget.projectId != null ? _loadExisting() : Future.value();
}
@override
void dispose() {
_titleController.dispose();
_descController.dispose();
_goalController.dispose();
super.dispose();
}
Future<void> _loadExisting() async {
final projects = ref.read(projectsProvider).value ?? [];
final project =
projects.where((p) => p.id == widget.projectId).firstOrNull;
if (project != null) {
_titleController.text = project.title;
_descController.text = project.description ?? '';
_goalController.text = project.goal ?? '';
_status = project.status;
}
}
Future<void> _save() async {
final title = _titleController.text.trim();
if (title.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Title is required.')));
return;
}
setState(() => _saving = true);
try {
if (widget.projectId == null) {
await ref.read(projectsProvider.notifier).create(
title: title,
description: _descController.text.trim().isEmpty
? null
: _descController.text.trim(),
goal: _goalController.text.trim().isEmpty
? null
: _goalController.text.trim(),
);
} else {
await ref.read(projectsProvider.notifier).updateProject(
widget.projectId!,
{
'title': title,
'description': _descController.text.trim(),
'goal': _goalController.text.trim(),
'status': _status,
},
);
}
if (mounted) context.pop();
} on AppException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(e.message)));
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: _initFuture,
builder: (context, snapshot) => Scaffold(
appBar: AppBar(
title: Text(
widget.projectId == null ? 'New Project' : 'Edit Project'),
actions: [
IconButton(
icon: _saving
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.check),
onPressed: _saving ? null : _save,
),
],
),
body: snapshot.connectionState == ConnectionState.waiting
? const Center(child: CircularProgressIndicator())
: ListView(
padding: const EdgeInsets.all(16),
children: [
TextField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'Title *', border: OutlineInputBorder()),
textInputAction: TextInputAction.next,
),
const SizedBox(height: 12),
TextField(
controller: _descController,
decoration: const InputDecoration(
labelText: 'Description',
border: OutlineInputBorder()),
maxLines: 3,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 12),
TextField(
controller: _goalController,
decoration: const InputDecoration(
labelText: 'Goal', border: OutlineInputBorder()),
maxLines: 2,
textInputAction: TextInputAction.done,
),
if (widget.projectId != null) ...[
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: _status,
decoration: const InputDecoration(
labelText: 'Status',
border: OutlineInputBorder()),
items: const [
DropdownMenuItem(
value: 'active', child: Text('Active')),
DropdownMenuItem(
value: 'completed', child: Text('Completed')),
DropdownMenuItem(
value: 'archived', child: Text('Archived')),
],
onChanged: (v) => setState(() => _status = v!),
),
],
],
),
),
);
}
}
+89
View File
@@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../data/models/project.dart';
import '../../providers/projects_provider.dart';
class ProjectsScreen extends ConsumerWidget {
const ProjectsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final projectsAsync = ref.watch(projectsProvider);
return Scaffold(
appBar: AppBar(title: const Text('Projects')),
body: projectsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (projects) => projects.isEmpty
? const Center(child: Text('No projects yet.'))
: RefreshIndicator(
onRefresh: () async => ref.invalidate(projectsProvider),
child: ListView.separated(
itemCount: projects.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (_, i) => _ProjectCard(project: projects[i]),
),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => context.push('/projects/new'),
tooltip: 'New project',
child: const Icon(Icons.add),
),
);
}
}
class _ProjectCard extends StatelessWidget {
final Project project;
const _ProjectCard({required this.project});
Color _statusColor(BuildContext context) => switch (project.status) {
'completed' => Colors.blue,
'archived' => Colors.grey,
_ => Theme.of(context).colorScheme.primary,
};
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(project.title),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (project.description?.isNotEmpty == true)
Text(
project.description!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (project.goal?.isNotEmpty == true)
Text(
project.goal!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(fontStyle: FontStyle.italic),
),
],
),
trailing: Chip(
label: Text(
project.status,
style: TextStyle(
fontSize: 11,
color: _statusColor(context),
),
),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onTap: () => context.push('/projects/${project.id}/tasks'),
);
}
}
+1 -1
View File
@@ -30,7 +30,7 @@ class _SplashScreenState extends ConsumerState<SplashScreen> {
if (!mounted) return;
final status = ref.read(authProvider);
if (status == AuthStatus.authenticated) {
context.go(Routes.notes);
context.go(Routes.briefing);
} else {
context.go(Routes.login);
}
+2 -1
View File
@@ -4,7 +4,6 @@ import 'package:go_router/go_router.dart';
import '../../core/constants.dart';
import '../../core/exceptions.dart';
import '../../data/api/tasks_api.dart';
import '../../data/models/task.dart';
import '../../providers/api_client_provider.dart';
import '../../providers/tasks_provider.dart';
@@ -239,6 +238,7 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
),
const SizedBox(height: 16),
DropdownButtonFormField<TaskStatus>(
// ignore: deprecated_member_use
value: _status,
decoration: const InputDecoration(
labelText: 'Status',
@@ -253,6 +253,7 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
),
const SizedBox(height: 16),
DropdownButtonFormField<TaskPriority>(
// ignore: deprecated_member_use
value: _priority,
decoration: const InputDecoration(
labelText: 'Priority',
+31 -8
View File
@@ -7,7 +7,12 @@ import '../data/models/message.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) {
@@ -53,13 +58,31 @@ 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,
),
? 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,
),
),
),
],
],
)
: MarkdownBody(
data: message.content.isEmpty ? '' : message.content,
+95
View File
@@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../data/models/knowledge_item.dart';
class KnowledgeItemCard extends StatelessWidget {
final KnowledgeItem item;
const KnowledgeItemCard({super.key, required this.item});
IconData get _icon => switch (item.noteType) {
'person' => Icons.person_outlined,
'place' => Icons.place_outlined,
'list' => Icons.checklist_outlined,
'task' => Icons.task_alt_outlined,
_ => Icons.description_outlined,
};
Color _statusColor(BuildContext context) {
if (item.noteType != 'task') return Theme.of(context).colorScheme.primary;
return switch (item.status) {
'done' => Colors.green,
'in_progress' => Colors.orange,
'cancelled' => Colors.grey,
_ => Theme.of(context).colorScheme.primary,
};
}
String? get _subtitle {
if (item.noteType == 'task') {
if (item.dueDate != null) return 'Due ${item.dueDate}';
return item.status;
}
if (item.body.trim().isEmpty) return null;
final preview = item.body.trim().replaceAll('\n', ' ');
return preview.length > 120 ? '${preview.substring(0, 120)}' : preview;
}
@override
Widget build(BuildContext context) {
return ListTile(
leading: Icon(_icon, color: _statusColor(context)),
title: Text(
item.title.isEmpty ? '(untitled)' : item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: _subtitle != null
? Text(
_subtitle!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
)
: null,
trailing: item.tags.isNotEmpty ? _TagChips(tags: item.tags) : null,
onTap: () {
if (item.noteType == 'task') {
context.push('/tasks/${item.id}/edit');
} else {
context.push('/notes/${item.id}');
}
},
);
}
}
class _TagChips extends StatelessWidget {
final List<String> tags;
const _TagChips({required this.tags});
@override
Widget build(BuildContext context) {
final shown = tags.take(2).toList();
final extra = tags.length - shown.length;
return Wrap(
spacing: 4,
children: [
for (final t in shown)
Chip(
label: Text(t, style: const TextStyle(fontSize: 10)),
padding: EdgeInsets.zero,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
),
if (extra > 0)
Chip(
label: Text('+$extra', style: const TextStyle(fontSize: 10)),
padding: EdgeInsets.zero,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
),
],
);
}
}
-271
View File
@@ -1,271 +0,0 @@
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/note.dart';
import '../data/models/project.dart';
import '../data/models/task.dart';
import '../providers/tasks_provider.dart';
// ── Note card ────────────────────────────────────────────────────────────────
class NoteLibraryCard extends StatelessWidget {
final Note note;
const NoteLibraryCard({super.key, required this.note});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: InkWell(
onTap: () => context
.push(Routes.noteDetail.replaceFirst(':id', '${note.id}')),
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.article_outlined,
size: 15, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(width: 6),
Expanded(
child: Text(
note.title.isNotEmpty ? note.title : 'Untitled',
style: theme.textTheme.titleSmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Text(
_relativeTime(note.updatedAt),
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant),
),
],
),
if (note.body.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
note.body.replaceAll('\n', ' '),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
if (note.tags.isNotEmpty) ...[
const SizedBox(height: 6),
Wrap(
spacing: 4,
runSpacing: 2,
children: note.tags
.take(4)
.map((t) => Chip(
label: Text(t),
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
))
.toList(),
),
],
],
),
),
),
);
}
}
// ── Task card ────────────────────────────────────────────────────────────────
class TaskLibraryCard extends ConsumerWidget {
final Task task;
const TaskLibraryCard({super.key, required this.task});
Color _priorityColor(BuildContext context) {
return switch (task.priority) {
TaskPriority.high => const Color(0xFFEF4444),
TaskPriority.medium => const Color(0xFFF59E0B),
_ => Theme.of(context).colorScheme.onSurfaceVariant,
};
}
IconData get _statusIcon => switch (task.status) {
TaskStatus.done => Icons.check_circle,
TaskStatus.inProgress => Icons.timelapse,
_ => Icons.radio_button_unchecked,
};
Color _statusColor(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return switch (task.status) {
TaskStatus.done => const Color(0xFF22C55E),
TaskStatus.inProgress => cs.primary,
_ => cs.onSurfaceVariant,
};
}
TaskStatus get _nextStatus => switch (task.status) {
TaskStatus.todo => TaskStatus.inProgress,
TaskStatus.inProgress => TaskStatus.done,
_ => TaskStatus.todo,
};
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: InkWell(
onTap: () => context
.push(Routes.taskEdit.replaceFirst(':id', '${task.id}')),
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 10, 14, 10),
child: Row(
children: [
// Status cycle button
IconButton(
icon: Icon(_statusIcon, color: _statusColor(context)),
onPressed: () => ref
.read(tasksProvider.notifier)
.updateTask(task.id, {'status': _nextStatus.value}),
tooltip: 'Cycle status',
visualDensity: VisualDensity.compact,
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
task.title.isNotEmpty ? task.title : 'Untitled',
style: theme.textTheme.titleSmall?.copyWith(
decoration: task.status == TaskStatus.done
? TextDecoration.lineThrough
: null,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (task.dueDate != null) ...[
const SizedBox(height: 2),
Text(
'Due ${_formatDate(task.dueDate!)}',
style: theme.textTheme.labelSmall?.copyWith(
color: task.dueDate!.isBefore(DateTime.now()) &&
task.status != TaskStatus.done
? const Color(0xFFEF4444)
: theme.colorScheme.onSurfaceVariant,
),
),
],
],
),
),
if (task.priority != TaskPriority.none &&
task.priority != TaskPriority.low)
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: _priorityColor(context),
shape: BoxShape.circle,
),
),
],
),
),
),
);
}
}
// ── Project card ─────────────────────────────────────────────────────────────
class ProjectLibraryCard extends StatelessWidget {
final Project project;
const ProjectLibraryCard({super.key, required this.project});
Color _parseColor(String? hex) {
if (hex == null || hex.isEmpty) return const Color(0xFF6366F1);
try {
return Color(int.parse(hex.replaceFirst('#', '0xFF')));
} catch (_) {
return const Color(0xFF6366F1);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = _parseColor(project.color);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () {}, // No project detail screen in this app
borderRadius: BorderRadius.circular(14),
child: Row(
children: [
// Colour strip
Container(width: 6, height: 64, color: color),
const SizedBox(width: 12),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
project.title,
style: theme.textTheme.titleSmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (project.description?.isNotEmpty == true) ...[
const SizedBox(height: 2),
Text(
project.description!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
),
const SizedBox(width: 12),
],
),
),
);
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
String _relativeTime(DateTime dt) {
final diff = DateTime.now().difference(dt);
if (diff.inMinutes < 1) return 'just now';
if (diff.inHours < 1) return '${diff.inMinutes}m ago';
if (diff.inDays < 1) return '${diff.inHours}h ago';
if (diff.inDays < 7) return '${diff.inDays}d ago';
return _formatDate(dt);
}
String _formatDate(DateTime dt) {
const months = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
];
return '${months[dt.month - 1]} ${dt.day}';
}
+228
View File
@@ -0,0 +1,228 @@
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;
final String url;
final String source;
final String snippet;
final DateTime? publishedAt;
const RssItemMeta({
required this.id,
required this.title,
required this.url,
required this.source,
required this.snippet,
this.publishedAt,
});
factory RssItemMeta.fromJson(Map<String, dynamic> json) => RssItemMeta(
id: json['id'] as int,
title: json['title'] as String? ?? '',
url: json['url'] as String? ?? '',
source: json['source'] as String? ?? '',
snippet: json['snippet'] as String? ?? '',
publishedAt: json['published_at'] != null
? DateTime.tryParse(json['published_at'] as String)
: 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!);
if (diff.inHours < 24) return '${diff.inHours}h ago';
if (diff.inHours < 48) return 'Yesterday';
return '${publishedAt!.month}/${publishedAt!.day}';
}
}
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 {
if (item.url.isEmpty) return;
final uri = Uri.tryParse(item.url);
if (uri != null && await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: scheme.outlineVariant),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Source + date row
Row(
children: [
if (item.source.isNotEmpty)
Text(
item.source.toUpperCase(),
style: textTheme.labelSmall?.copyWith(
color: scheme.primary,
fontWeight: FontWeight.w700,
letterSpacing: 0.6,
),
),
if (item.source.isNotEmpty && item.relativeDate.isNotEmpty)
Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(
item.relativeDate,
style: textTheme.labelSmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
),
],
),
const SizedBox(height: 4),
// Title — tappable if URL present
GestureDetector(
onTap: item.url.isNotEmpty ? _openUrl : null,
child: Text(
item.title,
style: textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: item.url.isNotEmpty ? scheme.primary : scheme.onSurface,
decoration: item.url.isNotEmpty ? TextDecoration.underline : null,
decorationColor: scheme.primary,
),
),
),
// Snippet
if (item.snippet.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
item.snippet,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
height: 1.45,
),
),
],
const SizedBox(height: 8),
// Actions row: reactions + discuss
Row(
children: [
_ReactionButton(
emoji: '👍',
active: reaction == 'up',
onTap: () => onReaction(item.id, 'up'),
),
const SizedBox(width: 6),
_ReactionButton(
emoji: '👎',
active: reaction == 'down',
onTap: () => onReaction(item.id, 'down'),
),
if (onDiscuss != null) ...[
const Spacer(),
_DiscussButton(onTap: onDiscuss!),
],
],
),
],
),
),
);
}
}
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;
final VoidCallback onTap;
const _ReactionButton({
required this.emoji,
required this.active,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: active ? scheme.primary.withValues(alpha: 0.12) : Colors.transparent,
border: Border.all(
color: active ? scheme.primary : scheme.outlineVariant,
),
borderRadius: BorderRadius.circular(6),
),
child: Text(emoji, style: const TextStyle(fontSize: 14)),
),
);
}
}
+2 -1
View File
@@ -23,12 +23,13 @@ class ProjectSelector extends ConsumerWidget {
return projectsAsync.when(
loading: () => const LinearProgressIndicator(),
error: (_, __) => const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(),
data: (projects) {
final active =
projects.where((p) => p.status == 'active').toList();
return DropdownButtonFormField<int?>(
// ignore: deprecated_member_use
value: value,
decoration: decoration ??
const InputDecoration(
+117
View File
@@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
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
/// - transcribing: indigo with spinner
/// - playing: indigo with volume_up icon
class VoiceMicButton extends StatefulWidget {
final VoiceMode mode;
final bool voiceModeActive;
final VoidCallback? onTap;
const VoiceMicButton({
super.key,
required this.mode,
required this.voiceModeActive,
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) {
VoiceMode.recording => const Color(0xFFEF4444),
VoiceMode.transcribing || VoiceMode.playing => cs.primary,
VoiceMode.idle => cs.surfaceContainerHighest,
};
}
Widget _icon(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final iconColor = widget.mode == VoiceMode.idle
? cs.onSurfaceVariant
: Colors.white;
return switch (widget.mode) {
VoiceMode.transcribing => SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: iconColor,
),
),
VoiceMode.playing => Icon(Icons.volume_up, color: iconColor, size: 20),
_ => Icon(Icons.mic, color: iconColor, size: 20),
};
}
@override
Widget build(BuildContext context) {
final isRecording = widget.mode == VoiceMode.recording;
final button = Material(
color: _bgColor(context),
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: widget.onTap,
child: SizedBox(
width: 40,
height: 40,
child: Center(child: _icon(context)),
),
),
);
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,
),
child: button,
);
}
}
+185
View File
@@ -0,0 +1,185 @@
import 'package:flutter/material.dart';
class WeatherCard extends StatelessWidget {
final Map<String, dynamic>? weather;
const WeatherCard({super.key, required this.weather});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
if (weather == null) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: scheme.outlineVariant),
),
child: Text(
'Weather data unavailable — will retry at next slot.',
style: TextStyle(
color: scheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
fontSize: 13,
),
),
);
}
final w = weather!;
final location = w['location'] as String? ?? '';
final currentTemp = w['current_temp'];
final condition = w['condition'] as String? ?? '';
final todayHigh = w['today_high'];
final todayLow = w['today_low'];
final yesterdayHigh = w['yesterday_high'];
final fetchedAt = w['fetched_at'] as String?;
final forecast = (w['forecast'] as List<dynamic>? ?? [])
.cast<Map<String, dynamic>>();
String? tempDelta;
if (todayHigh != null && yesterdayHigh != null) {
final diff = (todayHigh as num) - (yesterdayHigh as num);
if (diff.abs() < 1) {
tempDelta = 'Same as yesterday';
} else {
final dir = diff > 0 ? 'warmer' : 'cooler';
tempDelta = '${diff.abs().round()}° $dir than yesterday';
}
}
String? fetchedLabel;
if (fetchedAt != null) {
try {
final dt = DateTime.parse(fetchedAt).toLocal();
final h = dt.hour % 12 == 0 ? 12 : dt.hour % 12;
final m = dt.minute.toString().padLeft(2, '0');
final period = dt.hour < 12 ? 'AM' : 'PM';
fetchedLabel = '$h:$m $period';
} catch (_) {}
}
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: scheme.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header: location + fetched time
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
location,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
color: scheme.onSurface,
),
),
if (fetchedLabel != null)
Text(
'as of $fetchedLabel',
style: TextStyle(
fontSize: 12,
color: scheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 8),
// Current temp + condition
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
'$currentTemp°',
style: TextStyle(
fontSize: 36,
fontWeight: FontWeight.w700,
color: scheme.onSurface,
height: 1,
),
),
const SizedBox(width: 10),
Text(
condition,
style: TextStyle(
fontSize: 14,
color: scheme.onSurfaceVariant,
),
),
],
),
// Today high/low + delta
if (todayHigh != null) ...[
const SizedBox(height: 8),
Text(
'Today: $todayHigh° / $todayLow°'
'${tempDelta != null ? ' · $tempDelta' : ''}',
style: TextStyle(fontSize: 13, color: scheme.onSurfaceVariant),
),
],
// Forecast strip
if (forecast.isNotEmpty) ...[
const SizedBox(height: 12),
const Divider(height: 1),
const SizedBox(height: 12),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
spacing: 8,
children: forecast.map((day) {
return SizedBox(
width: 64,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
day['day'] as String? ?? '',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: scheme.onSurface,
),
),
const SizedBox(height: 3),
Text(
day['condition'] as String? ?? '',
style: TextStyle(
fontSize: 11,
color: scheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 3),
Text(
'${day['high']}° / ${day['low']}°',
style: TextStyle(
fontSize: 12,
color: scheme.onSurface,
),
),
],
),
);
}).toList(),
),
),
],
],
),
);
}
}
@@ -6,10 +6,22 @@
#include "generated_plugin_registrant.h"
#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) {
g_autoptr(FlPluginRegistrar) flutter_timezone_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterTimezonePlugin");
flutter_timezone_plugin_register_with_registrar(flutter_timezone_registrar);
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);
}
+3
View File
@@ -3,7 +3,10 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
flutter_timezone
open_file_linux
record_linux
url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
@@ -5,14 +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"))
}
+476 -20
View File
@@ -1,6 +1,22 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d"
url: "https://pub.dev"
source: hosted
version: "93.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b
url: "https://pub.dev"
source: hosted
version: "10.0.1"
archive:
dependency: transitive
description:
@@ -25,6 +41,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.13.0"
audio_session:
dependency: transitive
description:
name: audio_session
sha256: "2b7fff16a552486d078bfc09a8cde19f426dc6d6329262b684182597bec5b1ac"
url: "https://pub.dev"
source: hosted
version: "0.1.25"
boolean_selector:
dependency: transitive
description:
@@ -49,6 +73,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.4"
cli_config:
dependency: transitive
description:
name: cli_config
sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec
url: "https://pub.dev"
source: hosted
version: "0.2.0"
cli_util:
dependency: transitive
description:
@@ -81,6 +113,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
cookie_jar:
dependency: "direct main"
description:
@@ -89,6 +129,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.0.9"
coverage:
dependency: transitive
description:
name: coverage
sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d"
url: "https://pub.dev"
source: hosted
version: "1.15.0"
crypto:
dependency: transitive
description:
@@ -109,26 +157,34 @@ packages:
dependency: "direct main"
description:
name: dio
sha256: b9d46faecab38fc8cc286f80bc4d61a3bb5d4ac49e51ed877b4d6706efe57b25
sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c
url: "https://pub.dev"
source: hosted
version: "5.9.1"
version: "5.9.2"
dio_cookie_manager:
dependency: "direct main"
description:
name: dio_cookie_manager
sha256: d39c16abcc711c871b7b29bd51c6b5f3059ef39503916c6a9df7e22c4fc595e0
sha256: "0db1a7b997a0455e488ac35744c68eed3f2a4280d3ab531835a65641b0a08744"
url: "https://pub.dev"
source: hosted
version: "3.3.0"
version: "3.4.0"
dio_web_adapter:
dependency: transitive
description:
name: dio_web_adapter
sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78"
sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
version: "2.1.2"
equatable:
dependency: transitive
description:
name: equatable
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
url: "https://pub.dev"
source: hosted
version: "2.0.8"
fake_async:
dependency: transitive
description:
@@ -153,6 +209,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
@@ -250,20 +314,36 @@ packages:
dependency: "direct main"
description:
name: flutter_riverpod
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
sha256: "4e166be88e1dbbaa34a280bdb744aeae73b7ef25fdf8db7a3bb776760a3648e2"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
version: "3.3.1"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_timezone:
dependency: "direct main"
description:
name: flutter_timezone
sha256: e8d63f50f2806a3a71a08697286a0369e1d8f0902961327810459871c0bb01c2
url: "https://pub.dev"
source: hosted
version: "5.0.2"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
frontend_server_client:
dependency: transitive
description:
name: frontend_server_client
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
url: "https://pub.dev"
source: hosted
version: "4.0.0"
glob:
dependency: transitive
description:
@@ -276,26 +356,26 @@ packages:
dependency: "direct main"
description:
name: go_router
sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3
sha256: "7974313e217a7771557add6ff2238acb63f635317c35fa590d348fb238f00896"
url: "https://pub.dev"
source: hosted
version: "14.8.1"
version: "17.1.0"
google_fonts:
dependency: "direct main"
description:
name: google_fonts
sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055
sha256: db9df7a5898d894eeda4c78143f35c30a243558be439518972366880b80bf88e
url: "https://pub.dev"
source: hosted
version: "6.3.3"
version: "8.0.2"
hooks:
dependency: transitive
description:
name: hooks
sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6"
sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388
url: "https://pub.dev"
source: hosted
version: "1.0.1"
version: "1.0.2"
http:
dependency: transitive
description:
@@ -304,6 +384,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_multi_server:
dependency: transitive
description:
name: http_multi_server
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
url: "https://pub.dev"
source: hosted
version: "3.2.2"
http_parser:
dependency: transitive
description:
@@ -320,6 +408,22 @@ 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:
name: io
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
url: "https://pub.dev"
source: hosted
version: "1.0.5"
json_annotation:
dependency: transitive
description:
@@ -328,6 +432,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.11.0"
just_audio:
dependency: "direct main"
description:
name: just_audio
sha256: f978d5b4ccea08f267dae0232ec5405c1b05d3f3cd63f82097ea46c015d5c09e
url: "https://pub.dev"
source: hosted
version: "0.9.46"
just_audio_platform_interface:
dependency: transitive
description:
name: just_audio_platform_interface
sha256: "2532c8d6702528824445921c5ff10548b518b13f808c2e34c2fd54793b999a6a"
url: "https://pub.dev"
source: hosted
version: "4.6.0"
just_audio_web:
dependency: transitive
description:
name: just_audio_web
sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663"
url: "https://pub.dev"
source: hosted
version: "0.4.16"
leak_tracker:
dependency: transitive
description:
@@ -412,10 +540,18 @@ packages:
dependency: transitive
description:
name: native_toolchain_c
sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac"
sha256: "92b2ca62c8bd2b8d2f267cdfccf9bfbdb7322f778f8f91b3ce5b5cda23a3899f"
url: "https://pub.dev"
source: hosted
version: "0.17.4"
version: "0.17.5"
node_preamble:
dependency: transitive
description:
name: node_preamble
sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
objective_c:
dependency: transitive
description:
@@ -488,14 +624,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.0.3"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
package_info_plus:
dependency: "direct main"
description:
name: package_info_plus
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d
url: "https://pub.dev"
source: hosted
version: "8.3.1"
version: "9.0.0"
package_info_plus_platform_interface:
dependency: transitive
description:
@@ -560,6 +704,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
permission_handler:
dependency: "direct main"
description:
name: permission_handler
sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849"
url: "https://pub.dev"
source: hosted
version: "11.4.0"
permission_handler_android:
dependency: transitive
description:
name: permission_handler_android
sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc
url: "https://pub.dev"
source: hosted
version: "12.1.0"
permission_handler_apple:
dependency: transitive
description:
name: permission_handler_apple
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
url: "https://pub.dev"
source: hosted
version: "9.4.7"
permission_handler_html:
dependency: transitive
description:
name: permission_handler_html
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
url: "https://pub.dev"
source: hosted
version: "0.1.3+5"
permission_handler_platform_interface:
dependency: transitive
description:
name: permission_handler_platform_interface
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
url: "https://pub.dev"
source: hosted
version: "4.3.0"
permission_handler_windows:
dependency: transitive
description:
name: permission_handler_windows
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
url: "https://pub.dev"
source: hosted
version: "0.2.1"
petitparser:
dependency: transitive
description:
@@ -584,6 +776,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.8"
pool:
dependency: transitive
description:
name: pool
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
url: "https://pub.dev"
source: hosted
version: "1.5.2"
posix:
dependency: transitive
description:
@@ -600,14 +800,78 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
record:
dependency: "direct main"
description:
name: record
sha256: "2e3d56d196abcd69f1046339b75e5f3855b2406fc087e5991f6703f188aa03a6"
url: "https://pub.dev"
source: hosted
version: "5.2.1"
record_android:
dependency: transitive
description:
name: record_android
sha256: "94783f08403aed33ffb68797bf0715b0812eb852f3c7985644c945faea462ba1"
url: "https://pub.dev"
source: hosted
version: "1.5.1"
record_darwin:
dependency: transitive
description:
name: record_darwin
sha256: e487eccb19d82a9a39cd0126945cfc47b9986e0df211734e2788c95e3f63c82c
url: "https://pub.dev"
source: hosted
version: "1.2.2"
record_linux:
dependency: "direct overridden"
description:
name: record_linux
sha256: c31a35cc158cd666fc6395f7f56fc054f31685571684be6b97670a27649ce5c7
url: "https://pub.dev"
source: hosted
version: "1.3.0"
record_platform_interface:
dependency: transitive
description:
name: record_platform_interface
sha256: "8a81dbc4e14e1272a285bbfef6c9136d070a47d9b0d1f40aa6193516253ee2f6"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
record_web:
dependency: transitive
description:
name: record_web
sha256: "7e9846981c1f2d111d86f0ae3309071f5bba8b624d1c977316706f08fc31d16d"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
record_windows:
dependency: transitive
description:
name: record_windows
sha256: "223258060a1d25c62bae18282c16783f28581ec19401d17e56b5205b9f039d78"
url: "https://pub.dev"
source: hosted
version: "1.0.7"
riverpod:
dependency: transitive
description:
name: riverpod
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
sha256: "8c22216be8ad3ef2b44af3a329693558c98eca7b8bd4ef495c92db0bba279f83"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
version: "3.2.1"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev"
source: hosted
version: "0.28.0"
shared_preferences:
dependency: "direct main"
description:
@@ -664,11 +928,67 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shelf:
dependency: transitive
description:
name: shelf
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
url: "https://pub.dev"
source: hosted
version: "1.4.2"
shelf_packages_handler:
dependency: transitive
description:
name: shelf_packages_handler
sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
shelf_static:
dependency: transitive
description:
name: shelf_static
sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3
url: "https://pub.dev"
source: hosted
version: "1.1.3"
shelf_web_socket:
dependency: transitive
description:
name: shelf_web_socket
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
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
source: sdk
version: "0.0.0"
source_map_stack_trace:
dependency: transitive
description:
name: source_map_stack_trace
sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b
url: "https://pub.dev"
source: hosted
version: "2.1.2"
source_maps:
dependency: transitive
description:
name: source_maps
sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812"
url: "https://pub.dev"
source: hosted
version: "0.10.13"
source_span:
dependency: transitive
description:
@@ -709,6 +1029,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
synchronized:
dependency: transitive
description:
name: synchronized
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
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:
@@ -717,6 +1053,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test:
dependency: transitive
description:
name: test
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
url: "https://pub.dev"
source: hosted
version: "1.30.0"
test_api:
dependency: transitive
description:
@@ -725,6 +1069,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.10"
test_core:
dependency: transitive
description:
name: test_core
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
url: "https://pub.dev"
source: hosted
version: "0.6.16"
typed_data:
dependency: transitive
description:
@@ -741,6 +1093,78 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.1"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572"
url: "https://pub.dev"
source: hosted
version: "6.3.29"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
url: "https://pub.dev"
source: hosted
version: "6.4.1"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.dev"
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
url: "https://pub.dev"
source: hosted
version: "2.4.2"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
url: "https://pub.dev"
source: hosted
version: "3.1.5"
uuid:
dependency: transitive
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.dev"
source: hosted
version: "4.5.3"
vector_math:
dependency: transitive
description:
@@ -757,6 +1181,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "15.0.2"
watcher:
dependency: transitive
description:
name: watcher
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
web:
dependency: transitive
description:
@@ -765,6 +1197,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: transitive
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev"
source: hosted
version: "3.0.3"
webkit_inspection_protocol:
dependency: transitive
description:
name: webkit_inspection_protocol
sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
win32:
dependency: transitive
description:
+14 -5
View File
@@ -2,7 +2,7 @@ name: fabled_app
description: "FabledAssistant mobile client for Android."
publish_to: 'none'
version: 26.03.02+1
version: 0.0.0+0 # overridden at build time via --build-name / --build-number
environment:
sdk: ^3.11.0
@@ -12,19 +12,28 @@ dependencies:
sdk: flutter
cupertino_icons: ^1.0.8
flutter_riverpod: ^2.5.1
go_router: ^14.2.0
flutter_riverpod: ^3.3.1
go_router: ^17.1.0
dio: ^5.6.0
cookie_jar: ^4.0.8
dio_cookie_manager: ^3.1.1
path_provider: ^2.1.4
shared_preferences: ^2.3.2
markdown: ^7.2.2
package_info_plus: ^8.0.0
package_info_plus: ^9.0.0
open_file: ^3.3.2
permission_handler: ^11.3.1
flutter_inappwebview: ^6.1.5
flutter_markdown_plus: ^1.0.7
google_fonts: ^6.2.1
google_fonts: ^8.0.2
flutter_timezone: ^5.0.2
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
dev_dependencies:
flutter_test:
+274 -3
View File
@@ -1,8 +1,279 @@
// Placeholder — integration tests go here once the app is running on device.
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() {
test('placeholder', () {
expect(true, isTrue);
group('Note.fromJson', () {
test('parses noteType when present', () {
final json = {
'id': 1,
'title': 'Alice',
'body': '',
'tags': <dynamic>[],
'note_type': 'person',
'created_at': '2024-01-01T00:00:00',
'updated_at': '2024-01-01T00:00:00',
};
final note = Note.fromJson(json);
expect(note.noteType, equals('person'));
});
test('defaults noteType to note when absent', () {
final json = {
'id': 2,
'title': 'My note',
'body': '',
'tags': <dynamic>[],
'created_at': '2024-01-01T00:00:00',
'updated_at': '2024-01-01T00:00:00',
};
final note = Note.fromJson(json);
expect(note.noteType, equals('note'));
});
});
group('KnowledgeItem.fromJson', () {
test('parses a task item with task fields', () {
final json = {
'id': 10,
'note_type': 'task',
'title': 'Do the thing',
'body': '',
'tags': <dynamic>[],
'status': 'todo',
'priority': 'high',
'due_date': '2025-01-01',
'created_at': '2024-01-01T00:00:00',
'updated_at': '2024-01-01T00:00:00',
};
final item = KnowledgeItem.fromJson(json);
expect(item.noteType, equals('task'));
expect(item.status, equals('todo'));
expect(item.priority, equals('high'));
});
test('defaults noteType to note when absent', () {
final json = {
'id': 11,
'title': 'A note',
'body': '',
'tags': <dynamic>[],
'created_at': '2024-01-01T00:00:00',
'updated_at': '2024-01-01T00:00:00',
};
final item = KnowledgeItem.fromJson(json);
expect(item.noteType, equals('note'));
expect(item.status, isNull);
});
});
group('VoiceStatus.fromJson', () {
test('parses enabled with stt and tts available', () {
final status = VoiceStatus.fromJson({
'enabled': true,
'stt': true,
'tts': true,
});
expect(status.enabled, isTrue);
expect(status.stt, isTrue);
expect(status.tts, isTrue);
});
test('parses disabled state', () {
final status = VoiceStatus.fromJson({
'enabled': false,
'stt': false,
'tts': false,
});
expect(status.enabled, isFalse);
});
test('fullyAvailable is false when enabled but stt is false', () {
final status = VoiceStatus.fromJson({
'enabled': true,
'stt': false,
'tts': true,
});
expect(status.fullyAvailable, isFalse);
});
});
group('VoiceNotifier sentence extraction', () {
test('extracts complete sentences at full stops', () {
final result = extractSentences('Hello world. How are you? I am fine!');
expect(result.sentences,
equals(['Hello world.', 'How are you?', 'I am fine!']));
expect(result.remainder, equals(''));
});
test('leaves incomplete fragment in remainder', () {
final result = extractSentences('Hello world. Incomplete');
expect(result.sentences, equals(['Hello world.']));
expect(result.remainder, equals('Incomplete'));
});
test('returns empty sentences and full text when no boundary', () {
final result = extractSentences('No boundary here');
expect(result.sentences, isEmpty);
expect(result.remainder, equals('No boundary here'));
});
});
group('VoiceNotifier markdown stripping', () {
test('strips code fences', () {
expect(stripMarkdownForTts('Before\n```dart\ncode\n```\nAfter'),
equals('Before After'));
});
test('strips bold and italic markers', () {
expect(stripMarkdownForTts('**bold** and *italic*'),
equals('bold and italic'));
});
test('strips headers', () {
expect(stripMarkdownForTts('## Section title'), equals('Section title'));
});
test('keeps link text, removes URL', () {
expect(stripMarkdownForTts('[click here](https://example.com)'),
equals('click here'));
});
test('strips list markers', () {
expect(stripMarkdownForTts('- item one\n- item two'),
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')));
expect(event.endDt, equals(DateTime.parse('2026-04-07T10:00:00+00:00')));
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);
});
});
}
@@ -7,8 +7,20 @@
#include "generated_plugin_registrant.h"
#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) {
FlutterInappwebviewWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterInappwebviewWindowsPluginCApi"));
FlutterTimezonePluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterTimezonePluginCApi"));
PermissionHandlerWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
RecordWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}
+4
View File
@@ -4,6 +4,10 @@
list(APPEND FLUTTER_PLUGIN_LIST
flutter_inappwebview_windows
flutter_timezone
permission_handler_windows
record_windows
url_launcher_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST