Compare commits

...

6 Commits

17 changed files with 130 additions and 43 deletions
+7 -7
View File
@@ -236,13 +236,13 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
/// Refresh every major data provider. Safe to call speculatively — /// Refresh every major data provider. Safe to call speculatively —
/// providers that aren't currently watched are already disposed. /// providers that aren't currently watched are already disposed.
void _refreshAll() { void _refreshAll() {
ref.invalidate(conversationsProvider); ref.read(conversationsProvider.notifier).refresh();
ref.invalidate(calendarProvider); ref.read(calendarProvider.notifier).refresh();
ref.invalidate(newsProvider); ref.read(newsProvider.notifier).refresh();
// Notifier (not AsyncNotifier) — needs explicit refresh call.
ref.read(knowledgeProvider.notifier).refresh(); ref.read(knowledgeProvider.notifier).refresh();
// briefingProvider is an AsyncNotifier family; invalidating the family // briefingProvider is an AsyncNotifier family; invalidating is safe
// is safe even if no conversation is open. // even if no conversation is open — it doesn't cause flicker since
// the briefing screen isn't a list view.
ref.invalidate(briefingProvider); ref.invalidate(briefingProvider);
} }
@@ -254,7 +254,7 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
case 1: case 1:
ref.read(knowledgeProvider.notifier).refresh(); ref.read(knowledgeProvider.notifier).refresh();
case 2: case 2:
ref.invalidate(conversationsProvider); ref.read(conversationsProvider.notifier).refresh();
} }
} }
+17 -17
View File
@@ -3,21 +3,21 @@ import 'package:google_fonts/google_fonts.dart';
// ── Colour constants ────────────────────────────────────────────────────────── // ── Colour constants ──────────────────────────────────────────────────────────
const _darkBackground = Color(0xFF111113); const _darkBackground = Color(0xFF0F0F14);
const _darkSurface = Color(0xFF18181C); const _darkSurface = Color(0xFF16161F);
const _darkSurfaceVar = Color(0xFF1E1E24); const _darkSurfaceVar = Color(0xFF1A1A24);
const _darkPrimary = Color(0xFF6366F1); const _darkPrimary = Color(0xFF7C3AED);
const _darkOnSurface = Color(0xFFE8E8F0); const _darkOnSurface = Color(0xFFE4E4F0);
const _darkOnSurfaceVar = Color(0xFF8888A8); 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 _lightSurface = Color(0xFFFFFFFF);
const _lightSurfaceVar = Color(0xFFF0F0F5); const _lightSurfaceVar = Color(0xFFF0F0F8);
const _lightPrimary = Color(0xFF4F46E5); const _lightPrimary = Color(0xFF7C3AED);
const _lightOnSurface = Color(0xFF18181C); const _lightOnSurface = Color(0xFF1A1A1A);
const _lightOnSurfaceVar = Color(0xFF6B6B88); const _lightOnSurfaceVar = Color(0xFF666666);
const _lightOutline = Color(0xFFD4D4E4); const _lightOutline = Color(0xFFDDDDE8);
// ── Typography ───────────────────────────────────────────────────────────────── // ── Typography ─────────────────────────────────────────────────────────────────
@@ -41,7 +41,7 @@ ThemeData fabledDarkTheme() {
brightness: Brightness.dark, brightness: Brightness.dark,
primary: _darkPrimary, primary: _darkPrimary,
onPrimary: Colors.white, onPrimary: Colors.white,
primaryContainer: const Color(0xFF3730A3), primaryContainer: const Color(0xFF5B21B6),
onPrimaryContainer: _darkOnSurface, onPrimaryContainer: _darkOnSurface,
secondary: _darkPrimary, secondary: _darkPrimary,
onSecondary: Colors.white, onSecondary: Colors.white,
@@ -118,7 +118,7 @@ ThemeData fabledLightTheme() {
brightness: Brightness.light, brightness: Brightness.light,
primary: _lightPrimary, primary: _lightPrimary,
onPrimary: Colors.white, onPrimary: Colors.white,
primaryContainer: const Color(0xFFE0E0FF), primaryContainer: const Color(0xFFEDE5FF),
onPrimaryContainer: _lightOnSurface, onPrimaryContainer: _lightOnSurface,
secondary: _lightPrimary, secondary: _lightPrimary,
onSecondary: Colors.white, onSecondary: Colors.white,
@@ -218,15 +218,15 @@ class GradientButton extends StatelessWidget {
: const LinearGradient( : const LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, 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), borderRadius: BorderRadius.circular(12),
boxShadow: disabled boxShadow: disabled
? null ? null
: [ : [
BoxShadow( BoxShadow(
color: const Color(0xFF6366F1).withValues(alpha: 0.35), color: const Color(0xFF7C3AED).withValues(alpha: 0.45),
blurRadius: 8, blurRadius: 8,
offset: const Offset(0, 3), offset: const Offset(0, 3),
), ),
+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. /// Synchronously updates selectedDay and focusedMonth. No API call.
void selectDay(DateTime day) { void selectDay(DateTime day) {
final current = state.value; final current = state.value;
+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 // Called after a message is sent to patch the server-generated title
// in-place without triggering a full reload or loading state. // in-place without triggering a full reload or loading state.
void patchConversation(Conversation updated) { void patchConversation(Conversation updated) {
@@ -86,6 +92,13 @@ class MessagesNotifier extends AsyncNotifier<List<Message>> {
return messages; 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 { Future<void> sendMessage(String content) async {
final convId = _convId; final convId = _convId;
final repo = ref.read(chatRepositoryProvider); final repo = ref.read(chatRepositoryProvider);
+37 -6
View File
@@ -115,12 +115,43 @@ class KnowledgeNotifier extends Notifier<KnowledgeState> {
} }
Future<void> refresh() async { Future<void> refresh() async {
state = KnowledgeState( // Keep current items visible during re-fetch (no flicker).
noteType: state.noteType, try {
activeTags: state.activeTags, if (state.noteType == 'task') {
searchQuery: state.searchQuery, final tasks = await ref.read(tasksApiProvider).getAll();
); final items = {
await _fetchFromScratch(); 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 ───────────────────────────────────────────── // ── Scroll-triggered loaders ─────────────────────────────────────────────
+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 { Future<void> loadMore() async {
final current = state.value; final current = state.value;
if (current == null || current.loadingMore || !current.hasMore) return; 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'); .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({ Future<Project> create({
required String title, required String title,
String? description, String? description,
+5
View File
@@ -17,6 +17,11 @@ class TasksNotifier extends AsyncNotifier<List<Task>> {
return ref.watch(tasksRepositoryProvider).getAll(); return ref.watch(tasksRepositoryProvider).getAll();
} }
Future<void> refresh() async {
final fresh = await ref.read(tasksRepositoryProvider).getAll();
state = AsyncData(fresh);
}
Future<Task> create({ Future<Task> create({
required String title, required String title,
String? description, String? description,
+1 -1
View File
@@ -462,7 +462,7 @@ class _GradientSendButton extends StatelessWidget {
: const LinearGradient( : const LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: [Color(0xFF6366F1), Color(0xFF4F46E5)], colors: [Color(0xFF7C3AED), Color(0xFF5B21B6)],
), ),
color: disabled ? scheme.onSurface.withValues(alpha: 0.12) : null, color: disabled ? scheme.onSurface.withValues(alpha: 0.12) : null,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
+1 -1
View File
@@ -68,7 +68,7 @@ class CalendarScreen extends ConsumerWidget {
const Divider(height: 1), const Divider(height: 1),
Expanded( Expanded(
child: RefreshIndicator( child: RefreshIndicator(
onRefresh: () async => ref.invalidate(calendarProvider), onRefresh: () => ref.read(calendarProvider.notifier).refresh(),
child: _AgendaList( child: _AgendaList(
events: events:
cal.eventsByDay[dateOnly(cal.selectedDay)] ?? [], cal.eventsByDay[dateOnly(cal.selectedDay)] ?? [],
+1 -1
View File
@@ -57,7 +57,7 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
'#EF4444', '#EF4444',
'#F59E0B', '#F59E0B',
'#10B981', '#10B981',
'#6366F1', '#7C3AED',
'#8B5CF6', '#8B5CF6',
'#EC4899', '#EC4899',
]; ];
+1 -1
View File
@@ -30,7 +30,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
@override @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) { if (state == AppLifecycleState.resumed) {
ref.invalidate(messagesProvider(widget.conversationId)); ref.read(messagesProvider(widget.conversationId).notifier).refresh();
} }
} }
@@ -64,7 +64,7 @@ class ConversationsTabScreen extends ConsumerWidget {
); );
} }
return RefreshIndicator( return RefreshIndicator(
onRefresh: () async => ref.invalidate(conversationsProvider), onRefresh: () => ref.read(conversationsProvider.notifier).refresh(),
child: ListView.builder( child: ListView.builder(
itemCount: convs.length, itemCount: convs.length,
itemBuilder: (ctx, i) { itemBuilder: (ctx, i) {
@@ -55,11 +55,11 @@ class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
} }
Color _parseColor(String? hex) { Color _parseColor(String? hex) {
if (hex == null || hex.isEmpty) return const Color(0xFF6366F1); if (hex == null || hex.isEmpty) return const Color(0xFF7C3AED);
try { try {
return Color(int.parse(hex.replaceFirst('#', '0xFF'))); return Color(int.parse(hex.replaceFirst('#', '0xFF')));
} catch (_) { } catch (_) {
return const Color(0xFF6366F1); return const Color(0xFF7C3AED);
} }
} }
@@ -151,8 +151,10 @@ class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
return RefreshIndicator( return RefreshIndicator(
onRefresh: () async { onRefresh: () async {
setState(() => _pendingStatus.clear()); setState(() => _pendingStatus.clear());
ref.invalidate(projectTasksProvider(widget.projectId)); await Future.wait([
ref.invalidate(projectMilestonesProvider(widget.projectId)); ref.refresh(projectTasksProvider(widget.projectId).future),
ref.refresh(projectMilestonesProvider(widget.projectId).future),
]);
}, },
child: CustomScrollView( child: CustomScrollView(
slivers: [ slivers: [
+1 -1
View File
@@ -97,7 +97,7 @@ class _NewsScreenState extends ConsumerState<NewsScreen> {
const Divider(height: 1), const Divider(height: 1),
Expanded( Expanded(
child: RefreshIndicator( child: RefreshIndicator(
onRefresh: () async => ref.invalidate(newsProvider), onRefresh: () => ref.read(newsProvider.notifier).refresh(),
child: ListView.builder( child: ListView.builder(
padding: padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 8), const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
+1 -1
View File
@@ -19,7 +19,7 @@ class ProjectsScreen extends ConsumerWidget {
data: (projects) => projects.isEmpty data: (projects) => projects.isEmpty
? const Center(child: Text('No projects yet.')) ? const Center(child: Text('No projects yet.'))
: RefreshIndicator( : RefreshIndicator(
onRefresh: () async => ref.invalidate(projectsProvider), onRefresh: () => ref.read(projectsProvider.notifier).refresh(),
child: ListView.separated( child: ListView.separated(
itemCount: projects.length, itemCount: projects.length,
separatorBuilder: (_, _) => const Divider(height: 1), separatorBuilder: (_, _) => const Divider(height: 1),
+2 -2
View File
@@ -209,8 +209,8 @@ void main() {
final event = CalendarEvent.fromJson(json); final event = CalendarEvent.fromJson(json);
expect(event.id, equals(10)); expect(event.id, equals(10));
expect(event.title, equals('Team meeting')); expect(event.title, equals('Team meeting'));
expect(event.startDt, equals(DateTime.parse('2026-04-07T09: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'))); expect(event.endDt, equals(DateTime.parse('2026-04-07T10:00:00+00:00').toLocal()));
expect(event.allDay, isFalse); expect(event.allDay, isFalse);
expect(event.description, equals('Weekly sync')); expect(event.description, equals('Weekly sync'));
expect(event.location, equals('Room 4')); expect(event.location, equals('Room 4'));