Compare commits

...

20 Commits

Author SHA1 Message Date
bvandeusen 6771ec5e81 Merge pull request 'Release v26.04.13.1' (#19) from dev into main 2026-04-13 05:13:44 +00:00
bvandeusen 4240c90d55 fix(voice,knowledge): mic recording + pagination + STT-only mode
Voice: recreate AudioRecorder each session to avoid stale native
state, improve permission handling (re-check after settings), show
feedback after 3 consecutive empty transcripts, allow STT-only mode
when TTS is unavailable.

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 11:34:00 -04:00
bvandeusen 36644cf8a5 ci: switch to ci-runner base image
Update runs-on from py3.12-node22 to ci-runner to match the new
shared runner base image with uv, ruff, jq, and tzdata baked in.
2026-04-12 00:00:40 -04:00
bvandeusen cb5ce44bbe ci: gate on dev/main pushes, pin Flutter, extract release script
- trigger: add branches: [dev, main] so analyze+test run on every
  push, not just release tags. Build job stays tag-gated via
  if: startsWith(github.ref, 'refs/tags/')
- pin Flutter to 3.41.6 instead of floating :stable for reproducible
  release builds
- concurrency: cancel in-progress non-tag runs
- permissions: contents: read default, contents: write scoped to build
- extract release publish logic to scripts/publish_apk_release.sh so
  it's testable locally (bash -x with env vars) and the YAML stays
  readable. Adds set -euo pipefail + curl -f so failures surface
  instead of getting swallowed by || echo "skipped"
- drop the broken artifact upload step (silently swallowed errors)
2026-04-11 16:11:50 -04:00
bvandeusen 356709856f Merge pull request 'fix(test): CalendarEvent test expects local time after toLocal() change' (#18) from dev into main 2026-04-08 21:22:44 +00:00
bvandeusen 6e067f99ef fix(test): update CalendarEvent test to expect local time after toLocal() change 2026-04-08 17:16:23 -04:00
bvandeusen ab3a482705 Merge pull request 'Violet theme, flicker-free refresh, STT context' (#17) from dev into main 2026-04-08 18:52:27 +00:00
bvandeusen 47c190891e fix(knowledge): hold stale items during refresh to eliminate flicker 2026-04-08 14:45:57 -04:00
bvandeusen 3e888b6458 fix: eliminate pull-to-refresh flicker by holding stale data during re-fetch 2026-04-08 14:39:50 -04:00
bvandeusen 6c29b685e8 feat(theme): shift Android palette from indigo to deep violet to match web identity 2026-04-08 13:54:39 -04:00
bvandeusen 5957551546 Merge pull request 'STT context, refresh improvements, calendar timezone fix' (#16) from dev into main 2026-04-07 21:52:20 +00:00
bvandeusen d2582f9111 feat: reduce resume cooldown to 30s; add pull-to-refresh to News, Calendar; refresh chat on resume 2026-04-07 17:43:46 -04:00
bvandeusen 36350d35b1 feat(stt): pass last assistant response as Whisper context to reduce mishearings 2026-04-07 09:57:07 -04:00
bvandeusen 96e6b6466f Merge pull request 'fix(calendar): convert event times to local timezone on parse' (#15) from dev into main 2026-04-07 03:07:22 +00:00
bvandeusen d75d34ce8e fix(calendar): convert event times to local timezone on parse 2026-04-06 23:05:33 -04:00
bvandeusen 1c97f9dea5 Android app: Calendar, News, Knowledge, Voice, Nav restructure, bug fixes 2026-04-07 02:41:10 +00:00
bvandeusen c177bf0691 fix: restore offline queue for captures, drain via chat on retry
Offline queue (SharedPreferences) persists captures when the device
is offline and replays them as chat conversations on next launch,
preserving the same fire-and-forget guarantee as the online path.

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 18:33:04 -04:00
24 changed files with 367 additions and 187 deletions
+38 -57
View File
@@ -1,6 +1,7 @@
# CI runs only on release tags.
# CI runs first; build only proceeds if all checks pass.
#
# Tag v*: analyze + test → build APK → attach to Forgejo Release
# Push to dev or main: flutter analyze + flutter test
# Tag v* (release): gates + signed APK build + attach to Forgejo Release
#
# To cut a release:
# Create a release via the Forgejo UI on main with a v* tag name.
@@ -11,19 +12,39 @@
# commands directly instead.
#
# Required secrets (repo → Settings → Secrets → Actions):
# RELEASE_TOKEN — Forgejo PAT with write:repository scope
# RELEASE_TOKEN — Forgejo PAT with write:repository scope
# RELEASE_KEYSTORE_BASE64 — base64 of the signing keystore
# RELEASE_KEYSTORE_PASSWORD — keystore + key password
# RELEASE_KEY_ALIAS — key alias within the keystore
name: CI & Build
on:
push:
branches: [dev, main]
tags: ["v*"]
# Cancel older runs on the same branch when a newer push lands. Tag runs
# are never cancelled so a release build can't kill itself mid-flight.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
# Least-privilege default. The build job upgrades to contents: write
# so it can attach the APK to a Forgejo release.
permissions:
contents: read
jobs:
analyze:
name: Analyze & test
runs-on: py3.12-node22
runs-on: ci-runner
container:
image: ghcr.io/cirruslabs/flutter:stable
# Pinned to a specific Flutter version for reproducible builds.
# Floating :stable means a random Flutter minor bump could change
# analyzer output or break the build without any commit landing.
# Bump this line (and verify locally with `flutter --version`)
# when you intentionally want a newer Flutter.
image: ghcr.io/cirruslabs/flutter:3.41.6
steps:
- name: Checkout
run: |
@@ -42,9 +63,14 @@ jobs:
build:
name: Build release APK
needs: [analyze]
runs-on: py3.12-node22
# Only tag pushes produce a signed release build. dev/main pushes
# run the gates above and stop there.
if: startsWith(github.ref, 'refs/tags/')
runs-on: ci-runner
container:
image: ghcr.io/cirruslabs/flutter:stable
image: ghcr.io/cirruslabs/flutter:3.41.6
permissions:
contents: write
steps:
- name: Checkout
run: |
@@ -74,57 +100,12 @@ jobs:
--build-name="$BUILD_NAME" \
--build-number="$BUILD_NUMBER"
- name: Set artifact name
id: artifact
run: |
echo "name=fabledapp-${{ github.ref_name }}-${{ github.sha }}" >> $GITHUB_OUTPUT
- name: Upload artifact to Forgejo
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
APK: build/app/outputs/flutter-apk/app-release.apk
API: https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledApp
ARTIFACT_NAME: ${{ steps.artifact.outputs.name }}
run: |
# Upload the APK as a workflow artifact via the Forgejo API.
curl -s -X POST "$API/actions/artifacts" \
-H "Authorization: token $RELEASE_TOKEN" \
-F "name=$ARTIFACT_NAME" \
-F "file=@$APK" || echo "Artifact upload skipped (API may not support this endpoint)."
- name: Publish Forgejo release
# Release-publish logic lives in a shell script so it's
# testable locally (bash -x scripts/publish_apk_release.sh
# with env vars set) instead of trapped in YAML.
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
TAG: ${{ github.ref_name }}
API: https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledApp
run: |
# Look for an existing release (created via the UI or a prior run).
EXISTING=$(curl -s \
"$API/releases/tags/$TAG" \
-H "Authorization: token $RELEASE_TOKEN")
RELEASE_ID=$(echo "$EXISTING" | grep -oE '"id":[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+')
if [ -n "$RELEASE_ID" ]; then
echo "Found existing release $TAG (id $RELEASE_ID), attaching APK..."
else
echo "No existing release found, creating $TAG..."
RESPONSE=$(curl -s -X POST "$API/releases" \
-H "Authorization: token $RELEASE_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"$TAG\", \"name\": \"$TAG\", \"body\": \"\"}")
RELEASE_ID=$(echo "$RESPONSE" | grep -oE '"id":[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+')
if [ -z "$RELEASE_ID" ]; then
echo "Failed to create release. API response:"
echo "$RESPONSE"
exit 1
fi
echo "Release created with id $RELEASE_ID."
fi
curl -s -X POST "$API/releases/$RELEASE_ID/assets" \
-H "Authorization: token $RELEASE_TOKEN" \
-F "attachment=@build/app/outputs/flutter-apk/app-release.apk"
echo "Done — $TAG is live at:"
echo "https://git.fabledsword.com/bvandeusen/FabledApp/releases/tag/$TAG"
APK: build/app/outputs/flutter-apk/app-release.apk
run: bash scripts/publish_apk_release.sh
+15 -25
View File
@@ -5,10 +5,10 @@ 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/briefing_provider.dart';
@@ -16,10 +16,8 @@ import 'providers/calendar_provider.dart';
import 'providers/chat_provider.dart';
import 'providers/knowledge_provider.dart';
import 'providers/news_provider.dart';
import 'providers/notes_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';
@@ -195,7 +193,7 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
];
// Minimum gap between app-resume refreshes to avoid hammering the server.
static const _resumeCooldown = Duration(minutes: 5);
static const _resumeCooldown = Duration(seconds: 30);
DateTime? _lastResumeRefresh;
int? _prevTabIndex;
@@ -238,13 +236,13 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
/// 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(conversationsProvider.notifier).refresh();
ref.read(calendarProvider.notifier).refresh();
ref.read(newsProvider.notifier).refresh();
ref.read(knowledgeProvider.notifier).refresh();
// briefingProvider is an AsyncNotifier family; invalidating the family
// is safe even if no conversation is open.
// briefingProvider is an AsyncNotifier family; invalidating is safe
// even if no conversation is open — it doesn't cause flicker since
// the briefing screen isn't a list view.
ref.invalidate(briefingProvider);
}
@@ -256,7 +254,7 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
case 1:
ref.read(knowledgeProvider.notifier).refresh();
case 2:
ref.invalidate(conversationsProvider);
ref.read(conversationsProvider.notifier).refresh();
}
}
@@ -541,27 +539,19 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
if (!mounted) return;
final queue = ref.read(captureQueueProvider);
if (queue.isEmpty) return;
final api = ref.read(quickCaptureApiProvider);
for (final text in List<String>.from(queue)) {
if (!mounted) break;
try {
final result = await api.capture(text);
// Dequeue before the mounted check — SharedPreferences doesn't need
// the widget alive, and skipping this would leave a ghost item.
final conv =
await ref.read(conversationsProvider.notifier).create('');
final chatRepo = ref.read(chatRepositoryProvider);
await chatRepo.sendMessage(conv.id, text);
chatRepo.streamGeneration(conv.id).drain<void>().ignore();
await ref.read(captureQueueProvider.notifier).dequeue(text);
if (!mounted) break;
switch (result.type) {
case 'note':
ref.invalidate(notesProvider);
case 'task':
case 'todo':
ref.invalidate(tasksProvider);
}
} on NetworkException {
break;
} catch (_) {
// Server error or unexpected failure — drop from queue to prevent
// ghost items that can never be cleared.
// Server error — drop from queue to prevent ghost items.
await ref.read(captureQueueProvider.notifier).dequeue(text);
}
}
+17 -17
View File
@@ -3,21 +3,21 @@ import 'package:google_fonts/google_fonts.dart';
// ── Colour constants ──────────────────────────────────────────────────────────
const _darkBackground = Color(0xFF111113);
const _darkSurface = Color(0xFF18181C);
const _darkSurfaceVar = Color(0xFF1E1E24);
const _darkPrimary = Color(0xFF6366F1);
const _darkOnSurface = Color(0xFFE8E8F0);
const _darkBackground = Color(0xFF0F0F14);
const _darkSurface = Color(0xFF16161F);
const _darkSurfaceVar = Color(0xFF1A1A24);
const _darkPrimary = Color(0xFF7C3AED);
const _darkOnSurface = Color(0xFFE4E4F0);
const _darkOnSurfaceVar = Color(0xFF8888A8);
const _darkOutline = Color(0xFF2E2E3A);
const _darkOutline = Color(0xFF2A2A3A);
const _lightBackground = Color(0xFFF4F4F8);
const _lightBackground = Color(0xFFF5F5FB);
const _lightSurface = Color(0xFFFFFFFF);
const _lightSurfaceVar = Color(0xFFF0F0F5);
const _lightPrimary = Color(0xFF4F46E5);
const _lightOnSurface = Color(0xFF18181C);
const _lightOnSurfaceVar = Color(0xFF6B6B88);
const _lightOutline = Color(0xFFD4D4E4);
const _lightSurfaceVar = Color(0xFFF0F0F8);
const _lightPrimary = Color(0xFF7C3AED);
const _lightOnSurface = Color(0xFF1A1A1A);
const _lightOnSurfaceVar = Color(0xFF666666);
const _lightOutline = Color(0xFFDDDDE8);
// ── Typography ─────────────────────────────────────────────────────────────────
@@ -41,7 +41,7 @@ ThemeData fabledDarkTheme() {
brightness: Brightness.dark,
primary: _darkPrimary,
onPrimary: Colors.white,
primaryContainer: const Color(0xFF3730A3),
primaryContainer: const Color(0xFF5B21B6),
onPrimaryContainer: _darkOnSurface,
secondary: _darkPrimary,
onSecondary: Colors.white,
@@ -118,7 +118,7 @@ ThemeData fabledLightTheme() {
brightness: Brightness.light,
primary: _lightPrimary,
onPrimary: Colors.white,
primaryContainer: const Color(0xFFE0E0FF),
primaryContainer: const Color(0xFFEDE5FF),
onPrimaryContainer: _lightOnSurface,
secondary: _lightPrimary,
onSecondary: Colors.white,
@@ -218,15 +218,15 @@ class GradientButton extends StatelessWidget {
: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF6366F1), Color(0xFF4F46E5)],
colors: [Color(0xFF7C3AED), Color(0xFF5B21B6)],
),
color: disabled ? const Color(0xFF6366F1) : null,
color: disabled ? const Color(0xFF7C3AED) : null,
borderRadius: BorderRadius.circular(12),
boxShadow: disabled
? null
: [
BoxShadow(
color: const Color(0xFF6366F1).withValues(alpha: 0.35),
color: const Color(0xFF7C3AED).withValues(alpha: 0.45),
blurRadius: 8,
offset: const Offset(0, 3),
),
+10 -5
View File
@@ -15,8 +15,9 @@ class VoiceStatus {
required this.tts,
});
/// True only when voice is enabled AND both STT and TTS are ready.
bool get fullyAvailable => enabled && stt && tts;
/// True when voice is enabled and at least STT is ready.
/// TTS is optional — voice mode works without it (STT-only).
bool get fullyAvailable => enabled && stt;
factory VoiceStatus.fromJson(Map<String, dynamic> json) => VoiceStatus(
enabled: json['enabled'] as bool? ?? false,
@@ -40,16 +41,20 @@ class VoiceApi {
}
/// POST WebM/Opus audio bytes and return the transcript string.
/// [context] is optional recent conversation text passed as initial_prompt
/// to Whisper, reducing mishearings of domain-specific words.
/// Returns empty string on empty or error response.
Future<String> transcribe(Uint8List audioBytes) async {
Future<String> transcribe(Uint8List audioBytes, {String? context}) async {
try {
final formData = FormData.fromMap({
final fields = <String, dynamic>{
'audio': MultipartFile.fromBytes(
audioBytes,
filename: 'audio.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,
+2 -2
View File
@@ -28,9 +28,9 @@ class CalendarEvent {
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),
startDt: DateTime.parse(json['start_dt'] as String).toLocal(),
endDt: json['end_dt'] != null
? DateTime.parse(json['end_dt'] as String)
? DateTime.parse(json['end_dt'] as String).toLocal()
: null,
allDay: json['all_day'] as bool? ?? false,
description: json['description'] as String? ?? '',
+2 -1
View File
@@ -7,6 +7,7 @@ class VoiceRepository {
const VoiceRepository(this._api);
Future<VoiceStatus> checkStatus() => _api.checkStatus();
Future<String> transcribe(Uint8List audioBytes) => _api.transcribe(audioBytes);
Future<String> transcribe(Uint8List audioBytes, {String? context}) =>
_api.transcribe(audioBytes, context: context);
Future<Uint8List> synthesise(String text) => _api.synthesise(text);
}
+11
View File
@@ -56,6 +56,17 @@ class CalendarNotifier extends AsyncNotifier<CalendarState> {
);
}
/// Re-fetch events for the current range without clearing state (no flicker).
Future<void> refresh() async {
final current = state.value;
if (current == null) return;
final events = await ref.read(eventsApiProvider).getEvents(
current.loadedRange.start,
current.loadedRange.end,
);
state = AsyncData(current.copyWith(eventsByDay: _groupByDay(events)));
}
/// Synchronously updates selectedDay and focusedMonth. No API call.
void selectDay(DateTime day) {
final current = state.value;
+16 -32
View File
@@ -3,8 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/exceptions.dart';
import 'api_client_provider.dart';
import 'capture_queue_provider.dart';
import 'notes_provider.dart';
import 'tasks_provider.dart';
import 'chat_provider.dart';
/// Outcome of a single capture attempt — consumed by the UI for snackbars.
class CaptureResult {
@@ -25,7 +24,6 @@ class _CaptureResultNotifier extends Notifier<CaptureResult?> {
}
/// In-memory sequential work queue for quick captures.
/// Separate from [captureQueueProvider] (which is the offline persistence queue).
final captureWorkQueueProvider =
NotifierProvider<CaptureWorkQueueNotifier, List<String>>(
CaptureWorkQueueNotifier.new,
@@ -52,31 +50,24 @@ class CaptureWorkQueueNotifier extends Notifier<List<String>> {
// Signal "no result yet" so the same result value can re-trigger watch.
ref.read(captureResultProvider.notifier).state = null;
try {
final api = ref.read(quickCaptureApiProvider);
final result = await api.capture(text);
// Create a new conversation, add it to the conversations list, then
// send the message and kick off generation in the background.
final conv =
await ref.read(conversationsProvider.notifier).create('');
final chatRepo = ref.read(chatRepositoryProvider);
await chatRepo.sendMessage(conv.id, text);
// Fire-and-forget: drain the SSE stream so the server generates a
// response (creating notes/tasks/etc.) without blocking the UI.
chatRepo.streamGeneration(conv.id).drain<void>().ignore();
// Dequeue on success.
state = state.length > 1 ? state.sublist(1) : [];
// Invalidate content providers so lists refresh.
switch (result.type) {
case 'note':
ref.invalidate(notesProvider);
case 'task':
case 'todo':
ref.invalidate(tasksProvider);
}
// Publish result for snackbar.
final msg = result.message.isNotEmpty
? result.message
: '${_typeLabel(result.type)} created: ${result.title}';
ref.read(captureResultProvider.notifier).state = CaptureResult(msg);
ref.read(captureResultProvider.notifier).state =
const CaptureResult('Sent to Fabled.');
} on NetworkException catch (_) {
// Persist to offline queue and stop draining — still offline.
await ref.read(captureQueueProvider.notifier).enqueue(text);
state = state.length > 1 ? state.sublist(1) : [];
ref.read(captureResultProvider.notifier).state = CaptureResult(
ref.read(captureResultProvider.notifier).state = const CaptureResult(
"You're offline — capture saved and will retry automatically.",
);
break;
@@ -86,20 +77,13 @@ class CaptureWorkQueueNotifier extends Notifier<List<String>> {
CaptureResult(e.message, isError: true);
} catch (_) {
state = state.length > 1 ? state.sublist(1) : [];
ref.read(captureResultProvider.notifier).state =
CaptureResult('Capture failed. Please try again.', isError: true);
ref.read(captureResultProvider.notifier).state = const CaptureResult(
'Failed to send. Please try again.',
isError: true);
}
}
} finally {
_running = false;
}
}
String _typeLabel(String type) => switch (type) {
'note' => 'Note',
'task' => 'Task',
'event' => 'Event',
'todo' => 'To-do',
_ => type,
};
}
+13
View File
@@ -58,6 +58,12 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
]);
}
/// Re-fetch conversations without clearing the current list (no flicker).
Future<void> refresh() async {
final fresh = await ref.read(chatRepositoryProvider).getConversations();
state = AsyncData(fresh);
}
// Called after a message is sent to patch the server-generated title
// in-place without triggering a full reload or loading state.
void patchConversation(Conversation updated) {
@@ -86,6 +92,13 @@ class MessagesNotifier extends AsyncNotifier<List<Message>> {
return messages;
}
/// Re-fetch messages without clearing the current list (no flicker).
Future<void> refresh() async {
final (_, messages) =
await ref.read(chatRepositoryProvider).getMessages(_convId);
state = AsyncData(messages);
}
Future<void> sendMessage(String content) async {
final convId = _convId;
final repo = ref.read(chatRepositoryProvider);
+40 -6
View File
@@ -115,12 +115,43 @@ class KnowledgeNotifier extends Notifier<KnowledgeState> {
}
Future<void> refresh() async {
state = KnowledgeState(
noteType: state.noteType,
activeTags: state.activeTags,
searchQuery: state.searchQuery,
);
await _fetchFromScratch();
// Keep current items visible during re-fetch (no flicker).
try {
if (state.noteType == 'task') {
final tasks = await ref.read(tasksApiProvider).getAll();
final items = {
for (final t in tasks) t.id: KnowledgeItem.fromTask(t),
};
state = state.copyWith(
ids: tasks.map((t) => t.id).toList(),
items: items,
totalIds: tasks.length,
hasMore: false,
);
await _loadCounts();
return;
}
final (ids, total) = await _repo.fetchIds(
noteType: state.noteType,
tags: state.activeTags,
q: state.searchQuery,
limit: 50,
offset: 0,
);
// Hydrate the new IDs
final batch = await _repo.fetchBatch(ids);
final freshItems = {for (final item in batch) item.id: item};
state = state.copyWith(
ids: ids,
items: freshItems,
totalIds: total,
hasMore: ids.length < total,
);
await Future.wait([_loadCounts(), _loadTags()]);
} catch (_) {
// Silent — stale data is better than an error on refresh
}
}
// ── Scroll-triggered loaders ─────────────────────────────────────────────
@@ -215,6 +246,9 @@ class KnowledgeNotifier extends Notifier<KnowledgeState> {
isLoadingIds: false,
hasMore: combined.length < total,
);
// Hydrate the newly fetched IDs immediately — the user is
// already at the scroll bottom so _onScroll won't re-fire.
await hydrateNext();
} catch (_) {
state = state.copyWith(isLoadingIds: false);
}
+19
View File
@@ -71,6 +71,25 @@ class NewsNotifier extends AsyncNotifier<NewsState> {
);
}
/// Re-fetch the first page without clearing state (no flicker).
Future<void> refresh() async {
final current = state.value;
final items = await ref.read(newsApiProvider).getNewsItems(
days: 90,
limit: _limit,
offset: 0,
feedId: current?.selectedFeedId,
);
state = AsyncData(NewsState(
items: items,
offset: items.length,
hasMore: items.length == _limit,
loadingMore: false,
selectedFeedId: current?.selectedFeedId,
reactions: {for (final item in items) item.id: item.reaction},
));
}
Future<void> loadMore() async {
final current = state.value;
if (current == null || current.loadingMore || !current.hasMore) return;
+6
View File
@@ -15,6 +15,12 @@ class ProjectsNotifier extends AsyncNotifier<List<Project>> {
.getAll(sort: 'updated_at', order: 'desc');
}
Future<void> refresh() async {
final fresh = await ref.read(projectsRepositoryProvider)
.getAll(sort: 'updated_at', order: 'desc');
state = AsyncData(fresh);
}
Future<Project> create({
required String title,
String? description,
+5
View File
@@ -17,6 +17,11 @@ class TasksNotifier extends AsyncNotifier<List<Task>> {
return ref.watch(tasksRepositoryProvider).getAll();
}
Future<void> refresh() async {
final fresh = await ref.read(tasksRepositoryProvider).getAll();
state = AsyncData(fresh);
}
Future<Task> create({
required String title,
String? description,
+48 -17
View File
@@ -108,6 +108,13 @@ class VoiceNotifier extends Notifier<VoiceState> {
int _lastSeenLength = 0;
bool _streamComplete = false;
// Last complete assistant response — passed to Whisper as initial_prompt
// to reduce STT mishearings of domain-specific words.
String _lastAssistantContent = '';
// Empty transcript counter — show feedback after consecutive blanks
int _emptyTranscriptCount = 0;
// TTS playback queue
final _ttsQueue = Queue<Uint8List>();
bool _ttsPlaying = false;
@@ -116,7 +123,6 @@ class VoiceNotifier extends Notifier<VoiceState> {
@override
VoiceState build() {
_recorder = AudioRecorder();
_player = AudioPlayer();
ref.onDispose(() {
_amplitudeSubscription?.cancel();
@@ -142,32 +148,38 @@ class VoiceNotifier extends Notifier<VoiceState> {
return;
}
// Check server availability
// Check server availability — STT is required, TTS is optional.
try {
final status = await ref.read(voiceRepositoryProvider).checkStatus();
if (!status.fullyAvailable) {
onError('Voice not available on this server');
if (!status.enabled || !status.stt) {
onError('Speech-to-text not available on this server');
return;
}
// Downgrade to STT-only when TTS is unavailable
if (!status.tts) enableTts = false;
} catch (_) {
onError('Voice not available on this server');
onError('Could not reach voice service');
return;
}
// Check microphone permission
final permStatus = await Permission.microphone.request();
if (permStatus == PermissionStatus.denied ||
permStatus == PermissionStatus.permanentlyDenied) {
var permStatus = await Permission.microphone.request();
if (permStatus == PermissionStatus.permanentlyDenied) {
onError('Microphone blocked — opening settings');
final opened = await openAppSettings();
if (!opened) return;
// Re-check after user returns from settings
permStatus = await Permission.microphone.status;
}
if (!permStatus.isGranted) {
onError('Microphone permission required');
if (permStatus == PermissionStatus.permanentlyDenied) {
await openAppSettings();
}
return;
}
_onTranscript = onTranscript;
_onError = onError;
_enableTts = enableTts;
_emptyTranscriptCount = 0;
_tempDir = await getTemporaryDirectory();
state = state.copyWith(voiceModeActive: true, available: true);
@@ -205,6 +217,7 @@ class VoiceNotifier extends Notifier<VoiceState> {
_dispatchSentences(flush: isComplete);
if (isComplete) {
_lastAssistantContent = fullContent;
_streamComplete = true;
_checkRestartListening();
}
@@ -220,12 +233,22 @@ class VoiceNotifier extends Notifier<VoiceState> {
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 {
// Recreate the recorder each session — the record package can leave
// the native AudioRecord in a bad state after stop/error cycles,
// and a stale instance is the most common cause of "could not start".
_recorder?.dispose();
_recorder = AudioRecorder();
if (!await _recorder!.hasPermission()) {
_onError?.call('Microphone permission was revoked');
exitVoiceMode();
return;
}
await _recorder!.start(
const RecordConfig(encoder: AudioEncoder.aacLc, sampleRate: 16000),
path: path,
@@ -236,7 +259,7 @@ class VoiceNotifier extends Notifier<VoiceState> {
.onAmplitudeChanged(const Duration(milliseconds: 200))
.listen(_onAmplitude);
} catch (e) {
_onError?.call('Microphone error: could not start recording');
_onError?.call('Microphone error: $e');
exitVoiceMode();
}
}
@@ -278,16 +301,24 @@ class VoiceNotifier extends Notifier<VoiceState> {
if (!state.voiceModeActive) return;
final transcript =
await ref.read(voiceRepositoryProvider).transcribe(bytes);
final transcript = await ref.read(voiceRepositoryProvider).transcribe(
bytes,
context: _lastAssistantContent.isNotEmpty ? _lastAssistantContent : null,
);
if (!state.voiceModeActive) return;
if (transcript.isEmpty) {
// Empty transcript — restart silently
_emptyTranscriptCount++;
if (_emptyTranscriptCount >= 3) {
_onError?.call('No speech detected — tap the mic to try again');
exitVoiceMode();
return;
}
await _startListening();
return;
}
_emptyTranscriptCount = 0;
// Reset TTS state for this new turn
_sentenceBuffer = '';
+1 -1
View File
@@ -462,7 +462,7 @@ class _GradientSendButton extends StatelessWidget {
: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF6366F1), Color(0xFF4F46E5)],
colors: [Color(0xFF7C3AED), Color(0xFF5B21B6)],
),
color: disabled ? scheme.onSurface.withValues(alpha: 0.12) : null,
borderRadius: BorderRadius.circular(10),
+13 -5
View File
@@ -67,10 +67,13 @@ class CalendarScreen extends ConsumerWidget {
),
const Divider(height: 1),
Expanded(
child: _AgendaList(
events:
cal.eventsByDay[dateOnly(cal.selectedDay)] ?? [],
notifier: notifier,
child: RefreshIndicator(
onRefresh: () => ref.read(calendarProvider.notifier).refresh(),
child: _AgendaList(
events:
cal.eventsByDay[dateOnly(cal.selectedDay)] ?? [],
notifier: notifier,
),
),
),
],
@@ -106,7 +109,12 @@ class _AgendaList extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (events.isEmpty) {
return const Center(child: Text('No events'));
return ListView(
children: const [
SizedBox(height: 80),
Center(child: Text('No events')),
],
);
}
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
+6 -6
View File
@@ -57,7 +57,7 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
'#EF4444',
'#F59E0B',
'#10B981',
'#6366F1',
'#7C3AED',
'#8B5CF6',
'#EC4899',
];
@@ -143,19 +143,19 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
Future<void> _delete() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
builder: (dialogContext) => AlertDialog(
title: const Text('Delete this event?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
onPressed: () => Navigator.pop(dialogContext, true),
child: Text(
'Delete',
style:
TextStyle(color: Theme.of(context).colorScheme.error),
style: TextStyle(
color: Theme.of(dialogContext).colorScheme.error),
),
),
],
+16 -1
View File
@@ -16,12 +16,27 @@ 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.read(messagesProvider(widget.conversationId).notifier).refresh();
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_controller.dispose();
_scrollController.dispose();
// Exit voice mode if the user navigates away mid-session.
@@ -64,7 +64,7 @@ class ConversationsTabScreen extends ConsumerWidget {
);
}
return RefreshIndicator(
onRefresh: () async => ref.invalidate(conversationsProvider),
onRefresh: () => ref.read(conversationsProvider.notifier).refresh(),
child: ListView.builder(
itemCount: convs.length,
itemBuilder: (ctx, i) {
@@ -99,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')),
],
),
@@ -55,11 +55,11 @@ class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
}
Color _parseColor(String? hex) {
if (hex == null || hex.isEmpty) return const Color(0xFF6366F1);
if (hex == null || hex.isEmpty) return const Color(0xFF7C3AED);
try {
return Color(int.parse(hex.replaceFirst('#', '0xFF')));
} catch (_) {
return const Color(0xFF6366F1);
return const Color(0xFF7C3AED);
}
}
@@ -151,8 +151,10 @@ class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
return RefreshIndicator(
onRefresh: () async {
setState(() => _pendingStatus.clear());
ref.invalidate(projectTasksProvider(widget.projectId));
ref.invalidate(projectMilestonesProvider(widget.projectId));
await Future.wait([
ref.refresh(projectTasksProvider(widget.projectId).future),
ref.refresh(projectMilestonesProvider(widget.projectId).future),
]);
},
child: CustomScrollView(
slivers: [
+4 -1
View File
@@ -96,7 +96,9 @@ class _NewsScreenState extends ConsumerState<NewsScreen> {
),
const Divider(height: 1),
Expanded(
child: ListView.builder(
child: RefreshIndicator(
onRefresh: () => ref.read(newsProvider.notifier).refresh(),
child: ListView.builder(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
itemCount: news.items.length + 1,
@@ -128,6 +130,7 @@ class _NewsScreenState extends ConsumerState<NewsScreen> {
);
},
),
),
),
],
),
+1 -1
View File
@@ -19,7 +19,7 @@ class ProjectsScreen extends ConsumerWidget {
data: (projects) => projects.isEmpty
? const Center(child: Text('No projects yet.'))
: RefreshIndicator(
onRefresh: () async => ref.invalidate(projectsProvider),
onRefresh: () => ref.read(projectsProvider.notifier).refresh(),
child: ListView.separated(
itemCount: projects.length,
separatorBuilder: (_, _) => const Divider(height: 1),
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
#
# Publish a signed release APK to Forgejo.
#
# Finds the release matching $TAG (creating it if the CI job got here
# before the UI did), then attaches the APK as a release asset.
#
# Required env:
# RELEASE_TOKEN — Forgejo PAT with write:repository scope
# TAG — release tag (e.g. v26.04.11)
# APK — path to the built APK
#
# Optional env:
# FORGEJO_API — defaults to the FabledApp repo API root
#
# Exits non-zero if the release can't be created or the asset upload
# fails. Designed to be testable locally:
# RELEASE_TOKEN=... TAG=v0.0.1 APK=/tmp/test.apk bash -x scripts/publish_apk_release.sh
set -euo pipefail
: "${RELEASE_TOKEN:?RELEASE_TOKEN not set}"
: "${TAG:?TAG not set}"
: "${APK:?APK not set}"
if [ ! -f "$APK" ]; then
echo "APK not found at $APK" >&2
exit 1
fi
API="${FORGEJO_API:-https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledApp}"
# jq isn't in the cirruslabs Flutter image, so we parse JSON with grep.
# Fragile but bounded: we only care about the first "id": <n> field,
# which is always the release id in Forgejo's responses for these
# endpoints. If Forgejo ever adds a preceding id field, revisit.
extract_id() {
echo "$1" | grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+'
}
# Look for an existing release (created via the UI or a prior run).
# curl -f turns 4xx/5xx into non-zero so we can distinguish "not found"
# (no release yet) from "auth broken" (real failure).
existing=$(curl -fsS -H "Authorization: token $RELEASE_TOKEN" \
"$API/releases/tags/$TAG" 2>/dev/null || true)
release_id=$(extract_id "$existing")
if [ -z "$release_id" ]; then
echo "No existing release for $TAG — creating one..."
response=$(curl -fsS -X POST \
-H "Authorization: token $RELEASE_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"$TAG\", \"name\": \"$TAG\", \"body\": \"\"}" \
"$API/releases")
release_id=$(extract_id "$response")
if [ -z "$release_id" ]; then
echo "Failed to create release. API response:" >&2
echo "$response" >&2
exit 1
fi
echo "Created release $TAG (id=$release_id)."
else
echo "Found existing release $TAG (id=$release_id). Attaching APK..."
fi
curl -fsS -X POST \
-H "Authorization: token $RELEASE_TOKEN" \
-F "attachment=@$APK" \
"$API/releases/$release_id/assets" > /dev/null
echo "Done — $TAG is live at:"
echo "https://git.fabledsword.com/bvandeusen/FabledApp/releases/tag/$TAG"
+2 -2
View File
@@ -209,8 +209,8 @@ void main() {
final event = CalendarEvent.fromJson(json);
expect(event.id, equals(10));
expect(event.title, equals('Team meeting'));
expect(event.startDt, equals(DateTime.parse('2026-04-07T09:00:00+00:00')));
expect(event.endDt, equals(DateTime.parse('2026-04-07T10:00:00+00:00')));
expect(event.startDt, equals(DateTime.parse('2026-04-07T09:00:00+00:00').toLocal()));
expect(event.endDt, equals(DateTime.parse('2026-04-07T10:00:00+00:00').toLocal()));
expect(event.allDay, isFalse);
expect(event.description, equals('Weekly sync'));
expect(event.location, equals('Room 4'));